@sema-agent/core
Version:
Stateless, task-oriented AI agent core
597 lines (596 loc) • 41.9 kB
TypeScript
/**
* 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;