/** * SendUserFile v2 — the sandbox-lane file source (clay ruling 2026-07-14: Plan B direct upload). * * The tool cannot reach the task's sandbox through core (ToolExecuteContext carries no env handle — core * keeps the per-task ExecutionEnv private to its prepare-task closure). But the server BUILDS the * `executionEnvFactory` core calls, and the factory context carries `taskId` — so we wrap OUR OWN factory * (same posture as withWorktreeIsolation) and keep a replica-local taskId → env registry, unregistering * when core destroys the env. No core change, no parallel abstraction — pure deployment glue on a seam * the deployment already owns. * * The send itself is the anti-relay shape ([ref]; k8s workspace-snapshot posture): the server presigns a * single-object PUT (120s, method-bound, STAGING key — 修3), the SANDBOX `curl -T`s the file straight to S3 — bytes never * relay through the server, S3 credentials never enter the sandbox. Tenant isolation = the sandbox can * only read its own filesystem, which is exactly the tenant's task workspace; that is why this lane is * multi-tenant-safe while the host lane never is (host-lane multi-tenant is permanently closed). * * ssh lane (backfill 2026-07-14): the SAME send chain works over the ssh adapter unchanged — its exec * face is contract-identical (Result{stdout,stderr,exitCode}, stderr split, `options.timeout` in SECONDS * ×1000, remote-env-ssh.ts) and the peer is assumed LINUX (`stat -c %s` = GNU/busybox; the fleet is all * Linux — a macOS peer's `stat -f %z` dialect would fail LOUD at the exit-code/stat-parse guards, never * silently). But the TENANT judgment INVERTS: an ssh peer is ONE persistent real machine shared by every * task (sshExecutionEnvFactory ignores ctx — same host + same mountPath for all tasks; destroy() only * disconnects, files persist; capabilities.isolation:false). "Scoped read = tenant boundary" does NOT * hold there, so the ssh lane is SINGLE-USER ONLY (same gate as host) — see `sandboxSendLaneEnabled`. * * The presigned PUT URL is a capability secret: it never enters model context (the tool returns delivery * metadata only) and is REDACTED from every error surfaced from here (curl stderr may echo the URL). * * RESIDUAL CLOSED (修3, 三路复审 2026-07-15; was the accepted dual-review residual of 2026-07-14): the URL * still rides the exec command line (readable via /proc inside the sandbox), but it is now a capability on * a STAGING key only — the server promotes staging→final with its own creds (CopyObject), deletes staging, * and size-verifies the FINAL object, whose write URL never existed outside the server. A /proc reader can * no longer rewrite the delivered object after verification or skew the ledgered size (see * PreparedDirectUpload in send-user-file.ts for the full promotion contract). */ import type { ExecutionEnv, ExecutionEnvFactory } from "@sema-agent/core"; import type { IssuedFileLink, PreparedDirectUpload } from "../plugins/send-user-file.js"; /** Leak backstop (codex review HIGH): core skips destroy on suspend/review paths and a crash may skip it * entirely — the suspendVM wrap covers the common case, this bound covers the rest. Far above any real * per-replica concurrency. 修6: at the cap the registry REFUSES the new registration instead of FIFO-evicting * the oldest entry — every entry here is live-until-destroy/suspend by construction, so there is no * "terminal/stale" liveness verdict to evict on, and silently breaking an EXISTING live task's send face to * admit a new one inverts the harm (the old FIFO victim's SendUserFile would report "no sandbox attached" * while its env kept executing). The refused task gets a TYPED capacity error at send time instead. */ export declare const TASK_ENV_REGISTRY_MAX = 512; /** Replica-local taskId → live ExecutionEnv(s). Registered by the factory wrap, dropped on destroy/suspend. * * 修5 (concurrent-generation guard): deployments WITHOUT a run-store single-active-session claim can race * two legs of one session through the factory concurrently — previously the second registration silently * OVERWROTE the first, so leg A's later SendUserFile resolved to leg B's env (wrong-generation read/upload). * The registry now keeps EVERY live env per key and `get()` fail-louds on ambiguity: neither leg can silently * read the other's sandbox, and the moment one generation is destroyed/suspended the survivor resolves again. * (Rejecting the second factory call outright was considered and discarded: a stale never-destroyed entry — * crash/no-destroy path — would then brick every future leg of that task, and the factory seam must not fail * env creation for a send-face bookkeeping conflict. Deployments WITH the single-active-session claim * serialize legs upstream and never see this state.) */ export declare class TaskEnvRegistry { private readonly map; /** 修6: taskIds whose registration was refused at the cap — lets get() report the TRUE reason (typed * capacity error, not a misleading "no sandbox attached"). Bounded by the same cap (marker-only). */ private readonly refusedAtCapacity; private liveCount; /** Resolve the ONE live env for a task. Throws typed errors on the two governed failure states: * ambiguity (修5 — concurrent generations both live) and capacity refusal (修6). Returns undefined when * the task simply has no registered env (caller reports "no sandbox attached"). */ get(taskId: string | undefined): ExecutionEnv | undefined; /** Wrap the deployment's own factory: register each minted env under the task's CANONICAL id — core hands * the factory the raw `spec.taskId` but gives tools `hostTaskId = spec.taskId ?? sessionId` (prepare-task * 262/320/428; the sync `/v1/tasks` leg deliberately sets no spec.taskId), so the registry key MUST apply * the same fallback or every sync-leg lookup misses (codex review HIGH). Unregister when core calls * destroy() (identity guard: only the env's OWN entry is dropped — a replacement env for a reused key must * not be evicted by the old one's late destroy). core SKIPS destroy on durable suspend/review (runtask * keeps the env for the snapshot) — wrap suspendVM the same way so a suspended task doesn't pin its env * here forever; the bounded-size backstop covers any remaining no-destroy crash path. */ wrapFactory(factory: ExecutionEnvFactory): ExecutionEnvFactory; } /** * Which REMOTE_EXEC lanes may register the sandbox-send TaskEnvRegistry (and thus mount the sandbox-lane * SendUserFile source), under which tenancy gate. The judgment per lane (2026-07-14 ssh backfill review): * * - e2b / k8s: one task = one disposable sandbox; the sandbox filesystem IS the tenant's task workspace, * so a sandbox-scoped read is tenant-scoped by construction → open to any tenant. * - ssh: the peer is ONE persistent real machine SHARED across tasks (factory ignores ctx: every task * gets the same host+mountPath; destroy=disconnect only, files persist). Tenant A's task can read the * residue of tenant B's task → the isolation premise FAILS. Multi-tenant here would be an * arbitrary-host-path-read → public-URL exfil face, so the lane opens ONLY single-user * (`requirePrincipal !== true`), the exact gate the host lane uses. NOT a lane to "fix later with a * per-task workspace": tasks legitimately operate outside any workspace on a real host ([ref] — * batch deployment), so per-task dirs would not restore the boundary anyway. * - adb / local-docker / host / unset: no registry (host+unset use the local-fs source in main.ts; * adb/local-docker remain a follow-on). */ export declare function sandboxSendLaneEnabled(provider: string | undefined, requirePrincipal: boolean): boolean; /** Strip the capability URL (and any SigV4 signature) from text before it reaches an error/log — including * the percent-encoded forms an adapter may echo (the k8s exec transport carries the command in a request * URI, so an error can quote the URL re-encoded; codex review MED). */ export declare function redactPutUrl(text: string, url: string): string; export interface SandboxFileSendDeps { registry: TaskEnvRegistry; /** `scope` = the caller's VERIFIED principal (ToolExecuteContext.principal) — keys the hashed scope segment * in the object key + rides back on IssuedFileLink.scope for the ledger. Absent on single-user lanes. */ prepare: (filename: string | undefined, scope?: string) => PreparedDirectUpload; maxBytes: number; } /** The live object `createSandboxFileSend` produces — a bare callable (deps captured in closure), mirroring * the sibling `SendUserFileIssuer` role (send-user-file.ts) one level down: this is the sandbox-lane half. */ export type SandboxFileSend = (path: string, ctx: { taskId?: string; principal?: string; }) => Promise; /** * The sandbox-lane `send` seam for the SendUserFile tool: in-sandbox stat (existence + regular file + * size cap, BEFORE any signing) → presign PUT → in-sandbox `curl -T` → finalize the user-facing link. * Throws typed Errors (redacted) — the tool reports them per file. */ export declare function createSandboxFileSend(deps: SandboxFileSendDeps): SandboxFileSend; //# sourceMappingURL=sandbox-file-send.d.ts.map