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-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/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", diff --git a/packages/agent-interface/src/environment-provider.ts b/packages/agent-interface/src/environment-provider.ts index 50212e9..dc7743a 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; + /** Each iteration replays buffered UTF-8 stdout, then continues until exit. */ + stdout(): AsyncIterable; + /** Each iteration replays buffered UTF-8 stderr, then continues until exit. */ + 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. + * Unsupported egress modes must fail instead of weakening the policy. + */ + 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; diff --git a/packages/agent-provider-tangle/README.md b/packages/agent-provider-tangle/README.md index 9e99793..86e96d9 100644 --- a/packages/agent-provider-tangle/README.md +++ b/packages/agent-provider-tangle/README.md @@ -10,3 +10,25 @@ const provider = createTangleProvider({ client: new Sandbox({ apiKey: process.env.TANGLE_API_KEY }), }) ``` + +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({ + client: new Sandbox({ apiKey: process.env.TANGLE_API_KEY }), + exactProcess: {}, +}) + +const environment = await provider.exactProcess!.create({ + image: 'ghcr.io/acme/agent@sha256:<64-hex-manifest-digest>', + 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..b844554 --- /dev/null +++ b/packages/agent-provider-tangle/src/exact-process.ts @@ -0,0 +1,452 @@ +import { isAbsolute } from "node:path"; +import { isDeepStrictEqual } from "node:util"; +import type { CreateSandboxOptions } from "@tangle-network/sandbox"; +import type { AgentCandidateTermination } from "@tangle-network/agent-interface"; +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; +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; +} + +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 { + 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 }), + }); + 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; + } + }, + async get(id): Promise { + const box = await get.call(client, id); + if (!box || !isExactProcessSandbox(box)) return null; + return sandboxInstanceAsExactProcessEnvironment(box, providerName); + }, + async list(query): Promise { + assertSupportedProviderOptions(query?.providerOptions); + const matches: AgentExactProcessEnvironment[] = []; + 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: LIST_PAGE_SIZE, + offset, + }); + for (const box of page) { + if ( + isExactProcessSandbox(box) && + metadataMatches(box.metadata, query?.metadata) + ) { + matches.push( + sandboxInstanceAsExactProcessEnvironment(box, providerName), + ); + } + } + if (page.length < LIST_PAGE_SIZE) return matches; + } + throw new Error( + "Tangle exact process lookup exceeds the Sandbox list pagination limit", + ); + }, + }; +} + +function exactSandboxOptions( + input: CreateAgentExactProcessEnvironmentInput, + defaults: TangleExactProcessOptions, +): 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", + ); + } + 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"); + } + const resources = sandboxResourcesFromRequest(input.resources); + return { + image: input.image, + agent: false, + driver: { type: "host-agent", runtimeBackend: "docker" }, + 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, + [EXACT_PROCESS_METADATA_KEY]: true, + }, + ...(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 || + !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 } : {}), + }, + ); + operation.signal?.throwIfAborted(); + return sandboxProcessAsExactProcess(handle); + }, + }, + async writeFile(path, bytes, options): Promise { + options.signal?.throwIfAborted(); + 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?.throwIfAborted(); + }, + async readFile(path, options): Promise { + options.signal?.throwIfAborted(); + assertAbsoluteFilePath(path); + if (!Number.isSafeInteger(options.maxBytes) || options.maxBytes < 1) { + throw new Error( + "Tangle exact process maxBytes must be a positive integer", + ); + } + 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; + }, + async destroy(): Promise { + await destroy(); + }, + }; +} + +function validateExactProcessLaunch(input: AgentExactProcessLaunch): void { + 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", + ); + } +} + +function sandboxProcessAsExactProcess( + process: SandboxProcessLike, +): AgentExactProcess { + return { + pid: process.pid, + async status(): Promise { + return exactProcessStatusFromSandbox(await process.status()); + }, + 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 }); + }, + async *stdout(): AsyncIterable { + yield* process.stdout(); + }, + async *stderr(): AsyncIterable { + yield* process.stderr(); + }, + }; +} + +function exactProcessStatusFromSandbox( + status: SandboxProcessStatusLike, +): AgentExactProcessStatus { + 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 } : {}), + ...(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, +): 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..5d8ab45 100644 --- a/packages/agent-provider-tangle/src/index.test.ts +++ b/packages/agent-provider-tangle/src/index.test.ts @@ -1,14 +1,55 @@ -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 type { + CreateSandboxOptions, + 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_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("maps create options, stream events, workspace methods, and dispatch sessions", async () => { + 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 ordinary agent environments", async () => { let createOptions: CreateSandboxOptions | undefined; const files = new Map(); const box: SandboxInstanceLike = { @@ -20,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); @@ -52,10 +101,283 @@ 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("runs the exact-process lifecycle through process-only sandboxes", async () => { + let firstCreateOptions: ExactCreateOptions | undefined; + let createRequestOptions: + | { signal?: AbortSignal; timeoutMs?: number } + | undefined; + let listOptions: Record | undefined; + let writeOptions: Record | undefined; + let launchOptions: Record | undefined; + let readBatchCalls = 0; + let killCalls = 0; + let nextId = 1; + const records = new Map< + string, + { + signature: string; + box: SandboxInstanceLike; + deleted: boolean; + } + >(); + + const client: SandboxClientLike = { + 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; + } + + 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) { + 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; + const visible = [...records.values()] + .filter((record) => !record.deleted) + .map((record) => record.box); + const offset = Number(listOptions.offset ?? 0); + const limit = Number(listOptions.limit ?? 1_000); + return visible.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" }, + 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(firstCreateOptions).toEqual({ + image: EXACT_IMAGE, + agent: false, + driver: { type: "host-agent", runtimeBackend: "docker" }, + 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", + "tangle.exactProcess": true, + }, + teamId: "team-1", + resources: { cpuCores: 1, memoryMB: 512, diskGB: 1 }, + }); + expect(createRequestOptions?.timeoutMs).toBe(3_000); + expect(writeOptions).toEqual({ encoding: "base64", mode: 0o640 }); + expect(launchOptions).toMatchObject({ + 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: 1_000, + offset: 0, + }); + expect(readBatchCalls).toBe(1); + expect(killCalls).toBe(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( + provider.exactProcess?.create({ + image: EXACT_IMAGE, + egress: { mode: "blocked" }, + maxLifetimeMs: 10_000, + resources: EXACT_RESOURCES, + metadata: { executionId: "unproven" }, + idempotencyKey: "candidate-unproven", + }), + ).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( + provider.exactProcess?.create({ + image: EXACT_IMAGE, + egress: { mode: "blocked" }, + maxLifetimeMs: 10_000, + resources: EXACT_RESOURCES, + 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 da8ae0b..5b8dc8f 100644 --- a/packages/agent-provider-tangle/src/index.ts +++ b/packages/agent-provider-tangle/src/index.ts @@ -30,14 +30,56 @@ import type { ResourceRequest, } from "@tangle-network/agent-interface/environment-provider"; import type { 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; +} + +export interface SandboxProcessLike { + readonly pid: number; + status(): Promise; + wait(): 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; @@ -50,6 +92,28 @@ 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; + 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 }, + ): 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..a7075bb 100644 --- a/packages/agent-provider-testkit/src/index.ts +++ b/packages/agent-provider-testkit/src/index.ts @@ -3,8 +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; @@ -23,6 +27,23 @@ export interface ProviderConformanceReport { checked: string[]; } +export interface ExactProcessProviderLifecycleOptions { + createProvider(): AgentEnvironmentProvider | Promise; + createInput: CreateAgentExactProcessEnvironmentInput; + launch: AgentExactProcessLaunch; + expectedStdout: string; + expectedStderr: string; + /** Overall wall-clock bound for one lifecycle check. Defaults to 30 seconds. */ + timeoutMs?: number; +} + +export interface ExactProcessProviderLifecycleReport { + provider: string; + environmentId: string; + pid: number; + checked: string[]; +} + export class ProviderConformanceError extends Error { constructor( message: string, @@ -104,6 +125,193 @@ 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 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 abortable( + provider.exactProcess.create({ ...options.createInput, signal }), + signal, + ); + assert( + repeated.id === environment.id, + "repeated exact create must recover the same environment", + checked, + ); + checked.push("exact-process-idempotency"); + + let collisionRejected = false; + let collisionEnvironment: AgentExactProcessEnvironment | undefined; + try { + 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, + "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); + await environment.writeFile("/tmp/agent-provider-testkit.bin", expectedFile, { + mode: 0o640, + signal, + }); + const actualFile = await environment.readFile( + "/tmp/agent-provider-testkit.bin", + { maxBytes: expectedFile.byteLength, 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, + }); + } 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, + }); + 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 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( + terminationEqual(status.termination, 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"); + + 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 { + provider: provider.name, + environmentId: environment.id, + pid: process.pid, + checked, + }; + } finally { + clearTimeout(timeout); + if (!destroyAttempted) await environment.destroy(); + } +} + async function checkWorkspace( environment: AgentEnvironment, capabilities: AgentEnvironmentCapabilities, @@ -139,6 +347,51 @@ 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 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; 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: