import { z } from "zod"; import { NeedsActionSchema } from "../../policy/needs-action.js"; // The runner link's edges: the /system/runners/connect handshake, boot env, and turn placement (procedures live in // contracts/runner.contract.ts). A runner is a sandbox image in runner mode, no browser owner or tunnel, that dials its // parent over one outbound socket authenticating in its first frame. // Boot env for a runner container: who to dial, and the single-use pairing token to enroll with. export const RUNNER_PARENT_URL_ENV = "RUNNER_PARENT_URL"; export const RUNNER_PAIR_TOKEN_ENV = "RUNNER_PAIR_TOKEN"; // First frame on /system/runners/connect: plain JSON, not oRPC, since a socket must prove whose it is before a typed // link exists. Token rides the frame, never the URL. export const RunnerHelloSchema = z.object({ type: z.literal("runner-hello"), // The runner's durable token, redeemed once from the pairing over /system/runners/enroll. token: z.string(), // The daemon build the runner runs, so an old image is visible rather than mysteriously wrong. version: z.string(), // Compared against what the parent runs to report parity (outdated → rebuild action); an outdated runner still runs // turns rather than being refused. image: z.string(), // The image `image` was composed from (SANDBOX_BASE_IMAGE). Parity compares this, not `image`: an overlay build's // tag names its own container (`intentic-sandbox-env-:`), so two sides on one base and one overlay // never share it. Absent on an old image, which is then compared by `image` alone. baseImage: z.string().optional(), // That base's registry digest (SANDBOX_BASE_DIGEST), when the runner's machine could read one; parity then compares // the exact image rather than a tag two machines may have pulled at different moments (state/sandbox-build.ts). baseDigest: z.string().optional(), channel: z.string().optional(), overlayHash: z.string().optional(), // Runner's declared settings-only sandbox.toml (definition.ts), diffed against the parent's to itemize drift; // absent on an old image. definitionToml: z.string().optional(), }); export type RunnerHello = z.infer; // The URL a runner dials, given its parent's public URL. One builder, so `ic`, the Fly provisioner and the // daemon route cannot disagree about where the door is (hostConnectUrl's rule). /* HOW OFTEN THE PARENT PINGS A CONNECTED RUNNER, read by both sides of the socket. */ export const RUNNER_HEARTBEAT_MS = 30_000; export const runnerConnectUrl = (parentUrl: string): string => `${parentUrl.replace(/^http/, "ws").replace(/\/$/, "")}/system/runners/connect`; // Where a runner redeems its pairing for a durable token, once, over plain HTTPS. export const runnerEnrollUrl = (parentUrl: string): string => `${parentUrl.replace(/\/$/, "")}/system/runners/enroll`; // The parent's git door for one repo: stock git fetch/push against its smart-HTTP git dirs, authenticated by the // runner's own token as bearer. export const runnerGitUrl = (parentUrl: string, repo: string): string => `${parentUrl.replace(/\/$/, "")}/system/runners/git/${encodeURIComponent(repo)}`; // Where a runner's push lands; never refs/heads/agent/ directly since git refuses updating a checked-out ref. The // parent advances that branch by hard-resetting its mirror worktree to this ref. export const runnerIncomingRef = (conversationId: string): string => `refs/runner-incoming/${conversationId}`; // Container name for a runner on its host machine; the `ic`-prefix (runner.rs SLUG_PREFIX) must match exactly, or // updates target a container that doesn't exist. export const runnerSlug = (name: string): string => `runner-${name}`; // Credential doors: a runner's turns spend the origin sandbox's model providers, never accounts of its own. The parent // answers with the least that travels (a minted token or a route); refresh tokens never leave it. export const runnerCredentialsUrl = (parentUrl: string): string => `${parentUrl.replace(/\/$/, "")}/system/runners/credentials`; export const runnerCredentialRefreshUrl = (parentUrl: string): string => `${parentUrl.replace(/\/$/, "")}/system/runners/credentials/refresh`; // Re-serves the parent's model translator to runners: subscription-routed turns dial this authenticated proxy instead // of syncing auth files, and the parent forwards to its own loopback translator. export const runnerTranslatorPath = "/system/runners/translator"; export const runnerTranslatorUrl = (parentUrl: string): string => `${parentUrl.replace(/\/$/, "")}${runnerTranslatorPath}`; export const RunnerCredentialRequestSchema = z.object({ // Provider as the turn names it (absent = claude); an open vocabulary, including endpoint/. agent: z.string().optional(), account: z.string().optional(), model: z.string().optional(), }); export type RunnerCredentialRequest = z.infer; // What travels back, by kind: // oauth: a native-Claude access token minted for this turn; `account` set for a stored account, absent for the // container-env fallback // parent-translator: runs against the parent's translator through the proxy; the runner supplies its own token as // bearer // endpoint: a foreign endpoint capability the runner dials directly, with its own bearer // A refusal uses the same codes as local resolution, so a remote refusal reads like a local one. export const RunnerCredentialSchema = z.union([ z.object({ ok: z.literal(true), kind: z.literal("oauth"), accessToken: z.string(), account: z.string().optional() }), z.object({ ok: z.literal(true), kind: z.literal("parent-translator"), model: z.string(), trial: z.boolean().optional() }), z.object({ ok: z.literal(true), kind: z.literal("endpoint"), baseUrl: z.string(), authToken: z.string(), model: z.string(), trial: z.boolean().optional(), }), z.object({ ok: z.literal(false), code: z.enum(["subscription-required", "claude-reauth", "trial-unavailable"]).optional(), message: z.string(), }), ]); export type RunnerCredential = z.infer; export const RunnerCredentialRefreshRequestSchema = z.object({ account: z.string().min(1), // Token the harness was refused with; rotation supersedes exactly that one, never a token already rotated. rejected: z.string().min(1), }); export type RunnerCredentialRefreshRequest = z.infer; // Absent `accessToken` means the parent couldn't re-mint (a revoked account); the harness gives up as it would locally. export const RunnerCredentialRefreshSchema = z.object({ accessToken: z.string().optional() }); export type RunnerCredentialRefresh = z.infer; // What a runner is, hardware-wise, and how full it is right now: what the placement picker shows and the fleet advice // (runners/fleet-advice.ts) weighs. Re-asked on demand rather than only at connect, so the live half stays live. export const RunnerFactsSchema = z.object({ cpus: z.number().int().positive(), memoryMb: z.number().int().positive(), freeDiskMb: z.number().int().nonnegative(), // 0..1 over the last minute; a coarse busy signal for a picker, not a metrics feed. load: z.number().nonnegative(), // The runner's own resource budget, the figures its turn door admits by: what is free under its memory limit, and // that limit. Absent from a runner that predates them, which is then sized by `memoryMb` alone. memoryFreeMb: z.number().int().nonnegative().optional(), memoryLimitMb: z.number().int().positive().optional(), // Pressure stall over the last ten seconds, in percent: anything waiting for a CPU, everything waiting on memory. // Absent where the kernel does not report it. cpuPressure: z.number().nonnegative().optional(), memoryStall: z.number().nonnegative().optional(), }); export type RunnerFacts = z.infer; // Whether a runner matches this sandbox's image, channel and approved overlay: `current`, `outdated` (gets an update // button), or `unknown` (never connected, or a parent that can't name its own image). Reported, never enforced. export const RunnerParitySchema = z.enum(["current", "outdated", "unknown"]); export type RunnerParity = z.infer; // One runner as the owner's list shows it: enrolled state plus whatever the hub currently knows — HostSummary's shape, // retold with parity instead of platform/scopes. export const RunnerSummarySchema = z.object({ id: z.string(), // The connected device that requested it (Devices view); absent for one started by hand on a machine. host: z.string().optional(), online: z.boolean(), version: z.string().optional(), image: z.string().optional(), channel: z.string().optional(), overlayHash: z.string().optional(), facts: RunnerFactsSchema.optional(), lastSeen: z.number().optional(), // Computed once by the daemon (buildParity, state/sandbox-build.ts), so every surface shows the same badge. parity: RunnerParitySchema, // Where the runner's environment differs from this sandbox's, one line per difference; absent if never reported, // empty if they agree. A Setting line is fixable live; an overlay line needs a rebuild. drift: z.array(NeedsActionSchema).optional(), }); export type RunnerSummary = z.infer; // GET /system/runners: every enrolled runner, the one list the Devices view, the placement picker and the Access tab read. export const RunnersListSchema = z.object({ runners: z.array(RunnerSummarySchema) }); export type RunnersList = z.infer; // What a runner answers when it takes a definition (`applyDefinition`), and POST /system/runners/{id}/definition/sync // relays: the settings it now runs. export const RunnerDefinitionAppliedSchema = z.object({ settings: z.array(z.string()) }); export type RunnerDefinitionApplied = z.infer; // A workspace sync, narrated line by line as it happens (first contact clones whole repos). `op`: `pull` brings the // runner's checkout current before a turn, `push` returns the branch after; `repos` is the parent's own record, since // the runner's mirror could be stale. export const RunnerSyncSchema = z.object({ op: z.enum(["pull", "push"]), conversationId: z.string().min(1), branch: z.string().min(1), repos: z.array( z.object({ repo: z.string().min(1), // "" for the workspace root itself; every other repo sits at its root-relative dir. dir: z.string(), mainBranch: z.string().min(1), }), ), }); export type RunnerSync = z.infer; export const RunnerSyncLineSchema = z.union([ z.object({ kind: z.literal("line"), text: z.string() }), z.object({ kind: z.literal("done"), ok: z.boolean(), detail: z.string().optional() }), ]); export type RunnerSyncLine = z.infer; // One dispatched turn, not AgentTurnSchema: that's the browser's request full of fields the parent resolves first. This // is the residue after resolution, plus attachments inline, since the runner has no route to the parent's attachment // store. export const RunnerTurnSchema = z.object({ conversationId: z.string().min(1), branch: z.string().min(1), prompt: z.string(), provider: z.string(), harness: z.string(), model: z.string().optional(), effort: z.string().optional(), thinking: z.boolean().optional(), fast: z.boolean().optional(), // Which origin-sandbox account pays; credentials resolve against the parent, naming an account there. account: z.string().optional(), sessionId: z.string().optional(), // Base64 since frames are JSON and attachment count is capped; `path` is the workspace-relative path the prompt // already names. attachments: z.array(z.object({ path: z.string().min(1), bytesBase64: z.string() })).optional(), }); export type RunnerTurn = z.infer; // OFFLOADED COMMANDS: one shell line the parent hands a runner instead of running it itself (settings `offload`). The // code travels as a snapshot commit of the parent's working tree, uncommitted and untracked work included, parked under // this ref in the repo's git dir for the runner to fetch through the git door, and deleted once the run ends. export const OFFLOAD_REF_PREFIX = "refs/intentic-offload/"; export const offloadRef = (runId: string): string => `${OFFLOAD_REF_PREFIX}${runId}`; const OffloadRunIdSchema = z.string().regex(/^[a-z0-9][a-z0-9-]{7,63}$/u); const EnvNameSchema = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/u); export const RunnerCommandSchema = z.object({ runId: OffloadRunIdSchema, // The repo id the git door serves (a /history/gits entry: `root` for the workspace itself). repo: z.string().min(1), // The snapshot commit's ref in that repo, always under OFFLOAD_REF_PREFIX. ref: z.string().startsWith(OFFLOAD_REF_PREFIX), // Where in the repo the line runs, repo-relative; "" is the repo root. cwd: z.string().refine((path) => !path.startsWith("/") && !path.split("/").includes(".."), "must stay inside the repo"), command: z.string().min(1).max(64_000), // Values set for the line, beside the runner's own environment. Never a secret: a line that needs one runs here. env: z.record(EnvNameSchema, z.string()).default({}), // Variables the line writes a file to (a report path): the runner points each at a scratch file and hands the file // back with the exit. exports: z.array(EnvNameSchema).max(8).default([]), // The heavy-command rule it matched here, named in the runner's own output and queue. label: z.string().min(1).max(80), timeoutMs: z .number() .int() .positive() .max(6 * 60 * 60_000) .optional(), }); export type RunnerCommand = z.infer; // What a running command streams back: narration of the runner's own steps (fetch, install, queue), the line's output // as it comes, and last its end: the exit, every file it changed as a binary git patch against the snapshot, and the // exported files, base64. export const RunnerCommandFrameSchema = z.union([ z.object({ kind: z.literal("status"), text: z.string() }), z.object({ kind: z.literal("output"), stream: z.enum(["stdout", "stderr"]), text: z.string() }), z.object({ kind: z.literal("exit"), code: z.number().int(), signal: z.string().optional(), // Why the run did not end on its own: stopped, timed out, or never started (a fetch or install that failed). failure: z.string().optional(), // False when the line never started there, which sends it back to run on the parent. ran: z.boolean().default(true), patchBase64: z.string().optional(), files: z.record(EnvNameSchema, z.string()).default({}), }), ]); export type RunnerCommandFrame = z.infer; // Where a conversation runs, fixed on its first turn like `isolated` placement: later turns follow the registry, not // the sender. `runner` implies isolation since a remote conversation is branch-anchored; absent means local. export const AgentPlacementSchema = z.union([z.object({ kind: z.literal("local") }), z.object({ kind: z.literal("runner"), id: z.string().min(1) })]); export type AgentPlacement = z.infer;