From 35c176ce7c69780c99b10900ac427831c77131d7 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Sat, 18 Jul 2026 06:58:10 -0600 Subject: [PATCH 1/5] fix(interface): require explicit candidate PATH --- .../src/agent-candidate-code-schema.test.ts | 41 +++++++++++++++++++ .../src/agent-candidate-code-schema.ts | 11 +++++ ...nt-candidate-execution-plan-schema.test.ts | 4 ++ .../agent-candidate-execution-plan-schema.ts | 7 ++++ .../agent-candidate-outcome-schema.test.ts | 7 +++- .../src/agent-candidate-schema.test.ts | 4 +- .../src/agent-candidate.test-fixture.ts | 1 + 7 files changed, 73 insertions(+), 2 deletions(-) diff --git a/packages/agent-interface/src/agent-candidate-code-schema.test.ts b/packages/agent-interface/src/agent-candidate-code-schema.test.ts index 3475d96..4d377b5 100644 --- a/packages/agent-interface/src/agent-candidate-code-schema.test.ts +++ b/packages/agent-interface/src/agent-candidate-code-schema.test.ts @@ -18,6 +18,9 @@ const isolation = { }; const instructionDelivery = { kind: "argv-append" as const }; +const exactPath = { + PATH: { kind: "public" as const, value: "/usr/local/bin:/usr/bin:/bin" }, +}; describe("candidate code and execution schemas", () => { it("distinguishes disabled, proposer no-op, and real changes", () => { @@ -106,6 +109,7 @@ describe("candidate code and execution schemas", () => { }, instructionDelivery, cwd: { workspace: "candidate", path: "." }, + env: exactPath, environment: { kind: "pinned-container", container: { image: "node:22", indexDigest: candidateSha("1") }, @@ -138,6 +142,7 @@ describe("candidate code and execution schemas", () => { }, instructionDelivery: { kind: "stdin-utf8" }, cwd: { workspace: "task", path: "." }, + env: exactPath, environment: { kind: "evaluator-task-container" }, isolation, }), @@ -179,6 +184,7 @@ describe("candidate code and execution schemas", () => { launch: { kind: "container-command", executable: "codex" }, instructionDelivery, cwd: { workspace: "task", path: "." }, + env: exactPath, environment: { kind: "pinned-container", container: { @@ -197,6 +203,7 @@ describe("candidate code and execution schemas", () => { harnessVersion: "0.1.0", launch: { kind: "container-command" as const, executable: "codex" }, cwd: { workspace: "task" as const, path: "." }, + env: exactPath, environment: { kind: "evaluator-task-container" as const }, isolation, }; @@ -239,4 +246,38 @@ describe("candidate code and execution schemas", () => { }), ).toThrow(/evaluator exclusively owns/); }); + + it("requires an explicit PATH for relative container binaries and interpreters", () => { + const base = { + harness: "codex" as const, + harnessVersion: "0.1.0", + instructionDelivery, + cwd: { workspace: "task" as const, path: "." }, + environment: { kind: "evaluator-task-container" as const }, + isolation, + }; + for (const launch of [ + { kind: "container-command" as const, executable: "codex" }, + { + kind: "candidate-entrypoint" as const, + entrypoint: "run.js", + interpreter: "node" as const, + }, + ]) { + expect(() => agentCandidateExecutionSchema.parse({ ...base, launch })).toThrow(/PATH/); + expect(() => + agentCandidateExecutionSchema.parse({ + ...base, + launch, + env: { PATH: { kind: "public", value: "/usr/local/bin:/usr/bin:/bin" } }, + }), + ).not.toThrow(); + } + expect(() => + agentCandidateExecutionSchema.parse({ + ...base, + launch: { kind: "container-command", executable: "/usr/local/bin/codex" }, + }), + ).not.toThrow(); + }); }); diff --git a/packages/agent-interface/src/agent-candidate-code-schema.ts b/packages/agent-interface/src/agent-candidate-code-schema.ts index be898b2..87bd609 100644 --- a/packages/agent-interface/src/agent-candidate-code-schema.ts +++ b/packages/agent-interface/src/agent-candidate-code-schema.ts @@ -243,6 +243,17 @@ export const agentCandidateExecutionSchema = z }) .strict() .superRefine((execution, ctx) => { + const requiresPath = + execution.launch.kind === "container-command" + ? !execution.launch.executable.startsWith("/") + : execution.launch.interpreter !== undefined; + if (requiresPath && !execution.env?.PATH?.value.trim()) { + ctx.addIssue({ + code: "custom", + path: ["env", "PATH"], + message: "relative candidate executables require an explicit public PATH", + }); + } if (execution.env?.TANGLE_CANDIDATE_TASK_PATH !== undefined) { ctx.addIssue({ code: "custom", diff --git a/packages/agent-interface/src/agent-candidate-execution-plan-schema.test.ts b/packages/agent-interface/src/agent-candidate-execution-plan-schema.test.ts index 8476210..d12c3b8 100644 --- a/packages/agent-interface/src/agent-candidate-execution-plan-schema.test.ts +++ b/packages/agent-interface/src/agent-candidate-execution-plan-schema.test.ts @@ -118,6 +118,10 @@ function planFixture() { executable: "node", args: [{ kind: "public" as const, value: "dist/agent.js" }], env: { + PATH: { + kind: "public" as const, + value: "/usr/local/bin:/usr/bin:/bin", + }, TANGLE_CANDIDATE_TASK_PATH: { kind: "public" as const, value: "/tangle/input/task.txt", diff --git a/packages/agent-interface/src/agent-candidate-execution-plan-schema.ts b/packages/agent-interface/src/agent-candidate-execution-plan-schema.ts index c2d012d..a2917fa 100644 --- a/packages/agent-interface/src/agent-candidate-execution-plan-schema.ts +++ b/packages/agent-interface/src/agent-candidate-execution-plan-schema.ts @@ -420,6 +420,13 @@ export const agentCandidateExecutionPlanMaterialSchema = z }) .strict() .superRefine((material, ctx) => { + if (!material.launch.executable.startsWith("/") && !material.launch.env.PATH?.value.trim()) { + ctx.addIssue({ + code: "custom", + path: ["launch", "env", "PATH"], + message: "relative execution-plan executables require an explicit public PATH", + }); + } const routeIds = material.model.routes.map((route) => route.kind === "mode" || route.kind === "subagent" ? `${route.kind}:${route.name}` diff --git a/packages/agent-interface/src/agent-candidate-outcome-schema.test.ts b/packages/agent-interface/src/agent-candidate-outcome-schema.test.ts index a4335c0..d244cac 100644 --- a/packages/agent-interface/src/agent-candidate-outcome-schema.test.ts +++ b/packages/agent-interface/src/agent-candidate-outcome-schema.test.ts @@ -143,7 +143,9 @@ function materializationReceipt( launch: { executable: "node", args: [], - env: {}, + env: { + PATH: { kind: "public" as const, value: "/usr/local/bin:/usr/bin:/bin" }, + }, cwd: { workspace: "task" as const, path: "." }, }, memory: { mode: "disabled" as const }, @@ -363,6 +365,9 @@ function measuredBundle(digit: string, prompt: string) { }, instructionDelivery: { kind: "stdin-utf8" as const }, cwd: { workspace: "task" as const, path: "." }, + env: { + PATH: { kind: "public" as const, value: "/usr/local/bin:/usr/bin:/bin" }, + }, environment: { kind: "evaluator-task-container" as const }, isolation: { network: "disabled" as const, diff --git a/packages/agent-interface/src/agent-candidate-schema.test.ts b/packages/agent-interface/src/agent-candidate-schema.test.ts index 825c32f..76d3c64 100644 --- a/packages/agent-interface/src/agent-candidate-schema.test.ts +++ b/packages/agent-interface/src/agent-candidate-schema.test.ts @@ -361,7 +361,9 @@ describe("candidate receipts", () => { launch: { executable: "node", args: [{ kind: "public" as const, value: "dist/agent.js" }], - env: {}, + env: { + PATH: { kind: "public" as const, value: "/usr/local/bin:/usr/bin:/bin" }, + }, cwd: { workspace: "task" as const, path: "." }, }, knowledgeManifestDigest: candidateSha("7"), diff --git a/packages/agent-interface/src/agent-candidate.test-fixture.ts b/packages/agent-interface/src/agent-candidate.test-fixture.ts index 37a1846..42c320d 100644 --- a/packages/agent-interface/src/agent-candidate.test-fixture.ts +++ b/packages/agent-interface/src/agent-candidate.test-fixture.ts @@ -65,6 +65,7 @@ export function candidateFixture() { cwd: { workspace: "candidate", path: "." }, env: { NODE_ENV: { kind: "public", value: "production" }, + PATH: { kind: "public", value: "/usr/local/bin:/usr/bin:/bin" }, }, environment: { kind: "pinned-container", From 1ad5bd978348b9cf8b017e43bfed55abddee0ca9 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Sat, 18 Jul 2026 06:58:28 -0600 Subject: [PATCH 2/5] feat(interface): define exact process environments --- packages/agent-interface/README.md | 7 + .../src/environment-provider.ts | 138 ++++++++++++++++++ 2 files changed, 145 insertions(+) diff --git a/packages/agent-interface/README.md b/packages/agent-interface/README.md index b57a9e6..4f005b8 100644 --- a/packages/agent-interface/README.md +++ b/packages/agent-interface/README.md @@ -62,6 +62,13 @@ const provider: AgentEnvironmentProvider = { }; ``` +## Exact process environments + +Providers may expose the optional `exactProcess` capability for isolated, reproducible process execution. +It is separate from agent-backed `create()` because it guarantees a fresh environment, immutable image identity, explicit resources, bounded exact-byte file reads, shell-free argv, replacement process environment, recoverable output and terminal reason, bounded network access, and collision-safe idempotent recovery without starting a provider-managed agent. +Higher-level runtimes can use this primitive for measured candidates without making candidate lifecycle part of the provider contract. +Providers must omit the capability unless every property is enforced on their real execution path. + ## Frozen improvement candidates `AgentCandidateBundle` is the portable output of an improvement run: a recursively strict profile, an explicit disabled/no-op/changed code result, a shell-free launch, optional knowledge, isolated memory, ancestry, and spend. diff --git a/packages/agent-interface/src/environment-provider.ts b/packages/agent-interface/src/environment-provider.ts index 50212e9..d31026b 100644 --- a/packages/agent-interface/src/environment-provider.ts +++ b/packages/agent-interface/src/environment-provider.ts @@ -3,6 +3,7 @@ import type { AgentProfileCapabilities, AgentProfileValidationResult, } from "./agent-profile.js"; +import type { AgentCandidateTermination } from "./agent-candidate.js"; import type { InputPart, StreamEvent, TokenUsage } from "./index.js"; /** Portable profile reference: inline profile or provider catalog id. */ @@ -72,6 +73,138 @@ export interface ExecResult { stderr: string; } +export type AgentExactProcessEgressMode = "blocked" | "strict"; + +/** + * Outbound network policy for an exact process environment. `blocked` denies + * every protocol. `strict` permits only the named domains; direct-address, + * alternate-protocol, and cross-environment bypasses must fail. + */ +export type AgentExactProcessEgressPolicy = + | { mode: "blocked" } + | { mode: "strict"; allowDomains: readonly string[] }; + +/** Explicit portable limits for an exact process environment. */ +export interface AgentExactProcessResources { + /** Positive CPU core count. */ + cpu: number; + /** Positive integer mebibytes of memory. */ + memoryMb: number; + /** Positive integer mebibytes of disk. */ + diskMb: number; +} + +/** Terminal or running state reported by an exact process host. */ +export interface AgentExactProcessStatus { + pid: number; + running: boolean; + /** -1 while running; the exact process exit code after termination. */ + exitCode: number; + exitSignal?: string; + /** Required after termination; absent only while running. */ + termination?: AgentCandidateTermination; +} + +/** Recoverable handle for one shell-free process. */ +export interface AgentExactProcess { + readonly pid: number; + status(): Promise; + wait(): Promise; + /** Force-stop the full process tree. Idempotent after the process exits. */ + kill(): Promise; + /** Replay buffered UTF-8 stdout, then continue until the process exits. */ + stdout(): AsyncIterable; + /** Replay buffered UTF-8 stderr, then continue until the process exits. */ + stderr(): AsyncIterable; +} + +/** Shell-free launch whose environment replaces, rather than extends, ambient variables. */ +export interface AgentExactProcessLaunch { + /** Absolute path unless {@link env} supplies an explicit `PATH`. */ + executable: string; + args: readonly string[]; + cwd: string; + env: Readonly>; + stdin?: string; + /** Positive integer milliseconds, or zero to disable the process timeout. */ + timeoutMs: number; +} + +export interface AgentExactProcessManager { + list(): Promise; + get(pid: number): Promise; + /** Providers must honor the abort signal when supplied. */ + spawn( + input: AgentExactProcessLaunch, + options?: { signal?: AbortSignal }, + ): Promise; +} + +/** + * Fresh environment with no provider-managed user workload. + * + * Authenticated provider control services may exist, but no customer workload + * ingress or provider-managed user process may exist. The launched process + * sees only its supplied environment variables, with no ambient or injected + * secrets. + */ +export interface AgentExactProcessEnvironment { + readonly id: string; + readonly provider: string; + readonly metadata?: Record; + readonly process: AgentExactProcessManager; + /** Write exact bytes to an absolute path with a POSIX mode from 0 through 07777. */ + /** Providers must honor the abort signal when supplied. */ + writeFile( + path: string, + bytes: Uint8Array, + options: { mode: number; signal?: AbortSignal }, + ): Promise; + /** Read exact bytes or fail before content is loaded when the file exceeds maxBytes. */ + readFile( + path: string, + options: { maxBytes: number; signal?: AbortSignal }, + ): Promise; + destroy(): Promise; +} + +export interface AgentExactProcessEnvironmentQuery { + /** Every supplied key/value must match persisted environment metadata exactly. */ + metadata?: Record; + providerOptions?: Record; +} + +/** Input for a fresh environment with no provider-managed agent process. */ +export interface CreateAgentExactProcessEnvironmentInput { + /** Provider-specific immutable image reference. */ + image: string; + egress: AgentExactProcessEgressPolicy; + /** Positive integer milliseconds. */ + maxLifetimeMs: number; + /** Positive integer milliseconds when supplied. */ + provisionTimeoutMs?: number; + /** Required limits; exact execution never inherits provider defaults. */ + resources: AgentExactProcessResources; + metadata: Record; + idempotencyKey: string; + signal?: AbortSignal; + /** Provider-native fields may narrow, but never weaken, the isolation contract. */ + providerOptions?: Record; +} + +/** Optional all-or-nothing exact process capability of an environment provider. */ +export interface AgentExactProcessProvider { + /** + * Repeating the same idempotency key and input returns the same environment. + * Reusing the key with any different create input must fail. + */ + create(input: CreateAgentExactProcessEnvironmentInput): Promise; + /** Ordinary environments must return null. */ + get(id: string): Promise; + /** Return every matching exact environment; providers own any native pagination. */ + list(query?: AgentExactProcessEnvironmentQuery): Promise; +} + export interface CheckpointRequest { name?: string; metadata?: Record; @@ -202,6 +335,10 @@ export interface AgentEnvironmentCapabilities { placement: boolean; usage: boolean; confidential: boolean; + /** Present only when {@link AgentEnvironmentProvider.exactProcess} is implemented. */ + exactProcess?: { + egress: readonly AgentExactProcessEgressMode[]; + }; } export interface CreateAgentEnvironmentInput { @@ -221,6 +358,7 @@ export interface CreateAgentEnvironmentInput { export interface AgentEnvironmentProvider { readonly name: string; + readonly exactProcess?: AgentExactProcessProvider; capabilities(): | AgentEnvironmentCapabilities | Promise; From b658aee89fb3e4ab23b5c1dca170fc61d791b0c9 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Sat, 18 Jul 2026 06:58:38 -0600 Subject: [PATCH 3/5] feat(provider): adapt exact process environments --- packages/agent-provider-tangle/README.md | 21 + packages/agent-provider-tangle/package.json | 8 +- .../src/exact-process.ts | 352 ++++++++++++++++ .../agent-provider-tangle/src/index.test.ts | 384 +++++++++++++++++- packages/agent-provider-tangle/src/index.ts | 92 ++++- packages/agent-provider-testkit/README.md | 8 +- .../agent-provider-testkit/src/index.test.ts | 127 +++++- packages/agent-provider-testkit/src/index.ts | 162 ++++++++ pnpm-lock.yaml | 36 +- 9 files changed, 1169 insertions(+), 21 deletions(-) create mode 100644 packages/agent-provider-tangle/src/exact-process.ts diff --git a/packages/agent-provider-tangle/README.md b/packages/agent-provider-tangle/README.md index 9e99793..15164e0 100644 --- a/packages/agent-provider-tangle/README.md +++ b/packages/agent-provider-tangle/README.md @@ -10,3 +10,24 @@ const provider = createTangleProvider({ client: new Sandbox({ apiKey: process.env.TANGLE_API_KEY }), }) ``` + +Pass `exactProcess: {}` only when the Sandbox deployment supports attested exact-process creates. +The optional capability creates an ephemeral sandbox with an authenticated control service but no managed agent workload, no injected startup environment, explicit resources, exact blocked/domain egress, bounded binary file reads, shell-free launch, and recoverable process output plus terminal reason. + +```ts +const provider = createTangleProvider({ + client: new Sandbox({ apiKey: process.env.TANGLE_API_KEY }), + exactProcess: {}, +}) + +const environment = await provider.exactProcess!.create({ + image: `ghcr.io/acme/agent@sha256:${manifestDigest}`, + egress: { mode: 'blocked' }, + maxLifetimeMs: 120_000, + resources: { cpu: 1, memoryMb: 1024, diskMb: 1024 }, + metadata: { executionId: 'run-1' }, + idempotencyKey: 'run-1', +}) +``` + +The adapter rejects ordinary sandboxes during create, recovery, and list operations. diff --git a/packages/agent-provider-tangle/package.json b/packages/agent-provider-tangle/package.json index b4dc958..c02a5c4 100644 --- a/packages/agent-provider-tangle/package.json +++ b/packages/agent-provider-tangle/package.json @@ -33,6 +33,8 @@ "files": [ "dist/index.d.ts", "dist/index.js", + "dist/exact-process.d.ts", + "dist/exact-process.js", "README.md", "LICENSE" ], @@ -41,13 +43,13 @@ "check-types": "tsc --noEmit", "clean": "rm -rf dist", "prepublishOnly": "pnpm run build", - "test": "vitest run" + "test": "vitest run src" }, "dependencies": { "@tangle-network/agent-interface": "workspace:*" }, "peerDependencies": { - "@tangle-network/sandbox": ">=0.8.0 <1.0.0" + "@tangle-network/sandbox": ">=0.11.1 <1.0.0" }, "peerDependenciesMeta": { "@tangle-network/sandbox": { @@ -56,7 +58,7 @@ }, "devDependencies": { "@tangle-network/agent-provider-testkit": "workspace:*", - "@tangle-network/sandbox": "^0.8.2", + "@tangle-network/sandbox": "^0.11.1", "@types/node": "catalog:", "typescript": "^6.0.3", "vitest": "catalog:" diff --git a/packages/agent-provider-tangle/src/exact-process.ts b/packages/agent-provider-tangle/src/exact-process.ts new file mode 100644 index 0000000..ad5ae67 --- /dev/null +++ b/packages/agent-provider-tangle/src/exact-process.ts @@ -0,0 +1,352 @@ +import { isAbsolute } from "node:path"; +import { isDeepStrictEqual } from "node:util"; +import { + createExactProcessAttestation, + type CreateSandboxOptions, + type ExactProcessAttestation, + parseExactProcessAttestation, +} from "@tangle-network/sandbox"; +import type { + AgentExactProcess, + AgentExactProcessEnvironment, + AgentExactProcessLaunch, + AgentExactProcessProvider, + AgentExactProcessStatus, + CreateAgentExactProcessEnvironmentInput, +} from "@tangle-network/agent-interface/environment-provider"; +import type { + SandboxClientLike, + SandboxInstanceLike, + SandboxProcessLike, + SandboxProcessStatusLike, +} from "./index.js"; + +const IMMUTABLE_TANGLE_IMAGE = /^(?:sha256:[a-f0-9]{64}|\S+@sha256:[a-f0-9]{64})$/i; + +export interface TangleExactProcessOptions { + teamId?: string; +} + +export function createTangleExactProcessProvider(input: { + client: SandboxClientLike; + options: TangleExactProcessOptions; + providerName: string; +}): AgentExactProcessProvider { + const { client, options, providerName } = input; + const get = client.get; + const list = client.list; + if (!get || !list) throw new Error("Tangle exact process provider requires get() and list()"); + return { + async create(createInput): Promise { + if ( + createInput.providerOptions && + Object.keys(createInput.providerOptions).length > 0 + ) { + throw new Error("Tangle exact process providerOptions are not supported"); + } + if ( + Object.hasOwn(createInput.metadata, "capabilities") || + Object.hasOwn(createInput.metadata, "customer_id") || + Object.hasOwn(createInput.metadata, "exactProcess") || + Object.hasOwn(createInput.metadata, "integrationLaunch") || + Object.hasOwn(createInput.metadata, "teamId") + ) { + throw new Error("exact process ownership metadata is reserved by Tangle"); + } + const createOptions = exactSandboxOptions(createInput, options); + const expectedAttestation = await createExactProcessAttestation(createOptions); + const box = await client.create(createOptions, { + ...(createInput.signal ? { signal: createInput.signal } : {}), + ...(createInput.provisionTimeoutMs === undefined + ? {} + : { timeoutMs: createInput.provisionTimeoutMs }), + }); + const attestation = exactProcessAttestation(box, options.teamId); + if (!attestation) { + throw new Error("Tangle Sandbox did not attest the requested exact process mode"); + } + if (!isDeepStrictEqual(attestation, expectedAttestation)) { + throw new Error( + "Tangle exact process idempotency collision returned different create inputs", + ); + } + return sandboxInstanceAsExactProcessEnvironment(box, providerName); + }, + async get(id): Promise { + const box = await get.call(client, id); + return box && isExactProcessSandbox(box, options.teamId) + ? sandboxInstanceAsExactProcessEnvironment(box, providerName) + : null; + }, + async list(query): Promise { + if ( + query?.providerOptions && + Object.keys(query.providerOptions).length > 0 + ) { + throw new Error("Tangle exact process query providerOptions are not supported"); + } + const matches: AgentExactProcessEnvironment[] = []; + for (let offset = 0; ; offset += 100) { + const page = await list.call(client, { + ...(options.teamId ? { scope: `team:${options.teamId}` } : { scope: "personal" }), + limit: 100, + offset, + }); + for (const box of page) { + if ( + isExactProcessSandbox(box, options.teamId) && + metadataMatches(box.metadata, query?.metadata) + ) { + matches.push(sandboxInstanceAsExactProcessEnvironment(box, providerName)); + } + } + if (page.length < 100) break; + } + return matches; + }, + }; +} + +function exactSandboxOptions( + input: CreateAgentExactProcessEnvironmentInput, + defaults: TangleExactProcessOptions, +): CreateSandboxOptions { + if (!input.image.trim()) throw new Error("exact process image is required"); + if (!IMMUTABLE_TANGLE_IMAGE.test(input.image)) { + throw new Error("Tangle exact process image must include a sha256 manifest digest"); + } + if (!input.idempotencyKey.trim()) { + throw new Error("exact process idempotencyKey is required"); + } + if ( + !Number.isSafeInteger(input.maxLifetimeMs) || + input.maxLifetimeMs < 1 || + input.maxLifetimeMs % 1_000 !== 0 + ) { + throw new Error( + "Tangle exact process maxLifetimeMs must be a positive whole number of seconds", + ); + } + if ( + input.provisionTimeoutMs !== undefined && + (!Number.isSafeInteger(input.provisionTimeoutMs) || input.provisionTimeoutMs < 1) + ) { + throw new Error("exact process provisionTimeoutMs must be a positive integer"); + } + if (input.egress.mode === "strict" && input.egress.allowDomains.length === 0) { + throw new Error("strict exact process egress requires at least one domain"); + } + if (!input.resources) { + throw new Error("Tangle exact process resources are required"); + } + const resources = sandboxResourcesFromRequest(input.resources); + return { + image: input.image, + exactProcess: true, + publicEdge: false, + ephemeral: true, + sshEnabled: false, + webTerminalEnabled: false, + secrets: [], + capabilities: [], + egressPolicy: + input.egress.mode === "blocked" + ? { mode: "blocked" } + : { + mode: "strict", + allowDomains: [...input.egress.allowDomains], + includeImplicitDomains: false, + }, + maxLifetimeSeconds: input.maxLifetimeMs / 1_000, + idempotencyKey: input.idempotencyKey, + metadata: { ...input.metadata }, + ...(defaults.teamId ? { teamId: defaults.teamId } : {}), + resources, + }; +} + +function sandboxResourcesFromRequest( + requested: CreateAgentExactProcessEnvironmentInput["resources"], +): NonNullable { + if (!Number.isFinite(requested.cpu) || requested.cpu <= 0) { + throw new Error("Tangle exact process CPU must be positive and finite"); + } + if (!Number.isSafeInteger(requested.memoryMb) || requested.memoryMb < 1) { + throw new Error("Tangle exact process memoryMb must be a positive integer"); + } + if ( + !Number.isSafeInteger(requested.diskMb) || + requested.diskMb < 1 || + requested.diskMb % 1_024 !== 0 + ) { + throw new Error( + "Tangle exact process diskMb must be a positive whole number of gibibytes", + ); + } + return { + cpuCores: requested.cpu, + memoryMB: requested.memoryMb, + diskGB: requested.diskMb / 1_024, + }; +} + +function sandboxInstanceAsExactProcessEnvironment( + box: SandboxInstanceLike, + providerName: string, +): AgentExactProcessEnvironment { + if ( + !box.fs || + box.fs.supportsWriteMode !== true || + typeof box.fs.readBytes !== "function" || + !box.process || + !box.delete + ) { + throw new Error("Tangle sandbox does not expose exact files, processes, and deletion"); + } + const process = box.process; + const fs = box.fs; + const destroy = box.delete.bind(box); + return { + id: String(box.id), + provider: providerName, + ...(box.metadata ? { metadata: box.metadata } : {}), + process: { + async list(): Promise { + return (await process.list()).map(exactProcessStatusFromSandbox); + }, + async get(pid: number): Promise { + const handle = await process.get(pid); + return handle ? sandboxProcessAsExactProcess(handle) : null; + }, + async spawn( + launch: AgentExactProcessLaunch, + operation = {}, + ): Promise { + operation.signal?.throwIfAborted(); + validateExactProcessLaunch(launch); + const handle = await process.spawnExact(launch.executable, launch.args, { + cwd: launch.cwd, + env: { ...launch.env }, + inheritEnv: false, + ...(launch.stdin === undefined ? {} : { stdin: launch.stdin }), + timeoutMs: launch.timeoutMs, + ...(operation.signal ? { signal: operation.signal } : {}), + }); + return sandboxProcessAsExactProcess(handle); + }, + }, + async writeFile(path, bytes, options): Promise { + options.signal?.throwIfAborted(); + if (!isAbsolute(path)) { + throw new Error("Tangle exact process file path must be absolute"); + } + if (!Number.isSafeInteger(options.mode) || options.mode < 0 || options.mode > 0o7777) { + throw new Error("Tangle exact process file mode must be between 0 and 07777"); + } + await fs.write(path, Buffer.from(bytes).toString("base64"), { + encoding: "base64", + mode: options.mode, + ...(options.signal ? { signal: options.signal } : {}), + }); + }, + async readFile(path, options): Promise { + options.signal?.throwIfAborted(); + if (!isAbsolute(path)) { + throw new Error("Tangle exact process file path must be absolute"); + } + if (!Number.isSafeInteger(options.maxBytes) || options.maxBytes < 1) { + throw new Error("Tangle exact process maxBytes must be a positive integer"); + } + const bytes = await fs.readBytes(path, { + maxBytes: options.maxBytes, + ...(options.signal ? { signal: options.signal } : {}), + }); + if (bytes.byteLength > options.maxBytes) { + throw new Error("Tangle exact process file read violated its byte limit"); + } + return bytes; + }, + async destroy(): Promise { + await destroy(); + }, + }; +} + +function validateExactProcessLaunch(input: AgentExactProcessLaunch): void { + if (!input.executable || (!isAbsolute(input.executable) && !input.env.PATH)) { + throw new Error( + "Tangle exact process executable must be absolute unless env.PATH is supplied", + ); + } + if (!input.cwd) throw new Error("Tangle exact process cwd is required"); + if (!Number.isSafeInteger(input.timeoutMs) || input.timeoutMs < 0) { + throw new Error("Tangle exact process timeoutMs must be a non-negative integer"); + } +} + +function sandboxProcessAsExactProcess(process: SandboxProcessLike): AgentExactProcess { + return { + pid: process.pid, + async status(): Promise { + return exactProcessStatusFromSandbox(await process.status()); + }, + async wait() { + return process.waitForTermination(); + }, + async kill(): Promise { + await process.kill("SIGKILL", { tree: true }); + }, + async *stdout(): AsyncIterable { + yield* process.stdout(); + }, + async *stderr(): AsyncIterable { + yield* process.stderr(); + }, + }; +} + +function isExactProcessSandbox( + box: SandboxInstanceLike, + teamId: string | undefined, +): boolean { + return exactProcessAttestation(box, teamId) !== undefined; +} + +function exactProcessAttestation( + box: SandboxInstanceLike, + teamId: string | undefined, +): ExactProcessAttestation | undefined { + const attestation = parseExactProcessAttestation(box.exactProcess); + if (!attestation) return undefined; + if (teamId ? box.teamId !== teamId : box.teamId !== undefined) return undefined; + return attestation; +} + +function exactProcessStatusFromSandbox( + status: SandboxProcessStatusLike, +): AgentExactProcessStatus { + if (status.running && status.termination) { + throw new Error("Tangle exact process reported a terminal reason while running"); + } + if (!status.running && !status.termination) { + throw new Error("Tangle exact process reported no terminal reason after exit"); + } + return { + pid: status.pid, + running: status.running, + exitCode: status.exitCode, + ...(status.exitSignal ? { exitSignal: status.exitSignal } : {}), + ...(status.termination ? { termination: status.termination } : {}), + }; +} + +function metadataMatches( + actual: Record | undefined, + expected: Record | undefined, +): boolean { + if (!expected) return true; + if (!actual) return false; + return Object.entries(expected).every(([key, value]) => + isDeepStrictEqual(actual[key], value), + ); +} diff --git a/packages/agent-provider-tangle/src/index.test.ts b/packages/agent-provider-tangle/src/index.test.ts index b330c0d..abbd9ba 100644 --- a/packages/agent-provider-tangle/src/index.test.ts +++ b/packages/agent-provider-tangle/src/index.test.ts @@ -1,13 +1,47 @@ -import { describe, expect, it } from "vitest"; -import { runAgentEnvironmentProviderConformance } from "@tangle-network/agent-provider-testkit"; -import type { CreateSandboxOptions, SandboxEvent } from "@tangle-network/sandbox"; +import { describe, expect, it, vi } from "vitest"; +import { + runAgentEnvironmentProviderConformance, + runAgentExactProcessProviderLifecycleChecks, +} from "@tangle-network/agent-provider-testkit"; +import { + createExactProcessAttestation, + type CreateSandboxOptions, + type ExactProcessAttestation, + type SandboxEvent, +} from "@tangle-network/sandbox"; import { createTangleProvider, + defaultTangleSandboxCapabilities, type SandboxClientLike, type SandboxInstanceLike, } from "./index.js"; +const EXACT_IMAGE = `example/image@sha256:${"1".repeat(64)}`; +const EXACT_LOCAL_IMAGE = `sha256:${"2".repeat(64)}`; +const EXACT_RESOURCES = { cpu: 1, memoryMb: 512, diskMb: 1024 } as const; + describe("createTangleProvider", () => { + it("refuses to advertise exact processes without the exact adapter", async () => { + const provider = createTangleProvider({ + client: { + async create() { + throw new Error("not called"); + }, + }, + capabilities: { + ...defaultTangleSandboxCapabilities(), + exactProcess: { egress: ["blocked"] }, + }, + }); + + await expect(provider.capabilities()).rejects.toThrow( + "cannot advertise exactProcess", + ); + expect(defaultTangleSandboxCapabilities()).not.toHaveProperty( + "exactProcess", + ); + }); + it("maps create options, stream events, workspace methods, and dispatch sessions", async () => { let createOptions: CreateSandboxOptions | undefined; const files = new Map(); @@ -58,4 +92,348 @@ describe("createTangleProvider", () => { metadata: { alreadyExisted: true }, }); }); + + it("does not delete an idempotently recovered environment when adaptation fails", async () => { + const deleted = vi.fn(async () => undefined); + const box: SandboxInstanceLike = { + id: "exact-recovered", + async *streamPrompt(): AsyncIterable {}, + delete: deleted, + }; + const client: SandboxClientLike = { + async create(options) { + box.exactProcess = await createExactProcessAttestation(options ?? {}); + return box; + }, + async get() { + return box; + }, + async list() { + return [box]; + }, + }; + const provider = createTangleProvider({ client, exactProcess: {} }); + + await expect( + provider.exactProcess?.create({ + image: EXACT_IMAGE, + egress: { mode: "blocked" }, + maxLifetimeMs: 10_000, + resources: EXACT_RESOURCES, + metadata: { executionId: "recovered" }, + idempotencyKey: "candidate-recovered", + }), + ).rejects.toThrow("does not expose exact files, processes, and deletion"); + expect(deleted).not.toHaveBeenCalled(); + }); + + it("creates a recoverable bare exact-process environment without ambient access", async () => { + let createOptions: CreateSandboxOptions | undefined; + let createRequestOptions: { signal?: AbortSignal; timeoutMs?: number } | undefined; + let listOptions: Record | undefined; + let write: + | { + path: string; + content: string; + options: { encoding: "base64"; mode: number; signal?: AbortSignal }; + } + | undefined; + let launch: + | { + executable: string; + args: readonly string[]; + options?: { + cwd?: string; + env?: Record; + inheritEnv?: boolean; + stdin?: string; + timeoutMs?: number; + signal?: AbortSignal; + }; + } + | undefined; + let spawned = false; + let deleted = false; + let boundIdempotencyKey: string | undefined; + let attestExact = true; + let statusFails = false; + let killCalls = 0; + let listedBoxes: SandboxInstanceLike[] | undefined; + let attestationOverride: ExactProcessAttestation | undefined; + const writtenFiles = new Map(); + const status = { + pid: 73, + running: false, + exitCode: 0, + termination: { kind: "exit" as const, exitCode: 0 }, + }; + const process = { + pid: 73, + status: async () => { + if (statusFails) throw new Error("not found"); + return status; + }, + wait: async () => 0, + waitForTermination: async () => status.termination, + kill: async () => { + killCalls++; + }, + async *stdout() { + yield "ok"; + }, + async *stderr() {}, + }; + const box: SandboxInstanceLike = { + id: "exact-1", + status: "running", + metadata: { executionId: "execution-1", nested: { attempt: 1 } }, + async *streamPrompt(): AsyncIterable { + yield { type: "result", data: {} } as SandboxEvent; + }, + fs: { + supportsWriteMode: true, + async readBytes(path, options) { + const bytes = writtenFiles.get(path); + if (!bytes) throw new Error("not found"); + if (bytes.byteLength > options.maxBytes) { + throw new Error("file exceeds maxBytes"); + } + return bytes; + }, + async write(path, content, options) { + write = { path, content, options }; + writtenFiles.set(path, Buffer.from(content, "base64")); + }, + }, + process: { + list: async () => (spawned ? [status] : []), + get: async (pid) => (spawned && pid === process.pid ? process : null), + async spawnExact(executable, args, options) { + spawned = true; + launch = { executable, args, options }; + return process; + }, + }, + async delete() { + deleted = true; + }, + }; + const client: SandboxClientLike = { + async create(options, requestOptions) { + if ( + !deleted && + boundIdempotencyKey === options?.idempotencyKey && + box.exactProcess + ) { + return box; + } + createOptions = options; + createRequestOptions = requestOptions; + deleted = false; + spawned = false; + writtenFiles.clear(); + boundIdempotencyKey = options?.idempotencyKey; + box.metadata = { ...(options?.metadata ?? {}) }; + box.exactProcess = + attestExact && options?.exactProcess + ? (attestationOverride ?? (await createExactProcessAttestation(options))) + : undefined; + box.teamId = options?.teamId; + return box; + }, + async get(id) { + return id === box.id && !deleted ? box : null; + }, + async list(options) { + listOptions = options as Record; + if (deleted) return []; + if (!listedBoxes) return [box]; + const offset = Number(listOptions.offset ?? 0); + const limit = Number(listOptions.limit ?? 100); + return listedBoxes.slice(offset, offset + limit); + }, + }; + const provider = createTangleProvider({ + client, + exactProcess: { teamId: "team-1" }, + }); + + await expect( + runAgentExactProcessProviderLifecycleChecks({ + createProvider: () => provider, + createInput: { + image: EXACT_IMAGE, + egress: { mode: "strict", allowDomains: ["model.example"] }, + maxLifetimeMs: 61_000, + provisionTimeoutMs: 3_000, + resources: EXACT_RESOURCES, + metadata: { executionId: "execution-1", nested: { attempt: 1 } }, + idempotencyKey: "candidate-1", + }, + launch: { + executable: "/usr/bin/agent", + args: ["--prompt", "hello world"], + cwd: "/workspace", + env: { MODEL_URL: "http://model.internal" }, + stdin: "input", + timeoutMs: 2_000, + }, + expectedStdout: "ok", + expectedStderr: "", + }), + ).resolves.toMatchObject({ provider: "tangle-sandbox", environmentId: "exact-1" }); + + expect(createOptions).toEqual({ + image: EXACT_IMAGE, + exactProcess: true, + publicEdge: false, + ephemeral: true, + sshEnabled: false, + webTerminalEnabled: false, + secrets: [], + capabilities: [], + egressPolicy: { + mode: "strict", + allowDomains: ["model.example"], + includeImplicitDomains: false, + }, + maxLifetimeSeconds: 61, + idempotencyKey: "candidate-1", + metadata: { executionId: "execution-1", nested: { attempt: 1 } }, + teamId: "team-1", + resources: { cpuCores: 1, memoryMB: 512, diskGB: 1 }, + }); + expect(createRequestOptions?.timeoutMs).toBe(3_000); + expect(write).toEqual({ + path: "/tmp/agent-provider-testkit.bin", + content: "AAEC/w==", + options: { + encoding: "base64", + mode: 0o640, + signal: expect.any(AbortSignal), + }, + }); + expect(launch).toEqual({ + executable: "/usr/bin/agent", + args: ["--prompt", "hello world"], + options: { + cwd: "/workspace", + env: { MODEL_URL: "http://model.internal" }, + inheritEnv: false, + stdin: "input", + timeoutMs: 2_000, + signal: expect.any(AbortSignal), + }, + }); + expect(listOptions).toMatchObject({ scope: "team:team-1", limit: 100, offset: 0 }); + expect(deleted).toBe(true); + + const exact = provider.exactProcess; + expect(exact).toBeDefined(); + const environment = await exact!.create({ + image: EXACT_LOCAL_IMAGE, + egress: { mode: "blocked" }, + maxLifetimeMs: 10_000, + resources: EXACT_RESOURCES, + metadata: { executionId: "execution-2" }, + idempotencyKey: "candidate-2", + }); + await expect( + environment.process.spawn({ + executable: "agent", + args: [], + cwd: "/workspace", + env: {}, + timeoutMs: 1_000, + }), + ).rejects.toThrow("executable must be absolute"); + expect(spawned).toBe(false); + const staleProcess = await environment.process.spawn({ + executable: "/usr/bin/agent", + args: [], + cwd: "/workspace", + env: {}, + timeoutMs: 1_000, + }); + const killCallsBeforeStaleHandle = killCalls; + statusFails = true; + await expect(staleProcess.kill()).resolves.toBeUndefined(); + expect(killCalls).toBe(killCallsBeforeStaleHandle + 1); + statusFails = false; + await expect( + environment.writeFile("relative", Uint8Array.of(1), { mode: 0o644 }), + ).rejects.toThrow("file path must be absolute"); + await expect( + environment.writeFile("/tmp/file", Uint8Array.of(1), { mode: 0o10000 }), + ).rejects.toThrow("file mode"); + await environment.destroy(); + + deleted = false; + box.metadata = { executionId: "ordinary", exactProcess: true }; + box.exactProcess = undefined; + box.teamId = "team-1"; + await expect(exact!.get(box.id)).resolves.toBeNull(); + await expect(exact!.list()).resolves.toEqual([]); + + const paginationAttestation = await createExactProcessAttestation({ + image: EXACT_IMAGE, + egressPolicy: { mode: "blocked" }, + maxLifetimeSeconds: 10, + metadata: { campaign: "pagination" }, + teamId: "team-1", + }); + listedBoxes = Array.from({ length: 101 }, (_, index) => ({ + ...box, + id: `exact-${index}`, + exactProcess: paginationAttestation, + teamId: "team-1", + metadata: { campaign: "pagination" }, + })); + const paginated = await exact!.list({ metadata: { campaign: "pagination" } }); + expect(paginated).toHaveLength(101); + expect(listOptions).toMatchObject({ offset: 100, limit: 100 }); + listedBoxes = undefined; + + attestationOverride = await createExactProcessAttestation({ + image: EXACT_IMAGE, + egressPolicy: { mode: "blocked" }, + maxLifetimeSeconds: 10, + metadata: { executionId: "old-input" }, + teamId: "team-1", + }); + await expect( + exact!.create({ + image: `example/other@sha256:${"2".repeat(64)}`, + egress: { mode: "blocked" }, + maxLifetimeMs: 10_000, + resources: EXACT_RESOURCES, + metadata: { executionId: "new-input" }, + idempotencyKey: "candidate-collision", + }), + ).rejects.toThrow("idempotency collision"); + attestationOverride = undefined; + + attestExact = false; + await expect( + exact!.create({ + image: EXACT_IMAGE, + egress: { mode: "blocked" }, + maxLifetimeMs: 10_000, + resources: EXACT_RESOURCES, + metadata: { executionId: "collision" }, + idempotencyKey: "candidate-unattested", + }), + ).rejects.toThrow("did not attest"); + expect(deleted).toBe(false); + await expect( + exact!.create({ + image: EXACT_IMAGE, + egress: { mode: "blocked" }, + maxLifetimeMs: 10_000, + resources: EXACT_RESOURCES, + metadata: { exactProcess: true }, + idempotencyKey: "candidate-reserved-metadata", + }), + ).rejects.toThrow("metadata is reserved"); + }); }); diff --git a/packages/agent-provider-tangle/src/index.ts b/packages/agent-provider-tangle/src/index.ts index da8ae0b..b679a1f 100644 --- a/packages/agent-provider-tangle/src/index.ts +++ b/packages/agent-provider-tangle/src/index.ts @@ -1,6 +1,7 @@ import type { BackendType, CreateSandboxOptions, + ExactProcessAttestation, ExecResult as SandboxExecResult, PromptOptions, PromptResult, @@ -29,20 +30,70 @@ import type { PlacementInfo, ResourceRequest, } from "@tangle-network/agent-interface/environment-provider"; -import type { InputPart, TokenUsage } from "@tangle-network/agent-interface"; +import type { + AgentCandidateTermination, + InputPart, + TokenUsage, +} from "@tangle-network/agent-interface"; +import { + createTangleExactProcessProvider, + type TangleExactProcessOptions, +} from "./exact-process.js"; + +export type { TangleExactProcessOptions } from "./exact-process.js"; export interface SandboxClientLike { - create(options?: CreateSandboxOptions): Promise; + create( + options?: CreateSandboxOptions, + requestOptions?: { signal?: AbortSignal; timeoutMs?: number }, + ): Promise; get?(id: string): Promise; list?(options?: unknown): Promise; describePlacement?(box: SandboxInstanceLike): unknown; } +export interface SandboxProcessStatusLike { + pid: number; + running: boolean; + exitCode: number; + exitSignal?: string; + termination?: AgentCandidateTermination; +} + +export interface SandboxProcessLike { + readonly pid: number; + status(): Promise; + wait(): Promise; + waitForTermination(): Promise; + kill(signal?: "SIGKILL", options?: { tree?: boolean }): Promise; + stdout(): AsyncIterable; + stderr(): AsyncIterable; +} + +export interface SandboxProcessManagerLike { + list(): Promise; + get(pid: number): Promise; + spawnExact( + executable: string, + args: readonly string[], + options?: { + cwd?: string; + env?: Record; + inheritEnv?: boolean; + stdin?: string; + timeoutMs?: number; + signal?: AbortSignal; + }, + ): Promise; +} + export interface SandboxInstanceLike { id: string; name?: string; status?: unknown; metadata?: Record; + exactProcess?: ExactProcessAttestation; + teamId?: string; streamPrompt(message: string | InputPart[], options?: PromptOptions): AsyncIterable; prompt?(message: string | InputPart[], options?: PromptOptions): Promise; dispatchPrompt?(message: string | InputPart[], options?: PromptOptions): Promise; @@ -50,6 +101,19 @@ export interface SandboxInstanceLike { read?(path: string, options?: { sessionId?: string }): Promise; write?(path: string, content: string, options?: { sessionId?: string }): Promise; exec?(command: string, options?: unknown): Promise; + fs?: { + supportsWriteMode?: true; + readBytes( + path: string, + options: { maxBytes: number; signal?: AbortSignal }, + ): Promise; + write( + path: string, + content: string, + options: { encoding: "base64"; mode: number; signal?: AbortSignal }, + ): Promise; + }; + process?: SandboxProcessManagerLike; checkpoint?(options?: unknown): Promise; fork?(checkpointId: string, options?: unknown): Promise; refresh?(): Promise; @@ -72,17 +136,37 @@ export interface TangleProviderOptions { capabilities?: AgentEnvironmentCapabilities | (() => AgentEnvironmentCapabilities | Promise); validateProfile?: AgentEnvironmentProvider["validateProfile"]; mapCreateInput?: (input: CreateAgentEnvironmentInput) => CreateSandboxOptions; + exactProcess?: TangleExactProcessOptions; } export function createTangleProvider( options: TangleProviderOptions, ): AgentEnvironmentProvider { const providerName = options.name ?? "tangle-sandbox"; + const exactProcess = options.exactProcess + ? createTangleExactProcessProvider({ + client: options.client, + options: options.exactProcess, + providerName, + }) + : undefined; return { name: providerName, + ...(exactProcess ? { exactProcess } : {}), capabilities: async () => { - if (!options.capabilities) return defaultTangleSandboxCapabilities(); - return typeof options.capabilities === "function" ? options.capabilities() : options.capabilities; + const capabilities = options.capabilities + ? typeof options.capabilities === "function" + ? await options.capabilities() + : options.capabilities + : defaultTangleSandboxCapabilities(); + if (!exactProcess && capabilities.exactProcess) { + throw new Error( + "Tangle capabilities cannot advertise exactProcess without exactProcess configuration", + ); + } + return exactProcess + ? { ...capabilities, exactProcess: { egress: ["blocked", "strict"] } } + : capabilities; }, ...(options.validateProfile ? { validateProfile: options.validateProfile } : {}), async create(input) { diff --git a/packages/agent-provider-testkit/README.md b/packages/agent-provider-testkit/README.md index 04bf4e4..10a2efa 100644 --- a/packages/agent-provider-testkit/README.md +++ b/packages/agent-provider-testkit/README.md @@ -3,7 +3,10 @@ Framework-neutral checks for packages that implement `AgentEnvironmentProvider`. ```ts -import { runAgentEnvironmentProviderConformance } from '@tangle-network/agent-provider-testkit' +import { + runAgentEnvironmentProviderConformance, + runAgentExactProcessProviderLifecycleChecks, +} from '@tangle-network/agent-provider-testkit' await runAgentEnvironmentProviderConformance({ name: 'my-provider', @@ -13,3 +16,6 @@ await runAgentEnvironmentProviderConformance({ The checks create an environment, stream one turn, verify terminal completion, exercise declared workspace methods, and destroy the environment. + +`runAgentExactProcessProviderLifecycleChecks()` checks idempotent create and collision rejection, bounded exact-byte file round trips, terminal reasons, output replay, recovery, lookup, and deletion. +It intentionally does not certify a provider's network isolation, secret handling, public exposure, or process-tree behavior; provider packages must prove those properties against their real infrastructure. diff --git a/packages/agent-provider-testkit/src/index.test.ts b/packages/agent-provider-testkit/src/index.test.ts index 26dd6bb..391fdb2 100644 --- a/packages/agent-provider-testkit/src/index.test.ts +++ b/packages/agent-provider-testkit/src/index.test.ts @@ -1,9 +1,13 @@ import { describe, expect, it } from "vitest"; import type { AgentEnvironmentProvider, + AgentExactProcessEnvironment, AgentTurnInput, } from "@tangle-network/agent-interface/environment-provider"; -import { runAgentEnvironmentProviderConformance } from "./index.js"; +import { + runAgentEnvironmentProviderConformance, + runAgentExactProcessProviderLifecycleChecks, +} from "./index.js"; describe("runAgentEnvironmentProviderConformance", () => { it("accepts a provider that implements the required lifecycle", async () => { @@ -18,6 +22,43 @@ describe("runAgentEnvironmentProviderConformance", () => { }); }); +describe("runAgentExactProcessProviderLifecycleChecks", () => { + it("checks exact launch, recovery, lookup, and deletion", async () => { + const report = await runAgentExactProcessProviderLifecycleChecks({ + createProvider: () => fakeExactProcessProvider(), + createInput: { + image: "example@sha256:123", + egress: { mode: "blocked" }, + maxLifetimeMs: 10_000, + resources: { cpu: 1, memoryMb: 512, diskMb: 1024 }, + metadata: { executionId: "execution-1" }, + idempotencyKey: "execution-1", + }, + launch: { + executable: "/bin/example", + args: ["ok"], + cwd: "/tmp", + env: {}, + timeoutMs: 1_000, + }, + expectedStdout: "ok", + expectedStderr: "", + }); + + expect(report.checked).toEqual([ + "exact-process-capability", + "exact-process-idempotency", + "exact-process-idempotency-collision", + "fresh-environment", + "exact-file-roundtrip", + "exact-process-run", + "exact-process-recovery", + "exact-process-list", + "exact-process-destroy", + ]); + }); +}); + function fakeProvider(): AgentEnvironmentProvider { const files = new Map(); return { @@ -74,3 +115,87 @@ function fakeProvider(): AgentEnvironmentProvider { }, }; } + +function fakeExactProcessProvider(): AgentEnvironmentProvider { + const environments = new Map(); + const createInputs = new Map(); + const files = new Map(); + const provider: AgentEnvironmentProvider = { + ...fakeProvider(), + capabilities: async () => ({ + ...(await fakeProvider().capabilities()), + exactProcess: { egress: ["blocked", "strict"] }, + }), + exactProcess: { + async create(input) { + const existing = environments.get("exact-1"); + const createIdentity = JSON.stringify(input); + if (existing) { + if (createInputs.get(input.idempotencyKey) !== createIdentity) { + throw new Error("idempotency collision"); + } + return existing; + } + let deleted = false; + let spawned = false; + const output = "ok"; + const status = { + pid: 41, + running: false, + exitCode: 0, + termination: { kind: "exit" as const, exitCode: 0 }, + }; + const process = { + pid: 41, + status: async () => status, + wait: async () => status.termination, + kill: async () => {}, + async *stdout() { + yield output; + }, + async *stderr() {}, + }; + const environment: AgentExactProcessEnvironment = { + id: "exact-1", + provider: "fake", + metadata: input.metadata, + process: { + list: async () => (deleted || !spawned ? [] : [status]), + get: async (pid) => (!deleted && spawned && pid === process.pid ? process : null), + spawn: async () => { + spawned = true; + return process; + }, + }, + async writeFile(path, bytes) { + files.set(path, Uint8Array.from(bytes)); + }, + async readFile(path, options) { + const bytes = files.get(path); + if (!bytes) throw new Error("not found"); + if (bytes.byteLength > options.maxBytes) { + throw new Error("file exceeds maxBytes"); + } + return Uint8Array.from(bytes); + }, + async destroy() { + deleted = true; + environments.delete(environment.id); + files.clear(); + }, + }; + createInputs.set(input.idempotencyKey, createIdentity); + environments.set(environment.id, environment); + return environment; + }, + get: async (id) => environments.get(id) ?? null, + list: async (query) => + [...environments.values()].filter((environment) => + Object.entries(query?.metadata ?? {}).every( + ([key, value]) => environment.metadata?.[key] === value, + ), + ), + }, + }; + return provider; +} diff --git a/packages/agent-provider-testkit/src/index.ts b/packages/agent-provider-testkit/src/index.ts index 0209388..b55c4b7 100644 --- a/packages/agent-provider-testkit/src/index.ts +++ b/packages/agent-provider-testkit/src/index.ts @@ -3,7 +3,9 @@ import type { AgentEnvironmentCapabilities, AgentEnvironmentEvent, AgentEnvironmentProvider, + AgentExactProcessLaunch, CreateAgentEnvironmentInput, + CreateAgentExactProcessEnvironmentInput, } from "@tangle-network/agent-interface/environment-provider"; export interface ProviderConformanceOptions { @@ -23,6 +25,21 @@ export interface ProviderConformanceReport { checked: string[]; } +export interface ExactProcessProviderLifecycleOptions { + createProvider(): AgentEnvironmentProvider | Promise; + createInput: CreateAgentExactProcessEnvironmentInput; + launch: AgentExactProcessLaunch; + expectedStdout: string; + expectedStderr: string; +} + +export interface ExactProcessProviderLifecycleReport { + provider: string; + environmentId: string; + pid: number; + checked: string[]; +} + export class ProviderConformanceError extends Error { constructor( message: string, @@ -104,6 +121,146 @@ export async function runAgentEnvironmentProviderConformance( }; } +/** Check exact launch, output, recovery, lookup, and deletion behavior. */ +export async function runAgentExactProcessProviderLifecycleChecks( + options: ExactProcessProviderLifecycleOptions, +): Promise { + const checked: string[] = []; + const provider = await options.createProvider(); + const capabilities = await provider.capabilities(); + assert(provider.exactProcess, "provider.exactProcess is required", checked); + assert(capabilities.exactProcess, "capabilities.exactProcess is required", checked); + assert( + capabilities.exactProcess.egress.includes(options.createInput.egress.mode), + `provider does not declare ${options.createInput.egress.mode} egress support`, + checked, + ); + checked.push("exact-process-capability"); + + const environment = await provider.exactProcess.create(options.createInput); + let destroyed = false; + try { + const repeated = await provider.exactProcess.create(options.createInput); + assert( + repeated.id === environment.id, + "repeated exact create must recover the same environment", + checked, + ); + checked.push("exact-process-idempotency"); + + let collisionRejected = false; + try { + await provider.exactProcess.create({ + ...options.createInput, + maxLifetimeMs: options.createInput.maxLifetimeMs + 1_000, + }); + } catch { + collisionRejected = true; + } + assert( + collisionRejected, + "reusing an exact idempotency key with different input must fail", + checked, + ); + checked.push("exact-process-idempotency-collision"); + + assert((await environment.process.list()).length === 0, "exact environment must start empty", checked); + checked.push("fresh-environment"); + + const expectedFile = Uint8Array.of(0, 1, 2, 255); + const operation = new AbortController(); + await environment.writeFile("/tmp/agent-provider-testkit.bin", expectedFile, { + mode: 0o640, + signal: operation.signal, + }); + const actualFile = await environment.readFile( + "/tmp/agent-provider-testkit.bin", + { maxBytes: expectedFile.byteLength, signal: operation.signal }, + ); + assert( + bytesEqual(actualFile, expectedFile), + "exact file read must return the bytes that were written", + checked, + ); + let boundedReadRejected = false; + try { + await environment.readFile("/tmp/agent-provider-testkit.bin", { + maxBytes: expectedFile.byteLength - 1, + signal: operation.signal, + }); + } catch { + boundedReadRejected = true; + } + assert( + boundedReadRejected, + "exact file read must reject content above maxBytes", + checked, + ); + checked.push("exact-file-roundtrip"); + + const process = await environment.process.spawn(options.launch, { + signal: operation.signal, + }); + const stdout = (await collect(process.stdout())).join(""); + const stderr = (await collect(process.stderr())).join(""); + const termination = await process.wait(); + const status = await process.status(); + assert(!status.running, "exact process must reach a terminal status", checked); + assert(status.termination, "terminal exact process status requires a reason", checked); + assert( + JSON.stringify(status.termination) === JSON.stringify(termination), + "wait() and status() termination reasons must match", + checked, + ); + assert(stdout === options.expectedStdout, "exact process stdout differs", checked); + assert(stderr === options.expectedStderr, "exact process stderr differs", checked); + await process.kill(); + checked.push("exact-process-run"); + + const recovered = await provider.exactProcess.get(environment.id); + assert(recovered, "exact environment must be recoverable by id", checked); + const recoveredProcess = await recovered.process.get(process.pid); + assert(recoveredProcess, "exact process must be recoverable by pid", checked); + assert( + (await collect(recoveredProcess.stdout())).join("") === options.expectedStdout, + "recovered exact process stdout differs", + checked, + ); + assert( + (await collect(recoveredProcess.stderr())).join("") === options.expectedStderr, + "recovered exact process stderr differs", + checked, + ); + checked.push("exact-process-recovery"); + + const listed = await provider.exactProcess.list({ metadata: options.createInput.metadata }); + assert( + listed.filter((candidate) => candidate.id === environment.id).length === 1, + "exact environment metadata lookup must return one matching id", + checked, + ); + checked.push("exact-process-list"); + + await environment.destroy(); + destroyed = true; + assert( + (await provider.exactProcess.get(environment.id)) === null, + "destroyed exact environment must not be recoverable", + checked, + ); + checked.push("exact-process-destroy"); + + return { + provider: provider.name, + environmentId: environment.id, + pid: process.pid, + checked, + }; + } finally { + if (!destroyed) await environment.destroy(); + } +} + async function checkWorkspace( environment: AgentEnvironment, capabilities: AgentEnvironmentCapabilities, @@ -139,6 +296,11 @@ function assert(value: unknown, message: string, checked: string[]): asserts val if (!value) throw new ProviderConformanceError(message, checked); } +function bytesEqual(left: Uint8Array, right: Uint8Array): boolean { + if (left.byteLength !== right.byteLength) return false; + return left.every((byte, index) => byte === right[index]); +} + function isTerminalEvent(event: AgentEnvironmentEvent): boolean { if (event.type === "result" || event.type === "done" || event.type === "final") return true; if (event.type.endsWith(".completed") || event.type.endsWith(".failed")) return true; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 96da5c3..4a66b30 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -160,8 +160,8 @@ importers: specifier: workspace:* version: link:../agent-provider-testkit '@tangle-network/sandbox': - specifier: ^0.8.2 - version: 0.8.2 + specifier: ^0.11.1 + version: 0.11.1 '@types/node': specifier: 'catalog:' version: 25.6.0 @@ -964,11 +964,17 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@tangle-network/agent-interface@0.8.0': - resolution: {integrity: sha512-okz9LGKwPNKODNyT9Y7+T+sQsJ4g6oTy/hpWpxR6r2BI7pS6WqIdgCOQcx98+WtlPoibkY3ewRRAb8YJMrPHog==} + '@tangle-network/agent-core@0.4.19': + resolution: {integrity: sha512-GzygwJ6mmY4tgkFonc4yq2vU+kJSTo6JuZCUNsPQgjZigZeSpWjX+3r+ZxEi38VqAzt79MEn43/Txu+ZflqazQ==} - '@tangle-network/sandbox@0.8.2': - resolution: {integrity: sha512-MG3dj7SnF7vI8CagW1OwpkJSUq3IREpADBWp6knOukKxSYYCMGwJ0nPZz+O2eotI+Nl2A2LIGiHqPB82jgOvjw==} + '@tangle-network/agent-interface@0.30.0': + resolution: {integrity: sha512-GmwPwamrzOFPn+Qx7W0nt9QRUF9VUiZrMNLmF6QpL23R+7TD21HllPblVOYB5MVqA728FpDLXit3HfqY8mStwg==} + + '@tangle-network/agent-interface@0.31.0': + resolution: {integrity: sha512-OvP8OebhbFd4d/Mxt1QDPAckJdBIA9omxoXU17dBbyEwhZKsiM6rbuOPaPDcv0Sf4koNLI25CfOYuzqlDR6ypQ==} + + '@tangle-network/sandbox@0.11.1': + resolution: {integrity: sha512-9I5G1UHNhwTNFToe+Y0iQO1Joemd+7ba3LXIjfYUj+apRww6Eqq+1UGV2a11XaWUTlEqyhAc37F5OHBUj/xxCg==} peerDependencies: '@mastra/core': ^1.36.0 '@modelcontextprotocol/sdk': ^1.29.0 @@ -3143,13 +3149,25 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@tangle-network/agent-interface@0.8.0': + '@tangle-network/agent-core@0.4.19': + dependencies: + '@tangle-network/agent-interface': 0.31.0 + zod: 4.4.3 + + '@tangle-network/agent-interface@0.30.0': + dependencies: + '@noble/hashes': 1.8.0 + zod: 4.4.3 + + '@tangle-network/agent-interface@0.31.0': dependencies: + '@noble/hashes': 1.8.0 zod: 4.4.3 - '@tangle-network/sandbox@0.8.2': + '@tangle-network/sandbox@0.11.1': dependencies: - '@tangle-network/agent-interface': 0.8.0 + '@tangle-network/agent-core': 0.4.19 + '@tangle-network/agent-interface': 0.30.0 '@tybys/wasm-util@0.10.3': dependencies: From eda63ce96c032d88317a941715c73d3cbb2997ee Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Sat, 18 Jul 2026 08:08:21 -0600 Subject: [PATCH 4/5] feat(provider): define exact process environments --- .changeset/exact-process-environments.md | 8 ++++++++ packages/agent-provider-tangle/package.json | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 .changeset/exact-process-environments.md diff --git a/.changeset/exact-process-environments.md b/.changeset/exact-process-environments.md new file mode 100644 index 0000000..4437ca2 --- /dev/null +++ b/.changeset/exact-process-environments.md @@ -0,0 +1,8 @@ +--- +"@tangle-network/agent-interface": minor +"@tangle-network/agent-provider-tangle": minor +"@tangle-network/agent-provider-testkit": minor +--- + +Define a provider-neutral exact process environment with immutable images, explicit resources, bounded exact-byte files, collision-safe creation, and recoverable shell-free processes with exact terminal reasons. +Implement provider-secret-free, network-limited execution and recovery for attested Tangle sandboxes, with reusable lifecycle checks for every contract. diff --git a/packages/agent-provider-tangle/package.json b/packages/agent-provider-tangle/package.json index c02a5c4..1c5e952 100644 --- a/packages/agent-provider-tangle/package.json +++ b/packages/agent-provider-tangle/package.json @@ -49,7 +49,7 @@ "@tangle-network/agent-interface": "workspace:*" }, "peerDependencies": { - "@tangle-network/sandbox": ">=0.11.1 <1.0.0" + "@tangle-network/sandbox": ">=0.12.0 <1.0.0" }, "peerDependenciesMeta": { "@tangle-network/sandbox": { From 43e614b7f3d0a2b0360048351d2407fd799e90f1 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Mon, 20 Jul 2026 15:24:31 -0600 Subject: [PATCH 5/5] fix(provider): use published sandbox primitives --- .../src/environment-provider.ts | 8 +- packages/agent-provider-tangle/README.md | 7 +- packages/agent-provider-tangle/package.json | 2 +- .../src/exact-process.ts | 332 ++++++++---- .../agent-provider-tangle/src/index.test.ts | 500 ++++++++---------- packages/agent-provider-tangle/src/index.ts | 30 +- packages/agent-provider-testkit/src/index.ts | 125 ++++- 7 files changed, 570 insertions(+), 434 deletions(-) diff --git a/packages/agent-interface/src/environment-provider.ts b/packages/agent-interface/src/environment-provider.ts index d31026b..dc7743a 100644 --- a/packages/agent-interface/src/environment-provider.ts +++ b/packages/agent-interface/src/environment-provider.ts @@ -112,9 +112,9 @@ export interface AgentExactProcess { wait(): Promise; /** Force-stop the full process tree. Idempotent after the process exits. */ kill(): Promise; - /** Replay buffered UTF-8 stdout, then continue until the process exits. */ + /** Each iteration replays buffered UTF-8 stdout, then continues until exit. */ stdout(): AsyncIterable; - /** Replay buffered UTF-8 stderr, then continue until the process exits. */ + /** Each iteration replays buffered UTF-8 stderr, then continues until exit. */ stderr(): AsyncIterable; } @@ -153,8 +153,7 @@ export interface AgentExactProcessEnvironment { readonly provider: string; readonly metadata?: Record; readonly process: AgentExactProcessManager; - /** Write exact bytes to an absolute path with a POSIX mode from 0 through 07777. */ - /** Providers must honor the abort signal when supplied. */ + /** Write exact bytes to an absolute path with a POSIX mode from 0 through 07777. Providers must honor the abort signal when supplied. */ writeFile( path: string, bytes: Uint8Array, @@ -197,6 +196,7 @@ export interface AgentExactProcessProvider { /** * Repeating the same idempotency key and input returns the same environment. * Reusing the key with any different create input must fail. + * Unsupported egress modes must fail instead of weakening the policy. */ create(input: CreateAgentExactProcessEnvironmentInput): Promise; /** Ordinary environments must return null. */ diff --git a/packages/agent-provider-tangle/README.md b/packages/agent-provider-tangle/README.md index 15164e0..86e96d9 100644 --- a/packages/agent-provider-tangle/README.md +++ b/packages/agent-provider-tangle/README.md @@ -11,8 +11,9 @@ const provider = createTangleProvider({ }) ``` -Pass `exactProcess: {}` only when the Sandbox deployment supports attested exact-process creates. -The optional capability creates an ephemeral sandbox with an authenticated control service but no managed agent workload, no injected startup environment, explicit resources, exact blocked/domain egress, bounded binary file reads, shell-free launch, and recoverable process output plus terminal reason. +Pass `exactProcess: {}` only when the Sandbox deployment supports `agent: false` creates and reports `metadata.runtimeMode: "control"`. +The optional capability creates an ephemeral sandbox with an authenticated control service but no managed agent workload or agent credentials, explicit resources, exact blocked/domain egress, bounded binary file reads, shell-free launch, and recoverable process output plus terminal reason. +Set `teamId` inside `exactProcess` to scope create, lookup, and recovery to one team. ```ts const provider = createTangleProvider({ @@ -21,7 +22,7 @@ const provider = createTangleProvider({ }) const environment = await provider.exactProcess!.create({ - image: `ghcr.io/acme/agent@sha256:${manifestDigest}`, + image: 'ghcr.io/acme/agent@sha256:<64-hex-manifest-digest>', egress: { mode: 'blocked' }, maxLifetimeMs: 120_000, resources: { cpu: 1, memoryMb: 1024, diskMb: 1024 }, diff --git a/packages/agent-provider-tangle/package.json b/packages/agent-provider-tangle/package.json index 1c5e952..c02a5c4 100644 --- a/packages/agent-provider-tangle/package.json +++ b/packages/agent-provider-tangle/package.json @@ -49,7 +49,7 @@ "@tangle-network/agent-interface": "workspace:*" }, "peerDependencies": { - "@tangle-network/sandbox": ">=0.12.0 <1.0.0" + "@tangle-network/sandbox": ">=0.11.1 <1.0.0" }, "peerDependenciesMeta": { "@tangle-network/sandbox": { diff --git a/packages/agent-provider-tangle/src/exact-process.ts b/packages/agent-provider-tangle/src/exact-process.ts index ad5ae67..b844554 100644 --- a/packages/agent-provider-tangle/src/exact-process.ts +++ b/packages/agent-provider-tangle/src/exact-process.ts @@ -1,11 +1,7 @@ import { isAbsolute } from "node:path"; import { isDeepStrictEqual } from "node:util"; -import { - createExactProcessAttestation, - type CreateSandboxOptions, - type ExactProcessAttestation, - parseExactProcessAttestation, -} from "@tangle-network/sandbox"; +import type { CreateSandboxOptions } from "@tangle-network/sandbox"; +import type { AgentCandidateTermination } from "@tangle-network/agent-interface"; import type { AgentExactProcess, AgentExactProcessEnvironment, @@ -21,7 +17,26 @@ import type { SandboxProcessStatusLike, } from "./index.js"; -const IMMUTABLE_TANGLE_IMAGE = /^(?:sha256:[a-f0-9]{64}|\S+@sha256:[a-f0-9]{64})$/i; +const IMMUTABLE_TANGLE_IMAGE = + /^(?:sha256:[a-f0-9]{64}|\S+@sha256:[a-f0-9]{64})$/i; +const EXACT_PROCESS_METADATA_KEY = "tangle.exactProcess"; +const LIST_PAGE_SIZE = 1_000; +const MAX_LIST_OFFSET = 1_000; + +type TangleExactSandboxOptions = Omit< + CreateSandboxOptions, + "agent" | "driver" | "egressPolicy" +> & { + agent: false; + driver: { type: "host-agent"; runtimeBackend: "docker" }; + egressPolicy: + | { mode: "blocked" } + | { + mode: "strict"; + allowDomains: string[]; + includeImplicitDomains: false; + }; +}; export interface TangleExactProcessOptions { teamId?: string; @@ -35,74 +50,66 @@ export function createTangleExactProcessProvider(input: { const { client, options, providerName } = input; const get = client.get; const list = client.list; - if (!get || !list) throw new Error("Tangle exact process provider requires get() and list()"); + if (!get || !list) { + throw new Error("Tangle exact process provider requires get() and list()"); + } return { async create(createInput): Promise { - if ( - createInput.providerOptions && - Object.keys(createInput.providerOptions).length > 0 - ) { - throw new Error("Tangle exact process providerOptions are not supported"); - } - if ( - Object.hasOwn(createInput.metadata, "capabilities") || - Object.hasOwn(createInput.metadata, "customer_id") || - Object.hasOwn(createInput.metadata, "exactProcess") || - Object.hasOwn(createInput.metadata, "integrationLaunch") || - Object.hasOwn(createInput.metadata, "teamId") - ) { - throw new Error("exact process ownership metadata is reserved by Tangle"); - } - const createOptions = exactSandboxOptions(createInput, options); - const expectedAttestation = await createExactProcessAttestation(createOptions); - const box = await client.create(createOptions, { + assertSupportedProviderOptions(createInput.providerOptions); + assertUnreservedMetadata(createInput.metadata); + const box = await client.create(exactSandboxOptions(createInput, options), { ...(createInput.signal ? { signal: createInput.signal } : {}), ...(createInput.provisionTimeoutMs === undefined ? {} : { timeoutMs: createInput.provisionTimeoutMs }), }); - const attestation = exactProcessAttestation(box, options.teamId); - if (!attestation) { - throw new Error("Tangle Sandbox did not attest the requested exact process mode"); - } - if (!isDeepStrictEqual(attestation, expectedAttestation)) { - throw new Error( - "Tangle exact process idempotency collision returned different create inputs", - ); + try { + assertExactProcessSandbox(box); + return sandboxInstanceAsExactProcessEnvironment(box, providerName); + } catch (error) { + if (!box.delete) throw error; + try { + await box.delete(); + } catch (cleanupError) { + throw new AggregateError( + [error, cleanupError], + "Tangle exact process validation and cleanup both failed", + ); + } + throw error; } - return sandboxInstanceAsExactProcessEnvironment(box, providerName); }, async get(id): Promise { const box = await get.call(client, id); - return box && isExactProcessSandbox(box, options.teamId) - ? sandboxInstanceAsExactProcessEnvironment(box, providerName) - : null; + if (!box || !isExactProcessSandbox(box)) return null; + return sandboxInstanceAsExactProcessEnvironment(box, providerName); }, async list(query): Promise { - if ( - query?.providerOptions && - Object.keys(query.providerOptions).length > 0 - ) { - throw new Error("Tangle exact process query providerOptions are not supported"); - } + assertSupportedProviderOptions(query?.providerOptions); const matches: AgentExactProcessEnvironment[] = []; - for (let offset = 0; ; offset += 100) { + for (let offset = 0; offset <= MAX_LIST_OFFSET; offset += LIST_PAGE_SIZE) { const page = await list.call(client, { - ...(options.teamId ? { scope: `team:${options.teamId}` } : { scope: "personal" }), - limit: 100, + ...(options.teamId + ? { scope: `team:${options.teamId}` } + : { scope: "personal" }), + limit: LIST_PAGE_SIZE, offset, }); for (const box of page) { if ( - isExactProcessSandbox(box, options.teamId) && + isExactProcessSandbox(box) && metadataMatches(box.metadata, query?.metadata) ) { - matches.push(sandboxInstanceAsExactProcessEnvironment(box, providerName)); + matches.push( + sandboxInstanceAsExactProcessEnvironment(box, providerName), + ); } } - if (page.length < 100) break; + if (page.length < LIST_PAGE_SIZE) return matches; } - return matches; + throw new Error( + "Tangle exact process lookup exceeds the Sandbox list pagination limit", + ); }, }; } @@ -110,10 +117,12 @@ export function createTangleExactProcessProvider(input: { function exactSandboxOptions( input: CreateAgentExactProcessEnvironmentInput, defaults: TangleExactProcessOptions, -): CreateSandboxOptions { +): TangleExactSandboxOptions { if (!input.image.trim()) throw new Error("exact process image is required"); if (!IMMUTABLE_TANGLE_IMAGE.test(input.image)) { - throw new Error("Tangle exact process image must include a sha256 manifest digest"); + throw new Error( + "Tangle exact process image must include a sha256 manifest digest", + ); } if (!input.idempotencyKey.trim()) { throw new Error("exact process idempotencyKey is required"); @@ -129,20 +138,24 @@ function exactSandboxOptions( } if ( input.provisionTimeoutMs !== undefined && - (!Number.isSafeInteger(input.provisionTimeoutMs) || input.provisionTimeoutMs < 1) + (!Number.isSafeInteger(input.provisionTimeoutMs) || + input.provisionTimeoutMs < 1) ) { - throw new Error("exact process provisionTimeoutMs must be a positive integer"); + throw new Error( + "exact process provisionTimeoutMs must be a positive integer", + ); } - if (input.egress.mode === "strict" && input.egress.allowDomains.length === 0) { + if ( + input.egress.mode === "strict" && + input.egress.allowDomains.length === 0 + ) { throw new Error("strict exact process egress requires at least one domain"); } - if (!input.resources) { - throw new Error("Tangle exact process resources are required"); - } const resources = sandboxResourcesFromRequest(input.resources); return { image: input.image, - exactProcess: true, + agent: false, + driver: { type: "host-agent", runtimeBackend: "docker" }, publicEdge: false, ephemeral: true, sshEnabled: false, @@ -159,7 +172,10 @@ function exactSandboxOptions( }, maxLifetimeSeconds: input.maxLifetimeMs / 1_000, idempotencyKey: input.idempotencyKey, - metadata: { ...input.metadata }, + metadata: { + ...input.metadata, + [EXACT_PROCESS_METADATA_KEY]: true, + }, ...(defaults.teamId ? { teamId: defaults.teamId } : {}), resources, }; @@ -172,7 +188,9 @@ function sandboxResourcesFromRequest( throw new Error("Tangle exact process CPU must be positive and finite"); } if (!Number.isSafeInteger(requested.memoryMb) || requested.memoryMb < 1) { - throw new Error("Tangle exact process memoryMb must be a positive integer"); + throw new Error( + "Tangle exact process memoryMb must be a positive integer", + ); } if ( !Number.isSafeInteger(requested.diskMb) || @@ -197,11 +215,12 @@ function sandboxInstanceAsExactProcessEnvironment( if ( !box.fs || box.fs.supportsWriteMode !== true || - typeof box.fs.readBytes !== "function" || !box.process || !box.delete ) { - throw new Error("Tangle sandbox does not expose exact files, processes, and deletion"); + throw new Error( + "Tangle sandbox does not expose exact files, processes, and deletion", + ); } const process = box.process; const fs = box.fs; @@ -224,45 +243,80 @@ function sandboxInstanceAsExactProcessEnvironment( ): Promise { operation.signal?.throwIfAborted(); validateExactProcessLaunch(launch); - const handle = await process.spawnExact(launch.executable, launch.args, { - cwd: launch.cwd, - env: { ...launch.env }, - inheritEnv: false, - ...(launch.stdin === undefined ? {} : { stdin: launch.stdin }), - timeoutMs: launch.timeoutMs, - ...(operation.signal ? { signal: operation.signal } : {}), - }); + const handle = await process.spawnExact( + launch.executable, + launch.args, + { + cwd: launch.cwd, + env: { ...launch.env }, + inheritEnv: false, + ...(launch.stdin === undefined ? {} : { stdin: launch.stdin }), + timeoutMs: launch.timeoutMs, + ...(operation.signal ? { signal: operation.signal } : {}), + }, + ); + operation.signal?.throwIfAborted(); return sandboxProcessAsExactProcess(handle); }, }, async writeFile(path, bytes, options): Promise { options.signal?.throwIfAborted(); - if (!isAbsolute(path)) { - throw new Error("Tangle exact process file path must be absolute"); - } - if (!Number.isSafeInteger(options.mode) || options.mode < 0 || options.mode > 0o7777) { - throw new Error("Tangle exact process file mode must be between 0 and 07777"); + assertAbsoluteFilePath(path); + if ( + !Number.isSafeInteger(options.mode) || + options.mode < 0 || + options.mode > 0o7777 + ) { + throw new Error( + "Tangle exact process file mode must be between 0 and 07777", + ); } await fs.write(path, Buffer.from(bytes).toString("base64"), { encoding: "base64", mode: options.mode, - ...(options.signal ? { signal: options.signal } : {}), }); + options.signal?.throwIfAborted(); }, async readFile(path, options): Promise { options.signal?.throwIfAborted(); - if (!isAbsolute(path)) { - throw new Error("Tangle exact process file path must be absolute"); - } + assertAbsoluteFilePath(path); if (!Number.isSafeInteger(options.maxBytes) || options.maxBytes < 1) { - throw new Error("Tangle exact process maxBytes must be a positive integer"); + throw new Error( + "Tangle exact process maxBytes must be a positive integer", + ); } - const bytes = await fs.readBytes(path, { - maxBytes: options.maxBytes, - ...(options.signal ? { signal: options.signal } : {}), - }); - if (bytes.byteLength > options.maxBytes) { - throw new Error("Tangle exact process file read violated its byte limit"); + const stat = await fs.stat(path); + options.signal?.throwIfAborted(); + if (!stat.isFile) { + throw new Error("Tangle exact process path is not a regular file"); + } + if (stat.size > options.maxBytes) { + throw new Error("Tangle exact process file exceeds maxBytes"); + } + const result = await fs.readBatch([path], { encoding: "base64" }); + options.signal?.throwIfAborted(); + const file = result.files[0]; + if ( + result.errors.length !== 0 || + result.files.length !== 1 || + !file || + file.path !== path || + file.encoding !== "base64" + ) { + throw new Error( + result.errors[0]?.error ?? + "Tangle exact process file read returned an invalid result", + ); + } + const bytes = Uint8Array.from(Buffer.from(file.content, "base64")); + if ( + bytes.byteLength !== file.size || + bytes.byteLength !== stat.size || + bytes.byteLength > options.maxBytes + ) { + throw new Error( + "Tangle exact process file read violated its byte bound", + ); } return bytes; }, @@ -273,25 +327,37 @@ function sandboxInstanceAsExactProcessEnvironment( } function validateExactProcessLaunch(input: AgentExactProcessLaunch): void { - if (!input.executable || (!isAbsolute(input.executable) && !input.env.PATH)) { + if ( + !input.executable || + (!isAbsolute(input.executable) && !input.env.PATH?.trim()) + ) { throw new Error( "Tangle exact process executable must be absolute unless env.PATH is supplied", ); } if (!input.cwd) throw new Error("Tangle exact process cwd is required"); if (!Number.isSafeInteger(input.timeoutMs) || input.timeoutMs < 0) { - throw new Error("Tangle exact process timeoutMs must be a non-negative integer"); + throw new Error( + "Tangle exact process timeoutMs must be a non-negative integer", + ); } } -function sandboxProcessAsExactProcess(process: SandboxProcessLike): AgentExactProcess { +function sandboxProcessAsExactProcess( + process: SandboxProcessLike, +): AgentExactProcess { return { pid: process.pid, async status(): Promise { return exactProcessStatusFromSandbox(await process.status()); }, - async wait() { - return process.waitForTermination(); + async wait(): Promise { + await process.wait(); + const status = exactProcessStatusFromSandbox(await process.status()); + if (!status.termination) { + throw new Error("Tangle exact process remained running after wait()"); + } + return status.termination; }, async kill(): Promise { await process.kill("SIGKILL", { tree: true }); @@ -305,41 +371,75 @@ function sandboxProcessAsExactProcess(process: SandboxProcessLike): AgentExactPr }; } -function isExactProcessSandbox( - box: SandboxInstanceLike, - teamId: string | undefined, -): boolean { - return exactProcessAttestation(box, teamId) !== undefined; -} - -function exactProcessAttestation( - box: SandboxInstanceLike, - teamId: string | undefined, -): ExactProcessAttestation | undefined { - const attestation = parseExactProcessAttestation(box.exactProcess); - if (!attestation) return undefined; - if (teamId ? box.teamId !== teamId : box.teamId !== undefined) return undefined; - return attestation; -} - function exactProcessStatusFromSandbox( status: SandboxProcessStatusLike, ): AgentExactProcessStatus { - if (status.running && status.termination) { - throw new Error("Tangle exact process reported a terminal reason while running"); - } - if (!status.running && !status.termination) { - throw new Error("Tangle exact process reported no terminal reason after exit"); + if (status.running && status.exitSignal) { + throw new Error("Tangle exact process reported an exit signal while running"); } + const termination = processTermination(status); return { pid: status.pid, running: status.running, exitCode: status.exitCode, ...(status.exitSignal ? { exitSignal: status.exitSignal } : {}), - ...(status.termination ? { termination: status.termination } : {}), + ...(termination ? { termination } : {}), }; } +function processTermination( + status: SandboxProcessStatusLike, +): AgentCandidateTermination | undefined { + if (status.running) return undefined; + return status.exitSignal + ? { kind: "signal", signal: status.exitSignal } + : { kind: "exit", exitCode: status.exitCode }; +} + +function assertExactProcessSandbox(box: SandboxInstanceLike): void { + if (!isExactProcessSandbox(box)) { + throw new Error( + "Tangle Sandbox did not create the requested process-only runtime", + ); + } +} + +function isExactProcessSandbox(box: SandboxInstanceLike): boolean { + return ( + box.metadata?.runtimeMode === "control" && + box.metadata[EXACT_PROCESS_METADATA_KEY] === true + ); +} + +function assertUnreservedMetadata(metadata: Record): void { + const reserved = [ + "capabilities", + "customer_id", + "exactProcess", + "integrationLaunch", + "runtimeMode", + "teamId", + EXACT_PROCESS_METADATA_KEY, + ]; + if (reserved.some((name) => Object.hasOwn(metadata, name))) { + throw new Error("exact process ownership metadata is reserved by Tangle"); + } +} + +function assertSupportedProviderOptions( + providerOptions: Record | undefined, +): void { + if (providerOptions && Object.keys(providerOptions).length > 0) { + throw new Error("Tangle exact process providerOptions are not supported"); + } +} + +function assertAbsoluteFilePath(path: string): void { + if (!isAbsolute(path)) { + throw new Error("Tangle exact process file path must be absolute"); + } +} + function metadataMatches( actual: Record | undefined, expected: Record | undefined, diff --git a/packages/agent-provider-tangle/src/index.test.ts b/packages/agent-provider-tangle/src/index.test.ts index abbd9ba..5d8ab45 100644 --- a/packages/agent-provider-tangle/src/index.test.ts +++ b/packages/agent-provider-tangle/src/index.test.ts @@ -3,11 +3,9 @@ import { runAgentEnvironmentProviderConformance, runAgentExactProcessProviderLifecycleChecks, } from "@tangle-network/agent-provider-testkit"; -import { - createExactProcessAttestation, - type CreateSandboxOptions, - type ExactProcessAttestation, - type SandboxEvent, +import type { + CreateSandboxOptions, + SandboxEvent, } from "@tangle-network/sandbox"; import { createTangleProvider, @@ -17,9 +15,18 @@ import { } from "./index.js"; const EXACT_IMAGE = `example/image@sha256:${"1".repeat(64)}`; -const EXACT_LOCAL_IMAGE = `sha256:${"2".repeat(64)}`; const EXACT_RESOURCES = { cpu: 1, memoryMb: 512, diskMb: 1024 } as const; +type ExactCreateOptions = CreateSandboxOptions & { + agent?: boolean; + driver?: { type: string; runtimeBackend?: string }; + egressPolicy?: { + mode: string; + allowDomains?: string[]; + includeImplicitDomains?: boolean; + }; +}; + describe("createTangleProvider", () => { it("refuses to advertise exact processes without the exact adapter", async () => { const provider = createTangleProvider({ @@ -42,7 +49,7 @@ describe("createTangleProvider", () => { ); }); - it("maps create options, stream events, workspace methods, and dispatch sessions", async () => { + it("maps ordinary agent environments", async () => { let createOptions: CreateSandboxOptions | undefined; const files = new Map(); const box: SandboxInstanceLike = { @@ -54,11 +61,19 @@ describe("createTangleProvider", () => { type: "result", data: { finalText: `ok:${prompt}`, - usage: { inputTokens: 2, outputTokens: 3, totalCostUsd: 0.01 }, + usage: { + inputTokens: 2, + outputTokens: 3, + totalCostUsd: 0.01, + }, }, } as SandboxEvent; }, - dispatchPrompt: async () => ({ sessionId: "sess-1", status: "running", alreadyExisted: true }), + dispatchPrompt: async () => ({ + sessionId: "sess-1", + status: "running", + alreadyExisted: true, + }), read: async (path) => files.get(path) ?? "", write: async (path, content) => { files.set(path, content); @@ -86,171 +101,143 @@ describe("createTangleProvider", () => { expect(createOptions).toMatchObject({ backend: { type: "codex", profile: { name: "worker" } }, }); - const environment = await provider.create({ profile: "worker", backend: "codex" }); - await expect(environment.dispatch?.({ prompt: "go" })).resolves.toMatchObject({ - id: "sess-1", - metadata: { alreadyExisted: true }, - }); }); - it("does not delete an idempotently recovered environment when adaptation fails", async () => { - const deleted = vi.fn(async () => undefined); - const box: SandboxInstanceLike = { - id: "exact-recovered", - async *streamPrompt(): AsyncIterable {}, - delete: deleted, - }; - const client: SandboxClientLike = { - async create(options) { - box.exactProcess = await createExactProcessAttestation(options ?? {}); - return box; - }, - async get() { - return box; - }, - async list() { - return [box]; - }, - }; - const provider = createTangleProvider({ client, exactProcess: {} }); - - await expect( - provider.exactProcess?.create({ - image: EXACT_IMAGE, - egress: { mode: "blocked" }, - maxLifetimeMs: 10_000, - resources: EXACT_RESOURCES, - metadata: { executionId: "recovered" }, - idempotencyKey: "candidate-recovered", - }), - ).rejects.toThrow("does not expose exact files, processes, and deletion"); - expect(deleted).not.toHaveBeenCalled(); - }); - - it("creates a recoverable bare exact-process environment without ambient access", async () => { - let createOptions: CreateSandboxOptions | undefined; - let createRequestOptions: { signal?: AbortSignal; timeoutMs?: number } | undefined; - let listOptions: Record | undefined; - let write: - | { - path: string; - content: string; - options: { encoding: "base64"; mode: number; signal?: AbortSignal }; - } - | undefined; - let launch: - | { - executable: string; - args: readonly string[]; - options?: { - cwd?: string; - env?: Record; - inheritEnv?: boolean; - stdin?: string; - timeoutMs?: number; - signal?: AbortSignal; - }; - } + it("runs the exact-process lifecycle through process-only sandboxes", async () => { + let firstCreateOptions: ExactCreateOptions | undefined; + let createRequestOptions: + | { signal?: AbortSignal; timeoutMs?: number } | undefined; - let spawned = false; - let deleted = false; - let boundIdempotencyKey: string | undefined; - let attestExact = true; - let statusFails = false; + let listOptions: Record | undefined; + let writeOptions: Record | undefined; + let launchOptions: Record | undefined; + let readBatchCalls = 0; let killCalls = 0; - let listedBoxes: SandboxInstanceLike[] | undefined; - let attestationOverride: ExactProcessAttestation | undefined; - const writtenFiles = new Map(); - const status = { - pid: 73, - running: false, - exitCode: 0, - termination: { kind: "exit" as const, exitCode: 0 }, - }; - const process = { - pid: 73, - status: async () => { - if (statusFails) throw new Error("not found"); - return status; - }, - wait: async () => 0, - waitForTermination: async () => status.termination, - kill: async () => { - killCalls++; - }, - async *stdout() { - yield "ok"; - }, - async *stderr() {}, - }; - const box: SandboxInstanceLike = { - id: "exact-1", - status: "running", - metadata: { executionId: "execution-1", nested: { attempt: 1 } }, - async *streamPrompt(): AsyncIterable { - yield { type: "result", data: {} } as SandboxEvent; - }, - fs: { - supportsWriteMode: true, - async readBytes(path, options) { - const bytes = writtenFiles.get(path); - if (!bytes) throw new Error("not found"); - if (bytes.byteLength > options.maxBytes) { - throw new Error("file exceeds maxBytes"); - } - return bytes; - }, - async write(path, content, options) { - write = { path, content, options }; - writtenFiles.set(path, Buffer.from(content, "base64")); - }, - }, - process: { - list: async () => (spawned ? [status] : []), - get: async (pid) => (spawned && pid === process.pid ? process : null), - async spawnExact(executable, args, options) { - spawned = true; - launch = { executable, args, options }; - return process; - }, - }, - async delete() { - deleted = true; - }, - }; + let nextId = 1; + const records = new Map< + string, + { + signature: string; + box: SandboxInstanceLike; + deleted: boolean; + } + >(); + const client: SandboxClientLike = { - async create(options, requestOptions) { - if ( - !deleted && - boundIdempotencyKey === options?.idempotencyKey && - box.exactProcess - ) { - return box; + async create(rawOptions, requestOptions) { + const options = rawOptions as ExactCreateOptions; + const key = options.idempotencyKey ?? `unkeyed-${nextId}`; + const signature = JSON.stringify(options); + const existing = records.get(key); + if (existing) { + if (existing.signature !== signature) { + throw new Error("idempotency collision"); + } + existing.deleted = false; + return existing.box; } - createOptions = options; - createRequestOptions = requestOptions; - deleted = false; - spawned = false; - writtenFiles.clear(); - boundIdempotencyKey = options?.idempotencyKey; - box.metadata = { ...(options?.metadata ?? {}) }; - box.exactProcess = - attestExact && options?.exactProcess - ? (attestationOverride ?? (await createExactProcessAttestation(options))) - : undefined; - box.teamId = options?.teamId; + + firstCreateOptions ??= options; + createRequestOptions ??= requestOptions; + const files = new Map(); + let spawned = false; + const status = { + pid: 73, + running: false, + exitCode: 0, + }; + const process = { + pid: status.pid, + status: async () => status, + wait: async () => status.exitCode, + kill: async () => { + killCalls += 1; + }, + async *stdout() { + yield "ok"; + }, + async *stderr() {}, + }; + const id = `exact-${nextId++}`; + const record = { + signature, + deleted: false, + box: undefined as unknown as SandboxInstanceLike, + }; + const box: SandboxInstanceLike = { + id, + status: "running", + metadata: { + ...(options.metadata ?? {}), + runtimeMode: options.agent === false ? "control" : "agent", + }, + async *streamPrompt(): AsyncIterable {}, + fs: { + supportsWriteMode: true, + async stat(path) { + const bytes = files.get(path); + if (!bytes) throw new Error("not found"); + return { size: bytes.byteLength, isFile: true }; + }, + async readBatch(paths, readOptions) { + readBatchCalls += 1; + const found = paths.flatMap((path) => { + const bytes = files.get(path); + return bytes + ? [ + { + path, + content: Buffer.from(bytes).toString("base64"), + encoding: "base64" as const, + size: bytes.byteLength, + }, + ] + : []; + }); + return { + files: found, + errors: paths + .filter((path) => !files.has(path)) + .map((path) => ({ path, error: "not found" })), + }; + }, + async write(path, content, options) { + writeOptions = options; + files.set(path, Uint8Array.from(Buffer.from(content, "base64"))); + }, + }, + process: { + list: async () => (spawned ? [status] : []), + get: async (pid) => + spawned && pid === process.pid ? process : null, + async spawnExact(_executable, _args, options) { + spawned = true; + launchOptions = options; + return process; + }, + }, + async delete() { + record.deleted = true; + }, + }; + record.box = box; + records.set(key, record); return box; }, async get(id) { - return id === box.id && !deleted ? box : null; + for (const record of records.values()) { + if (record.box.id === id && !record.deleted) return record.box; + } + return null; }, async list(options) { listOptions = options as Record; - if (deleted) return []; - if (!listedBoxes) return [box]; + const visible = [...records.values()] + .filter((record) => !record.deleted) + .map((record) => record.box); const offset = Number(listOptions.offset ?? 0); - const limit = Number(listOptions.limit ?? 100); - return listedBoxes.slice(offset, offset + limit); + const limit = Number(listOptions.limit ?? 1_000); + return visible.slice(offset, offset + limit); }, }; const provider = createTangleProvider({ @@ -267,7 +254,7 @@ describe("createTangleProvider", () => { maxLifetimeMs: 61_000, provisionTimeoutMs: 3_000, resources: EXACT_RESOURCES, - metadata: { executionId: "execution-1", nested: { attempt: 1 } }, + metadata: { executionId: "execution-1" }, idempotencyKey: "candidate-1", }, launch: { @@ -281,11 +268,15 @@ describe("createTangleProvider", () => { expectedStdout: "ok", expectedStderr: "", }), - ).resolves.toMatchObject({ provider: "tangle-sandbox", environmentId: "exact-1" }); + ).resolves.toMatchObject({ + provider: "tangle-sandbox", + environmentId: "exact-1", + }); - expect(createOptions).toEqual({ + expect(firstCreateOptions).toEqual({ image: EXACT_IMAGE, - exactProcess: true, + agent: false, + driver: { type: "host-agent", runtimeBackend: "docker" }, publicEdge: false, ephemeral: true, sshEnabled: false, @@ -299,140 +290,93 @@ describe("createTangleProvider", () => { }, maxLifetimeSeconds: 61, idempotencyKey: "candidate-1", - metadata: { executionId: "execution-1", nested: { attempt: 1 } }, + metadata: { + executionId: "execution-1", + "tangle.exactProcess": true, + }, teamId: "team-1", resources: { cpuCores: 1, memoryMB: 512, diskGB: 1 }, }); expect(createRequestOptions?.timeoutMs).toBe(3_000); - expect(write).toEqual({ - path: "/tmp/agent-provider-testkit.bin", - content: "AAEC/w==", - options: { - encoding: "base64", - mode: 0o640, - signal: expect.any(AbortSignal), - }, - }); - expect(launch).toEqual({ - executable: "/usr/bin/agent", - args: ["--prompt", "hello world"], - options: { - cwd: "/workspace", - env: { MODEL_URL: "http://model.internal" }, - inheritEnv: false, - stdin: "input", - timeoutMs: 2_000, - signal: expect.any(AbortSignal), - }, - }); - expect(listOptions).toMatchObject({ scope: "team:team-1", limit: 100, offset: 0 }); - expect(deleted).toBe(true); - - const exact = provider.exactProcess; - expect(exact).toBeDefined(); - const environment = await exact!.create({ - image: EXACT_LOCAL_IMAGE, - egress: { mode: "blocked" }, - maxLifetimeMs: 10_000, - resources: EXACT_RESOURCES, - metadata: { executionId: "execution-2" }, - idempotencyKey: "candidate-2", - }); - await expect( - environment.process.spawn({ - executable: "agent", - args: [], - cwd: "/workspace", - env: {}, - timeoutMs: 1_000, - }), - ).rejects.toThrow("executable must be absolute"); - expect(spawned).toBe(false); - const staleProcess = await environment.process.spawn({ - executable: "/usr/bin/agent", - args: [], + expect(writeOptions).toEqual({ encoding: "base64", mode: 0o640 }); + expect(launchOptions).toMatchObject({ cwd: "/workspace", - env: {}, - timeoutMs: 1_000, + env: { MODEL_URL: "http://model.internal" }, + inheritEnv: false, + stdin: "input", + timeoutMs: 2_000, + signal: expect.any(AbortSignal), }); - const killCallsBeforeStaleHandle = killCalls; - statusFails = true; - await expect(staleProcess.kill()).resolves.toBeUndefined(); - expect(killCalls).toBe(killCallsBeforeStaleHandle + 1); - statusFails = false; - await expect( - environment.writeFile("relative", Uint8Array.of(1), { mode: 0o644 }), - ).rejects.toThrow("file path must be absolute"); - await expect( - environment.writeFile("/tmp/file", Uint8Array.of(1), { mode: 0o10000 }), - ).rejects.toThrow("file mode"); - await environment.destroy(); - - deleted = false; - box.metadata = { executionId: "ordinary", exactProcess: true }; - box.exactProcess = undefined; - box.teamId = "team-1"; - await expect(exact!.get(box.id)).resolves.toBeNull(); - await expect(exact!.list()).resolves.toEqual([]); - - const paginationAttestation = await createExactProcessAttestation({ - image: EXACT_IMAGE, - egressPolicy: { mode: "blocked" }, - maxLifetimeSeconds: 10, - metadata: { campaign: "pagination" }, - teamId: "team-1", + expect(listOptions).toMatchObject({ + scope: "team:team-1", + limit: 1_000, + offset: 0, }); - listedBoxes = Array.from({ length: 101 }, (_, index) => ({ - ...box, - id: `exact-${index}`, - exactProcess: paginationAttestation, - teamId: "team-1", - metadata: { campaign: "pagination" }, - })); - const paginated = await exact!.list({ metadata: { campaign: "pagination" } }); - expect(paginated).toHaveLength(101); - expect(listOptions).toMatchObject({ offset: 100, limit: 100 }); - listedBoxes = undefined; + expect(readBatchCalls).toBe(1); + expect(killCalls).toBe(1); + }); - attestationOverride = await createExactProcessAttestation({ - image: EXACT_IMAGE, - egressPolicy: { mode: "blocked" }, - maxLifetimeSeconds: 10, - metadata: { executionId: "old-input" }, - teamId: "team-1", + it("deletes a sandbox when the API cannot prove process-only mode", async () => { + const deleted = vi.fn(async () => undefined); + const box: SandboxInstanceLike = { + id: "unproven", + metadata: { "tangle.exactProcess": true }, + async *streamPrompt(): AsyncIterable {}, + delete: deleted, + }; + const provider = createTangleProvider({ + client: { + async create() { + return box; + }, + async get() { + return box; + }, + async list() { + return [box]; + }, + }, + exactProcess: {}, }); - await expect( - exact!.create({ - image: `example/other@sha256:${"2".repeat(64)}`, - egress: { mode: "blocked" }, - maxLifetimeMs: 10_000, - resources: EXACT_RESOURCES, - metadata: { executionId: "new-input" }, - idempotencyKey: "candidate-collision", - }), - ).rejects.toThrow("idempotency collision"); - attestationOverride = undefined; - attestExact = false; await expect( - exact!.create({ + provider.exactProcess?.create({ image: EXACT_IMAGE, egress: { mode: "blocked" }, maxLifetimeMs: 10_000, resources: EXACT_RESOURCES, - metadata: { executionId: "collision" }, - idempotencyKey: "candidate-unattested", + metadata: { executionId: "unproven" }, + idempotencyKey: "candidate-unproven", }), - ).rejects.toThrow("did not attest"); - expect(deleted).toBe(false); + ).rejects.toThrow("did not create the requested process-only runtime"); + expect(deleted).toHaveBeenCalledOnce(); + await expect(provider.exactProcess?.get(box.id)).resolves.toBeNull(); + }); + + it("rejects caller ownership markers", async () => { + const provider = createTangleProvider({ + client: { + async create() { + throw new Error("not called"); + }, + async get() { + return null; + }, + async list() { + return []; + }, + }, + exactProcess: {}, + }); + await expect( - exact!.create({ + provider.exactProcess?.create({ image: EXACT_IMAGE, egress: { mode: "blocked" }, maxLifetimeMs: 10_000, resources: EXACT_RESOURCES, - metadata: { exactProcess: true }, - idempotencyKey: "candidate-reserved-metadata", + metadata: { runtimeMode: "control" }, + idempotencyKey: "candidate-reserved", }), ).rejects.toThrow("metadata is reserved"); }); diff --git a/packages/agent-provider-tangle/src/index.ts b/packages/agent-provider-tangle/src/index.ts index b679a1f..5b8dc8f 100644 --- a/packages/agent-provider-tangle/src/index.ts +++ b/packages/agent-provider-tangle/src/index.ts @@ -1,7 +1,6 @@ import type { BackendType, CreateSandboxOptions, - ExactProcessAttestation, ExecResult as SandboxExecResult, PromptOptions, PromptResult, @@ -30,11 +29,7 @@ import type { PlacementInfo, ResourceRequest, } from "@tangle-network/agent-interface/environment-provider"; -import type { - AgentCandidateTermination, - InputPart, - TokenUsage, -} from "@tangle-network/agent-interface"; +import type { InputPart, TokenUsage } from "@tangle-network/agent-interface"; import { createTangleExactProcessProvider, type TangleExactProcessOptions, @@ -57,14 +52,12 @@ export interface SandboxProcessStatusLike { running: boolean; exitCode: number; exitSignal?: string; - termination?: AgentCandidateTermination; } export interface SandboxProcessLike { readonly pid: number; status(): Promise; wait(): Promise; - waitForTermination(): Promise; kill(signal?: "SIGKILL", options?: { tree?: boolean }): Promise; stdout(): AsyncIterable; stderr(): AsyncIterable; @@ -92,8 +85,6 @@ export interface SandboxInstanceLike { name?: string; status?: unknown; metadata?: Record; - exactProcess?: ExactProcessAttestation; - teamId?: string; streamPrompt(message: string | InputPart[], options?: PromptOptions): AsyncIterable; prompt?(message: string | InputPart[], options?: PromptOptions): Promise; dispatchPrompt?(message: string | InputPart[], options?: PromptOptions): Promise; @@ -103,14 +94,23 @@ export interface SandboxInstanceLike { exec?(command: string, options?: unknown): Promise; fs?: { supportsWriteMode?: true; - readBytes( - path: string, - options: { maxBytes: number; signal?: AbortSignal }, - ): Promise; + stat(path: string): Promise<{ size: number; isFile: boolean }>; + readBatch( + paths: string[], + options?: { encoding?: "utf8" | "base64" }, + ): Promise<{ + files: Array<{ + path: string; + content: string; + encoding: "utf8" | "base64"; + size: number; + }>; + errors: Array<{ path: string; error: string; code?: string }>; + }>; write( path: string, content: string, - options: { encoding: "base64"; mode: number; signal?: AbortSignal }, + options: { encoding: "base64"; mode: number }, ): Promise; }; process?: SandboxProcessManagerLike; diff --git a/packages/agent-provider-testkit/src/index.ts b/packages/agent-provider-testkit/src/index.ts index b55c4b7..a7075bb 100644 --- a/packages/agent-provider-testkit/src/index.ts +++ b/packages/agent-provider-testkit/src/index.ts @@ -3,10 +3,12 @@ import type { AgentEnvironmentCapabilities, AgentEnvironmentEvent, AgentEnvironmentProvider, + AgentExactProcessEnvironment, AgentExactProcessLaunch, CreateAgentEnvironmentInput, CreateAgentExactProcessEnvironmentInput, } from "@tangle-network/agent-interface/environment-provider"; +import type { AgentCandidateTermination } from "@tangle-network/agent-interface"; export interface ProviderConformanceOptions { name: string; @@ -31,6 +33,8 @@ export interface ExactProcessProviderLifecycleOptions { launch: AgentExactProcessLaunch; expectedStdout: string; expectedStderr: string; + /** Overall wall-clock bound for one lifecycle check. Defaults to 30 seconds. */ + timeoutMs?: number; } export interface ExactProcessProviderLifecycleReport { @@ -137,10 +141,30 @@ export async function runAgentExactProcessProviderLifecycleChecks( ); checked.push("exact-process-capability"); - const environment = await provider.exactProcess.create(options.createInput); - let destroyed = false; + const operation = new AbortController(); + const timeout = setTimeout( + () => operation.abort(new Error("exact process lifecycle check timed out")), + options.timeoutMs ?? 30_000, + ); + const signal = options.createInput.signal + ? AbortSignal.any([options.createInput.signal, operation.signal]) + : operation.signal; + let environment: AgentExactProcessEnvironment; + try { + environment = await abortable( + provider.exactProcess.create({ ...options.createInput, signal }), + signal, + ); + } catch (error) { + clearTimeout(timeout); + throw error; + } + let destroyAttempted = false; try { - const repeated = await provider.exactProcess.create(options.createInput); + const repeated = await abortable( + provider.exactProcess.create({ ...options.createInput, signal }), + signal, + ); assert( repeated.id === environment.id, "repeated exact create must recover the same environment", @@ -149,13 +173,28 @@ export async function runAgentExactProcessProviderLifecycleChecks( checked.push("exact-process-idempotency"); let collisionRejected = false; + let collisionEnvironment: AgentExactProcessEnvironment | undefined; try { - await provider.exactProcess.create({ - ...options.createInput, - maxLifetimeMs: options.createInput.maxLifetimeMs + 1_000, - }); + collisionEnvironment = await abortable( + provider.exactProcess.create({ + ...options.createInput, + maxLifetimeMs: options.createInput.maxLifetimeMs + 1_000, + signal, + }), + signal, + ); } catch { collisionRejected = true; + } finally { + if ( + collisionEnvironment?.id && + collisionEnvironment.id !== environment.id + ) { + await abortable( + collisionEnvironment.destroy(), + signal, + ); + } } assert( collisionRejected, @@ -168,14 +207,13 @@ export async function runAgentExactProcessProviderLifecycleChecks( checked.push("fresh-environment"); const expectedFile = Uint8Array.of(0, 1, 2, 255); - const operation = new AbortController(); await environment.writeFile("/tmp/agent-provider-testkit.bin", expectedFile, { mode: 0o640, - signal: operation.signal, + signal, }); const actualFile = await environment.readFile( "/tmp/agent-provider-testkit.bin", - { maxBytes: expectedFile.byteLength, signal: operation.signal }, + { maxBytes: expectedFile.byteLength, signal }, ); assert( bytesEqual(actualFile, expectedFile), @@ -186,7 +224,7 @@ export async function runAgentExactProcessProviderLifecycleChecks( try { await environment.readFile("/tmp/agent-provider-testkit.bin", { maxBytes: expectedFile.byteLength - 1, - signal: operation.signal, + signal, }); } catch { boundedReadRejected = true; @@ -199,16 +237,21 @@ export async function runAgentExactProcessProviderLifecycleChecks( checked.push("exact-file-roundtrip"); const process = await environment.process.spawn(options.launch, { - signal: operation.signal, + signal, }); + assert( + (await environment.process.list()).some((entry) => entry.pid === process.pid), + "spawned exact process must appear in process.list()", + checked, + ); const stdout = (await collect(process.stdout())).join(""); const stderr = (await collect(process.stderr())).join(""); - const termination = await process.wait(); + const termination = await abortable(process.wait(), signal); const status = await process.status(); assert(!status.running, "exact process must reach a terminal status", checked); assert(status.termination, "terminal exact process status requires a reason", checked); assert( - JSON.stringify(status.termination) === JSON.stringify(termination), + terminationEqual(status.termination, termination), "wait() and status() termination reasons must match", checked, ); @@ -241,13 +284,20 @@ export async function runAgentExactProcessProviderLifecycleChecks( ); checked.push("exact-process-list"); - await environment.destroy(); - destroyed = true; + destroyAttempted = true; + await abortable(environment.destroy(), signal); assert( (await provider.exactProcess.get(environment.id)) === null, "destroyed exact environment must not be recoverable", checked, ); + assert( + !(await provider.exactProcess.list({ + metadata: options.createInput.metadata, + })).some((candidate) => candidate.id === environment.id), + "destroyed exact environment must disappear from list()", + checked, + ); checked.push("exact-process-destroy"); return { @@ -257,7 +307,8 @@ export async function runAgentExactProcessProviderLifecycleChecks( checked, }; } finally { - if (!destroyed) await environment.destroy(); + clearTimeout(timeout); + if (!destroyAttempted) await environment.destroy(); } } @@ -301,6 +352,46 @@ function bytesEqual(left: Uint8Array, right: Uint8Array): boolean { return left.every((byte, index) => byte === right[index]); } +function terminationEqual( + left: AgentCandidateTermination, + right: AgentCandidateTermination, +): boolean { + if (!left || !right || typeof left !== "object" || typeof right !== "object") { + return false; + } + const a = left as Record; + const b = right as Record; + if (a.kind !== b.kind) return false; + switch (a.kind) { + case "exit": + return a.exitCode === b.exitCode; + case "timeout": + return a.timeoutMs === b.timeoutMs; + case "signal": + return a.signal === b.signal; + case "cancelled": + return true; + default: + return false; + } +} + +async function abortable(promise: Promise, signal: AbortSignal): Promise { + signal.throwIfAborted(); + let onAbort: (() => void) | undefined; + try { + return await Promise.race([ + promise, + new Promise((_resolve, reject) => { + onAbort = () => reject(signal.reason ?? new Error("operation aborted")); + signal.addEventListener("abort", onAbort, { once: true }); + }), + ]); + } finally { + if (onAbort) signal.removeEventListener("abort", onAbort); + } +} + function isTerminalEvent(event: AgentEnvironmentEvent): boolean { if (event.type === "result" || event.type === "done" || event.type === "final") return true; if (event.type.endsWith(".completed") || event.type.endsWith(".failed")) return true;