UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

597 lines (596 loc) • 41.9 kB
/** * Remote execution seam (design/48 §5) — the type-only contract a **remote/containerised** `ExecutionEnv` * implementation (E2B/Firecracker, owned by the service control plane) must satisfy so the agent's "hand" * (design/44) can act inside an isolated, *stateful*, *suspendable* workspace instead of the in-process * `NodeExecutionEnv`. * * **This file is a SEAM, not an implementation.** The real `RemoteContainerExecutionEnv` (E2B SDK, warm * pools, snapshot lifecycle, tier routing) lives in the service and is driven by real integration needs — * exactly like `CheckpointStore` (design/45). Core owns the seam shape; service owns the backend. * * Design rulings folded in (design/48 §10/§11 DeepSeek council): * - **#1 [blocker] interface extension, not capability probing**: `RemoteExecutionEnv extends ExecutionEnv`. * The base {@link ExecutionEnv} is UNCHANGED — `NodeExecutionEnv`/`StubExecutionEnv` need zero edits, and a * consumer that only needs buffered fs/shell keeps accepting the base type. * - **#3 [major] do not overload `exec`**: `exec()` stays buffered; streaming is a NEW {@link execStream}. * - **#4 [major] suspend × in-flight exec must not hang**: see {@link suspendVM}. * - **#8 [major] post-resume consistency**: see {@link postResumeInit}. * - **#10 naming**: the isolation level is `sandboxTier` (not `tier`) to avoid clashing with the existing * timeout-/degradation-"tier" in the codebase. * - **#16 suspend/resume disambiguation**: the VM-lifecycle methods are `suspendVM`/`resumeVM` to keep them * distinct from the Runner gate-resume and design/45 wake/resume semantics. * * **Deferred (NOT modelled here — they are runtime, not seam):** actually suspending a running task and * persisting the {@link WorkspaceHandle} into a design/45 `Checkpoint`; the `status:"suspended"` orchestrator * mapping; tier *classification* (the static table + Tier1-safe allow-list + fail-closed routing) — all are * service/design-45 implementation-time concerns. Core only declares the seam those will plug into. */ import type { ExecutionEnv } from "../internal/harness-types.js"; /** Snapshot identifier returned by {@link RemoteExecutionEnv.suspendVM}; opaque to core, resolved by the provider. */ export type SnapshotId = string; /** * Token identifying a still-running remote session, used by {@link RemoteExecutionEnv.reconnect} to re-attach * the control plane's transport after a control-plane replica crash/failover. The underlying VM state lives * independently of any single control-plane TCP connection (a Firecracker process survives a dropped client), * so reconnect re-binds rather than re-creates. */ export type SessionToken = string; /** Isolation routing level (design/48 §2). v1 runs everything as Tier 2; classification is DEFERRED to service. */ export type SandboxTier = 1 | 2; /** * Serializable identity of a remote workspace (design/48 §5 gap 1). The control plane persists this into its * durable state (and design/45 `Checkpoint`) so a *different* replica can reconnect/resume the same workspace. */ export interface WorkspaceHandle { /** Provider/VM/sandbox id (e.g. an E2B sandbox id). */ sandboxId: string; /** Provider identifier (e.g. `"e2b"`), so the control plane routes reconnect/destroy to the right backend. */ provider: string; /** Absolute mount path of the workspace root inside the remote env; remote paths normalize against this. */ mountPath: string; /** Set when this env was produced by resuming a snapshot ({@link RemoteExecutionEnv.resumeVM}), else undefined. */ snapshotId?: SnapshotId; /** Token for {@link RemoteExecutionEnv.reconnect} when the VM is still running (vs suspended to a snapshot). */ sessionToken?: SessionToken; /** How a checkpointed workspace is restored on resume (1.257.2 hardening): * `"park_only"` = durable-park-only suspend degrade (non-suspendable env; workspace persists on the * target, resume skips `resumeVM`). Absent = legacy/snapshot handle — resume treats a MISSING * `snapshotId` as corruption (fail-closed) unless the resumed env is itself non-suspendable * (tolerance for park handles minted by 1.257.1 before this field existed). */ restoreMode?: "park_only"; /** * design/380 O1④ — explicit identity of the physical/target device this workspace lives on * (device lane; an SSH/ADB adapter MAY also stamp it). MINTER-STATED: consumers read this field, * never parse an identity out of `sandboxId`. Plain string → rides CheckpointState.workspaceHandle * (all-string whitelist) with zero schema movement. */ deviceId?: string; } /** * A reference to a secret injected at {@link RemoteExecutionEnv.connect}/{@link RemoteExecutionEnv.postResumeInit} * time (design/48 §5 gap 7) — never baked into the image. The control plane resolves {@link ref} to a value out of * band; only the reference travels through core. */ export interface SecretRef { /** Logical name the secret is exposed under (an env-var name in the remote env for `destination:"remote"`). */ name: string; /** Opaque locator the control plane resolves to a value (vault path, KMS key id, …). */ ref: string; /** * Where the resolved secret is consumed (design/61 §9 B, council). Default `"remote"` (the original * semantics): injected **into the remote env** as `name` (E2B env-var) — visible to code running there. * `"adapter"`: used by the **adapter itself** to establish/authenticate the connection (an SSH private key, * an ADB token) and **MUST NOT be injected into the target machine/device** — a credential that reaches an * un-isolated target's environment is the lethal trifecta (design/53 §C). An adapter MUST honor this. */ destination?: "remote" | "adapter"; } /** Configuration for {@link RemoteExecutionEnv.connect}. */ export interface RemoteConnectConfig { /** Resume from this snapshot instead of provisioning a fresh workspace. */ snapshotId?: SnapshotId; /** Secrets to inject at connect time (design/48 §5 gap 7). */ secrets?: SecretRef[]; /** Abort provisioning/connection. */ abortSignal?: AbortSignal; } /** * Options for the VM-lifecycle ops {@link RemoteExecutionEnv.suspendVM} / {@link RemoteExecutionEnv.resumeVM} * (design/49 v1.5, code-ready council BUG#2). Parity with {@link RemoteConnectConfig.abortSignal} / * {@link ExecStreamOptions.signal}: without an abort hook a hung provider pause/restore (network partition) * would pin the calling worker forever. The implementation races the op against this signal + an internal * timeout; on abort it returns `{ok:false}` with code `"aborted"`. */ export interface VmLifecycleOptions { /** Abort a hung pause/restore. */ abortSignal?: AbortSignal; /** * (resume workspace-root fidelity) — the CHECKPOINTED {@link WorkspaceHandle} of the workspace * being restored, passed by the engine on the `resumeVM` leg. An adapter SHOULD bind the restored * workspace root (cwd / mount) to `priorHandle.mountPath` rather than its CURRENT deployment * configuration: across a config-knob change window (e.g. a mount-path rotation) the resumed task's * execs and artifacts must land in the root the task was checkpointed under, not the new one. An * adapter that cannot honor the prior root MUST still return its ACTUAL `mountPath` in the restored * handle (never echo `priorHandle.mountPath` unhonored) — the engine compares the two and surfaces a * divergence observation. Absent on non-resume ops and from pre-1.360 engines (adapters treat absence * as the legacy current-config behavior). */ priorHandle?: WorkspaceHandle; } /** * One chunk streamed from {@link RemoteExecutionEnv.execStream} (design/48 §5 gap 3, council BUG#3). A run is a * sequence of `stdout`/`stderr` chunks terminated by exactly one `exit` chunk carrying the typed exit code. * * The async iterator THROWS a {@link RemoteExecutionError} if the stream fails before an `exit` chunk arrives * (transport loss, or a {@link RemoteExecutionEnv.suspendVM} that aborts the in-flight command) — that is the * streaming analogue of the base {@link ExecutionEnv}'s never-throw `Result` contract, and it guarantees a * consumer is never left awaiting a chunk that will never come. */ export type OutputChunk = { type: "stdout"; data: string; } | { type: "stderr"; data: string; } | { type: "exit"; exitCode: number; }; /** Options for {@link RemoteExecutionEnv.execStream}. */ export interface ExecStreamOptions { /** * Isolation routing hint (design/48 §2; renamed from `tier` per §11 #10). The *classification* that decides * a command's tier — static tool table + Tier1-safe sub-command allow-list, fail-closed to Tier 2 — is the * env/control-plane's job and is DEFERRED; this param only lets a caller pass an explicit route when known. */ sandboxTier?: SandboxTier; /** Working directory; relative paths resolve against the workspace root. */ cwd?: string; /** Extra environment variables for the command. */ env?: Record<string, string>; /** * Total command wall-clock timeout in seconds. **🔴 Per-command — independent of the env/sandbox lifetime.** * A remote adapter MUST NOT fall back to the sandbox lifetime when this is unset (service [45]: an E2B adapter * that defaulted a missing per-command timeout to `cfg.timeoutMs` = the sandbox lifetime made a seconds-long * command wait the *whole sandbox lifetime*, ~30 min, on a provider RPC hang). "No `timeout`" means "no * wall-clock cap on a *making-progress* command" — NOT "wait until the env dies"; liveness is still bounded by * {@link readTimeoutMs} (see below). Distinct concerns: `timeout` caps a slow-but-progressing command; * `readTimeoutMs` catches a *hung/unreachable* one. */ timeout?: number; /** Abort the command. Aborting ends the stream with a {@link RemoteExecutionError} (code `"aborted"`, no `exit` chunk). */ signal?: AbortSignal; /** * Idle/liveness read timeout (ms). If no {@link OutputChunk} arrives within this window, `execStream` ends * with a {@link RemoteExecutionError} (code `"liveness_no_output"`) instead of hanging. **Needed in practice for remote * backends** (design/48 §5, service [7]): E2B issue #1128 — a *streaming* call sets no read timeout (only unary * does), so an unreachable sandbox hangs the worker forever. Distinct from `timeout` (total wall-clock). * * 🔴 **Liveness contract (service [45]/[59]):** a remote adapter SHOULD enforce a default liveness bound even * when this is unset (a missing idle timeout MUST NOT mean "wait forever / until the env dies"), and that bound * MUST cover the **command-creation/handshake RPC**, not only the post-first-chunk loop — a provider hang * typically occurs at creation, before any chunk arrives, so an idle timer started only after the first chunk * misses it. On trigger, throw {@link RemoteExecutionError} with code `"liveness_no_output"` (retryable by the * caller per the command's idempotency — the adapter MUST NOT blind-retry). The wall-clock {@link timeout} * keeps `"timeout"`: the two cuts are different words because they explain themselves with different numbers. */ readTimeoutMs?: number; /** Per-call output cap (design/48 §5 gap 8): stop streaming after this many bytes to avoid a `find /` flooding the control plane. */ maxOutputBytes?: number; } /** Stable error codes for the remote seam's lifecycle/streaming operations (design/48 §5/§11 BUG#2). */ export type RemoteExecutionErrorCode = /** A lifecycle/exec op failed because the workspace is (or became) suspended. */ "suspended" /** {@link RemoteExecutionEnv.suspendVM} refused because a command was still in flight (council #4). */ | "command_in_flight" /** Connect/resume/reconnect failed to reach or provision the remote workspace. */ | "connect_failed" /** A post-resume consistency step failed (design/48 §5/#8) — caller must destroy the env. */ | "post_resume_failed" /** The command/stream was aborted via its `AbortSignal`. */ | "aborted" /** * A bound on elapsed time was exceeded: a command's wall-clock {@link ExecStreamOptions.timeout}, or the idle * bound of a lifecycle / filesystem RPC (service [45]/[59]). **Retryable** by the caller per the op's * idempotency (the adapter MUST NOT blind-retry). Distinct from `"aborted"` (a caller-driven `AbortSignal`), * `"connect_failed"` (workspace provisioning, not a per-command hang) and `"liveness_no_output"` (an exec * stream's silence window — below). */ | "timeout" /** * An exec stream produced no {@link OutputChunk} for its liveness window ({@link ExecStreamOptions.readTimeoutMs}, * or the adapter's default bound) — including a provider RPC hang at command creation, before the first chunk * (service [45]/[59]). A hang detector, not a budget: it is the word for the cut that used to share `"timeout"` * with the wall-clock budget, and the two explain themselves with different numbers (the window versus the * budget the caller asked for), so a consumer must be able to tell them apart by code. **Retryable** by the * caller per the command's idempotency (the adapter MUST NOT blind-retry), exactly as `"timeout"` is. */ | "liveness_no_output" /** * The op is not supported by this adapter's capabilities (design/61 §9 A) — e.g. `suspendVM`/`resumeVM` on a * non-suspendable SSH/ADB env. Permanent for this env; check {@link RemoteExecutionEnv.capabilities} first. */ | "unsupported" /** * A **transient** authentication step that is expected to succeed on a retry (design/61 §9 C) — e.g. an ADB * device awaiting first-time authorization (connect once → user authorizes → connect again succeeds). * **Retryable** (typically retry-exactly-once). Distinct from {@link "auth_failed"} so a real rejection isn't * retried and a bounded ADB retry doesn't false-kill an SSH transient. */ | "auth_transient" /** * **Permanent** authentication failure (design/61 §9 C) — wrong/rejected SSH key, revoked token. **NEVER * retry** (retrying burns the whole attempt budget and can lock accounts). The caller must surface it. */ | "auth_failed" /** * The transport connection dropped mid-session (TCP RST, network partition, peer close) — design/61 §9 C/#14. * **Retryable** by re-establishing the connection (idempotent for a persistent SSH/ADB session). An adapter * MUST map raw transport errors (DNS `ENOTFOUND`, TCP RST) onto a typed code, not leave them as `"unknown"`, * else a retry whitelist misses them. */ | "transport_lost" /** * design/380 O12 — the op was COMMITTED to the remote target and its outcome is UNKNOWABLE (the target * went unreachable / restarted / was revoked after dispatch-commit; an execStream cut where the target * protocol's own server has ruled the outcome unknowable). NEVER auto-retried and deliberately NOT in * {@link RETRYABLE_REMOTE_ERROR_CODES}: the command may have already executed — a retry whitelist that * contained it would re-drive committed side effects. Distinct from `transport_lost` (connection story * known, idempotency-gated retry after reconnect). Message MUST carry the verify-first sentence ("may * have already executed on the target; verify its effect before re-running"). */ | "outcome_unknown" /** Unclassified provider/transport failure. */ | "unknown"; /** * The RETRYABLE half of {@link RemoteExecutionErrorCode} — the codes whose own contract above says a retry * can succeed (`auth_transient`: retry-exactly-once after the device is authorized; `connect_failed`: the * workspace could not be reached/provisioned this attempt; `timeout` / `liveness_no_output`: a time bound * tripped; `transport_lost`: re-establish the connection). Every OTHER code is permanent for this attempt * (`auth_failed`/`unsupported`), caller-driven (`aborted`), unclassifiable (`unknown`), or * outcome-unknowable (`outcome_unknown` — design/380 O12: the op may have ALREADY executed, so a retry * re-drives committed side effects) — retrying them burns budget or, worse, re-drives a rejected * credential or a committed op. * * ONE list, two consumers, so a caller's retry decision and its DISCLOSURE can never disagree: the engine * retries only IDEMPOTENT ops on these codes (see {@link withRetry}'s red line — a snapshot-taking * `suspendVM` is never retried), and it stamps `retryable` on the failure it reports to the caller for the * ops it does NOT retry, so a deployment can decide (refresh the credential and re-resume vs give up) * without parsing prose. */ export declare const RETRYABLE_REMOTE_ERROR_CODES: readonly RemoteExecutionErrorCode[]; /** Is `code` in the {@link RETRYABLE_REMOTE_ERROR_CODES} family? */ export declare function isRetryableRemoteErrorCode(code: RemoteExecutionErrorCode): boolean; /** * Error surfaced by {@link RemoteExecutionEnv} lifecycle ops and by {@link RemoteExecutionEnv.execStream}'s * iterator. Mirrors the vendored `ExecutionError` shape but carries the remote-specific code set; it is kept * separate from the vendored `ExecutionErrorCode` union deliberately — those codes only need to widen once the * design/45 suspend/resume *runtime* lands and the Runner/orchestrator layer must thread `"suspended"`. */ export declare class RemoteExecutionError extends Error { readonly code: RemoteExecutionErrorCode; constructor(code: RemoteExecutionErrorCode, message: string, cause?: Error); } /** * A remote, stateful, suspendable {@link ExecutionEnv} (design/48 §5). Extends the base with workspace identity, * connection lifecycle, streaming exec, and post-resume reconciliation. Every method follows the base contract: * **never throw/reject** — encode failures in the returned `Result` (the one exception is {@link execStream}, * whose iterator may throw mid-stream; see {@link OutputChunk}). * * Lifecycle methods are typed but their RUNTIME wiring (task suspension, `Checkpoint` persistence of the * {@link WorkspaceHandle}, cross-replica resume) is implemented with design/45 — see the file header. * * 🔴 **Universal liveness contract (service [45]/[59] class-fix) — applies to EVERY remote RPC**, not just * `execStream`: the inherited {@link ExecutionEnv} filesystem ops (`readTextFile`/`writeFile`/`listDir`/…), the * streaming/buffered exec, AND the VM-lifecycle ops below (`connect`/`reconnect`/`suspendVM`/`resumeVM`/ * `postResumeInit`/`destroy`). Each is a network round-trip to a provider that can hang. The contract: * 1. **Every remote RPC SHOULD enforce a bounded default liveness/idle timeout** even when no `abortSignal`/ * timeout is given — a hung provider call MUST NOT wait until the env/sandbox lifetime expires (the * service [45] bug, but generalized: it was found on `exec`, and `files.read`/`resumeVM`/etc. share the * exact exposure). An optional `abortSignal` (where present) composes with, but does not replace, this default. * 2. **🔴 Bound on LIVENESS (no progress), not a fixed short wall-clock** — distinguish *hung* from *slow but * progressing* (search [60] / service [50] data-transfer sharpening). A **data-transfer** op — a large-workspace * `suspendVM` snapshot, a big `writeFile`/`readTextFile` — may legitimately run for minutes; a fixed short * wall-clock would false-kill the (durable-checkpoint *命门*) path. So such ops bound on an **idle/no-heartbeat** * window (reset by observable progress), not total elapsed time. Cheap control RPCs (`isRunning`, a small * `connect` handshake) may use a short wall-clock. The adapter sizes the bound per op class. * 3. **A liveness breach surfaces as a typed, retryable error**: `RemoteExecutionError` code `"liveness_no_output"` * for an exec stream's silence window and `"timeout"` for the lifecycle ops' idle bound; for the inherited filesystem ops (which return the vendored `Result<_, FileError>`), * a `FileError` — but still **bounded, never an unbounded wait**. Retry is the caller's decision per the op's * idempotency; the adapter MUST NOT blind-retry. */ export interface RemoteExecutionEnv extends ExecutionEnv { /** * What this adapter can actually do (design/61 §9 A, council). The seam was built for E2B (a full * `{isolation:true, suspendable:true}` adapter); **partial-capability** adapters (SSH/ADB: `{false, false}` * — a real machine/device, not snapshotable and not isolated) declare it here. **Required + explicit** so * the orchestrator never has to guess: there is no safe default (assuming isolation when there is none is a * security hole; assuming suspendable crashes on `suspendVM`). Use {@link isSuspendable}/{@link isIsolated} * rather than the structural {@link isRemoteExecutionEnv} (method presence ≠ semantics — a stub `suspendVM` * that returns `"unsupported"` still satisfies the structural check). Extensible: add fields as new * partial-capability classes appear. */ readonly capabilities: { /** True iff actions are contained (E2B microVM); false for a real target (SSH host / ADB device). The * orchestrator tightens autonomy + the design/37 policy gate when this is false (design/53 zero-trust). */ isolation: boolean; /** True iff the env can be snapshot/`suspendVM`'d (E2B); false for SSH/ADB (durable suspend N/A). * * ⚠️ LOAD-BEARING INVARIANT (opus review 1.257.2): `suspendable:false` on a remote env also asserts * the workspace is EXTERNALLY DURABLE — it persists on the target across the env object's lifetime * (true for SSH hosts / ADB devices). The durable-park-only suspend degrade rests * on this: it skips `suspendVM` and trusts the factory to reconnect to the SAME workspace on resume. * An adapter for a non-suspendable EPHEMERAL backend (e.g. a snapshot-less container torn down with * the transport) must NOT be modeled as `suspendable:false` remote — it would silently take the * park-only branch and resume onto a fresh empty workspace. Model such a backend as a per-task env * without durable suspend instead (the human gate refuses it), or extend capabilities with an * explicit workspace-durability flag before building one. */ suspendable: boolean; /** True iff this adapter's {@link FileSystem.canonicalPath} is the AUTHORITY on where a path lands — * it answers for the very filesystem the commands run on, and a failure to answer is a failure of * the resolver rather than a namespace it cannot see into. Optional, and absent is not a * declaration: an adapter that says nothing keeps the lexical verdict for a chain its resolver answers * `not_supported` (no file face); a resolver that FAILS (any other non-answer) is a failure either way — * the read station records it and the command is unanswered. Declaring it says that even an * unanswerable chain is a question, not an absence — and the read * boundary's execute-time recheck then refuses rather than passing the written spelling through. * Read STRICTLY through {@link canonicalPathIsAuthoritative}, like every other capability whose * `true` changes what a containment leg does; a wrong-SHAPE value reads absent and is announced. */ canonicalPathAuthoritative?: boolean; }; /** Identity of the connected workspace (design/48 §5 gap 1). Synchronous: it is data the env already holds. */ workspaceHandle(): WorkspaceHandle; /** Provision/attach the remote workspace and inject secrets. Returns the resulting {@link WorkspaceHandle}. */ connect(config?: RemoteConnectConfig): Promise<{ ok: true; value: WorkspaceHandle; } | { ok: false; error: RemoteExecutionError; }>; /** * Snapshot the workspace and (provider permitting) stop billing; returns the {@link SnapshotId} to resume from. * * **Council #4 — must not leave an in-flight command hung.** If a {@link Shell.exec}/{@link execStream} is in * flight, an implementation MUST either (a) refuse with `RemoteExecutionError("command_in_flight")`, or (b) * abort the in-flight command — buffered `exec` then resolves an `Err` and an in-flight {@link execStream} * iterator throws `RemoteExecutionError("suspended")`. It must NOT silently snapshot and leave the command's * promise/iterator pending forever. * * **🔴 Atomicity contract (design/49 v1.5, code-ready council BUG#1/#13).** This is the FIRST durable side * effect of a remote durable-suspend, so its all-or-nothing semantics are load-bearing for the Runner's * commit ordering: * - `{ok:true}` ⇒ the VM is paused at `value` (a `SnapshotId`); the caller may now persist a checkpoint. * - `{ok:false}` ⇒ the VM is left in its ORIGINAL (running) state, untouched — the caller can safely fall * back to the synchronous `onAsk` gate as if no suspend was attempted (Runner: design/49 §4①). * An implementation MUST NOT return `{ok:false}` after it has already paused/snapshotted the VM. The gate * path means no command is in flight (the batch's prior calls are awaited, the gated call has not run), so * `command_in_flight` should not arise; if it does, the Runner treats it as an ordinary `{ok:false}` and * falls back to `onAsk` — see {@link RemoteExecutionErrorCode}. */ suspendVM(options?: VmLifecycleOptions): Promise<{ ok: true; value: SnapshotId; } | { ok: false; error: RemoteExecutionError; }>; /** Restore the workspace from a snapshot. Idempotent + re-entrant (a replica that crashed mid-suspend can be * superseded by another resuming the same {@link SnapshotId} without corruption — aligns with design/45 resolve-CAS). * Pass `options.abortSignal` so a hung restore cannot pin the resuming worker (design/49 BUG#2). */ resumeVM(snapshotId: SnapshotId, options?: VmLifecycleOptions): Promise<{ ok: true; value: WorkspaceHandle; } | { ok: false; error: RemoteExecutionError; }>; /** Re-attach the control-plane transport to a still-running VM after a control-plane reconnect (design/48 §5 gap 4). */ reconnect(sessionToken: SessionToken): Promise<{ ok: true; value: WorkspaceHandle; } | { ok: false; error: RemoteExecutionError; }>; /** * Re-establish consistency AFTER {@link resumeVM}/{@link reconnect} (design/48 §5 gap, council #8): re-fetch git * remote refs + invalidate stale package/index caches, and re-inject secrets (short-lived credentials may have * expired while suspended). Any failed step → `Err("post_resume_failed")` and the caller MUST destroy the env. * * Note: a resumed guest's network/long-lived connections (git remote / API / registry) are NOT guaranteed to * survive the snapshot (decision 2), so this is also where the VM-internal agent rebuilds those. * * 🔴 Ordering red line (design/48 §5/#6): at-rest encryption of the memory snapshot must be ensured BEFORE * secrets are injected — never let plaintext credentials land in an unencrypted snapshot. That encryption is a * service-side property of the snapshot store; this method must fail-and-clean if it cannot be guaranteed. * * `opts.abortSignal` (design/384 slice 2, ADDITIVE — implementations are not forced to change): an * early-release channel for a caller whose wait on this init is bounded (the park fence's paused-VM * compensation). Best-effort exactly like {@link suspendVM}'s — an implementation may ignore it; the * caller bounds its own wait and treats a deaf adapter as failed. */ postResumeInit(opts?: { abortSignal?: AbortSignal; }): Promise<{ ok: true; value: void; } | { ok: false; error: RemoteExecutionError; }>; /** * Stream a command's output (design/48 §5 gap 3) — the standard path for long build/test runs. Distinct from * the buffered base `exec()` (council #3: no overload). Yields `stdout`/`stderr` chunks then exactly one `exit` * chunk; the iterator throws {@link RemoteExecutionError} if the stream fails before `exit` (see {@link OutputChunk}). * * 🔴 **Timeout/liveness contract (service [45]/[59]) — applies to BOTH `execStream` and the base `exec`:** a * per-command timeout is **independent of the env/sandbox lifetime** and an adapter MUST NOT fall back to the * lifetime when none is given (else a hung provider RPC pins the worker for the whole sandbox lifetime). An * adapter SHOULD enforce a bounded default liveness/idle timeout that also covers the command-creation RPC, and * surface a silence breach as a typed, retryable {@link RemoteExecutionError} code `"liveness_no_output"` and a * wall-clock breach as `"timeout"`. See * {@link ExecStreamOptions.timeout} / {@link ExecStreamOptions.readTimeoutMs}. */ execStream(command: string, options?: ExecStreamOptions): AsyncIterable<OutputChunk>; /** * Tear down the workspace and release provider resources. Best-effort; must never throw (like `cleanup`). * **Must be idempotent** — safe to call more than once (the second call is a no-op). The Runner calls it * once on task end, and `prepareTask` calls it on a prepare-time throw; a remote impl may also be reaped, * so a defensive double-call must not error or double-bill. * * contract note: on a NON-isolated env (no `capabilities.isolation` — host lane, SSH host), * destroy() is workspace/object-level cleanup and must NOT reap still-running background processes — * that is `disposeBackgroundShells`' job, which honours the timeout/session keep-alive except-list * (monitor timeout anchor). An isolated env (container/VM) naturally takes everything down; * the Runner's envDying settle accounts for that with an accurate killed receipt beforehand. */ destroy(): Promise<void>; } /** Context handed to an {@link ExecutionEnvFactory} for each task (design/48 §5 / §7 Q7). Deliberately minimal: * per-task identity is enough to allocate/route a per-task container; richer routing (tenant/principal) is * captured in the factory closure by the trusted control plane that builds it. */ export interface ExecutionEnvFactoryContext { /** Resolved session id for the task — the stable identity of its per-task workspace. */ sessionId: string; /** * design/380 O1① — the run TREE's placement root (fixed point), minted by prepare AFTER session * resolution as `internals.placementRoot ?? internals.rootSessionId ?? sessionId` — a top-level * run's own resolved sessionId, every descendant's inherited root, verbatim (delegation and * workflow spawn chains re-thread an explicit `placementRoot` — C12; a bare DURABLE RESUME crosses * too since design/380 O1③: the suspend mint stamps the fixed point on the checkpoint and the * resume restores it into trusted internals — formerly the pinned residual). A target-bound * factory (device lane) keys its placement lookup on THIS, never on `sessionId` (a child's fresh * session id must not read as a new placement). Always present and non-empty — an empty value is * an assembly error the engine refuses loudly at the mint (`config.placement_root_invalid`) and a * factory MUST refuse loudly too (loud-bad-value posture), never default around. */ placementRootSessionId: string; /** Caller-supplied task id, when set on the `TaskSpec`. */ taskId?: string; /** * design/97 CORE-6 — per-task ISOLATION request threaded from the workflow's `ctx.agent({ isolation })` via * the TRUSTED `RunInternals` channel (never from the untrusted `TaskSpec`). When `"worktree"`, a * control-plane factory **MUST either mint a git-worktree-rooted env for this agent (e.g. via * {@link addWorktree}) WITH a `destroy()` that removes the worktree, or THROW.** It MUST NOT silently * return a non-isolated env (the shared base env, or a child rooted at `parentCwd`): the spawn may be * governed by a relaxed write baseline (`WorkflowGovernanceBaseline.worktreeBase`) that is safe ONLY * inside a worktree — a silent degrade would point those writes at the shared working tree. A factory * throw fails the spawn (fail-closed: the child never starts) — the honest outcome. The engine * additionally rejects the observable degrade shapes at its consumption point (no factory configured; * the shared static env returned as-is; an in-process env rooted at the base root or the parent's cwd), * but full worktree verification is the factory's contract — there is no receipt field on * {@link ExecutionEnv}. Unset = default (the factory's normal per-task env). */ isolation?: "worktree"; /** * Field-reported (sub-agents landing in an EMPTY sandbox): the PARENT task's * effective working root, threaded (like `isolation`) via the TRUSTED `RunInternals` channel when this * task is a sub-agent (workflow `ctx.agent` / Task delegation). CC parity: a Task sub-agent inherits the * main session's cwd. A single-user/TOC factory SHOULD root the child env here (unless `isolation` * requests a worktree — that wins); a multi-tenant/TOB factory minting isolated containers may ignore * it. Absent on top-level tasks. Static shared-`executionEnv` deployments need nothing — the child * already shares the parent env (and its cwd). */ parentCwd?: string; } /** * A **trusted control-plane** factory that mints a per-task {@link ExecutionEnv} (design/48 §5 answers core * gap-a / §7 Q7). Lives on `RunnerDeps` (deployment-level) — NOT on `TaskSpec` — so an untrusted caller can * never inject its own env and escape the sandbox (design/44 §7 Q4 red line; decision 3: the Docker * fast-lane must be control-plane-assigned, never task-selectable). * * The remote model is "one container per task", so the factory is invoked once per task. The Runner owns the * lifetime of a factory-produced env: if it implements {@link RemoteExecutionEnv.destroy} (see {@link hasDestroy}), * the Runner calls it when the task ends. */ export type ExecutionEnvFactory = (ctx: ExecutionEnvFactoryContext) => ExecutionEnv | Promise<ExecutionEnv>; /** True when `env` exposes a lifecycle {@link RemoteExecutionEnv.destroy} the Runner should call on task end. */ export declare function hasDestroy(env: ExecutionEnv): env is ExecutionEnv & Pick<RemoteExecutionEnv, "destroy">; /** Structural check that `env` implements the remote seam (lifecycle + streaming surface + declared capabilities). */ export declare function isRemoteExecutionEnv(env: ExecutionEnv): env is RemoteExecutionEnv; /** * The RESTORE half of the suspend contract — the methods the RESUME leg calls after a `suspendVM` * ({@link RemoteExecutionEnv.resumeVM} + {@link RemoteExecutionEnv.postResumeInit}). Returns the names an * adapter is MISSING (empty = complete). * * Split out from {@link isRemoteExecutionEnv} deliberately: that predicate answers "can this env be driven * as a remote env at all" (a park-only SSH/ADB host legitimately never restores a VM and is checked with * it), while THIS one answers "if I snapshot this env now, will the resume leg find a way back". Naming the * missing methods lets the suspend-time guard say WHICH part of the adapter is absent instead of failing on * the resume leg — a different process, hours later — with a bare `env.resumeVM is not a function`. */ export declare function missingRestoreSurface(env: ExecutionEnv): readonly ("resumeVM" | "postResumeInit")[]; /** * Can this env be durably suspended/snapshotted (design/61 §9 A)? Use this — NOT the structural * {@link isRemoteExecutionEnv} — before calling `suspendVM` (a non-suspendable SSH/ADB env declares * `capabilities.suspendable: false` but still has the method, which returns `"unsupported"`). Non-remote * (in-process) envs are not suspendable. * * A composite type guard (structural AND capability) so a `suspendVM` call site narrowed by THIS predicate * gets the {@link RemoteExecutionEnv} type without a cast — `true` always implies the structural check too. * The capability is read STRICTLY, like the containment ones: a non-boolean reads as NOT suspendable and * the census announces it. This took anything truthy, so `suspendable: "false"` was snapshotted — the one * key of the three whose malformed reading fell on the unsafe side. * * It also requires the RESTORE surface ({@link missingRestoreSurface}). A suspend is only durable * if something can undo it; an adapter that offers `suspendVM` but no `resumeVM` used to pass this guard, * take a real snapshot, commit a real checkpoint — and then blow up on the resume leg with an untyped * TypeError. "Suspendable" now means the whole round trip, so a half-adapter is rejected at the suspend site * where the deployment can still be told which method it owes. * * ⚠️ A capability-DECLARING but restore-incomplete adapter must NOT be re-routed to the durable-park-only * degrade by this predicate returning false — park-only rests on the workspace being externally durable * (see {@link RemoteExecutionEnv.capabilities}.suspendable), which a snapshot backend does not have. Call * sites split the two cases on the DECLARED `capabilities.suspendable` flag, not on this predicate alone. */ export declare function isSuspendable(env: ExecutionEnv): env is RemoteExecutionEnv; /** * Is this env an isolated sandbox (design/61 §9 A, design/53)? `false` for a real SSH host / ADB device AND * for any in-process env (the host process is not isolated). The orchestrator tightens autonomy + the * design/37 policy gate when this is `false` (zero-trust on an un-isolated target). The safe default is * `false` — isolation must be explicitly declared, never assumed, and that is why the flag is read * STRICTLY: every consumer of a `true` here RELAXES something (autonomy, a policy tier, a containment * leg that the sandbox is then answerable for), so a value that is not the boolean `true` — absent, * a string, a truthy object from a JS adapter the compiler never saw — is not a declaration and lands * on the checking side. This is the reading every consumer of the containment question comes through, * and the only one that also NARROWS; a caller already holding a `RemoteExecutionEnv` may spell the * same strict `!== true` test itself (the worktree-isolation refusal in the runner's prepare does). * What no caller may do is infer isolation from the remote INTERFACE — implementing the seam is not * declaring containment, and a host-lane adapter drives the operator's own machine through it. */ export declare function isIsolated(env: ExecutionEnv): boolean; /** * Is CONTAINMENT this env's own job rather than the engine's — i.e. may a containment leg stop at the * written spelling and leave the rest to the target? True exactly for a DECLARED isolated sandbox: the * sandbox is the security boundary there, and its filesystem is not the one this process can resolve * (`canonicalPath` is an RPC into somebody else's namespace). * * The one question the `bash`/`bash_readonly` read boundary asks before deciding whether to re-resolve * a candidate through the filesystem, and the one both faces' DISCLOSURE reads, so a card can never * promise a refusal the leg does not issue (or omit one it does). Deliberately NOT the remote INTERFACE: * a non-isolated adapter (host lane, SSH host — see {@link RemoteExecutionEnv.destroy}'s contract note) * implements the whole seam while driving an ordinary machine whose paths its own `canonicalPath` * answers exactly, and reading the shape as containment made that deployment's boundary lexical-only. */ export declare function boundaryIsTheSandbox(env: ExecutionEnv): boolean; /** * Does this env DECLARE that its `canonicalPath` is the authority on where a path lands? * * The question the execute-time read boundary asks about its own resolver's SILENCE. Resolving a * spelling walks it from the deepest answerable ancestor; when no ancestor answers at all, two * different worlds produce the same `undefined`: a resolver that is authoritative and is FAILING (an * RPC into the machine the command will run on, a transport that dropped), and an env that simply does * not answer path questions (an in-memory double, an adapter with no resolution surface). The first is * a question and the second is an absence, and reading both as the absence is what leaves a containment * leg silently open on an RPC-backed lane. * * So the adapter says which it is, and says it the way {@link isIsolated} is said — strictly, because a * `true` here makes a containment leg REFUSE where it used to pass. ABSENT is the reading every adapter * has had, so no existing env moves; a PRESENT non-boolean reads absent HERE too but is announced at the * env's admission point, once per key — this reader does not validate. A non-remote env keeps the same * reading, which is why the local lane is unchanged: its resolver answers `/`, so the arm this gates * has no case to fire on. */ export declare function canonicalPathIsAuthoritative(env: ExecutionEnv): boolean;