Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/exact-process-environments.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions packages/agent-interface/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
41 changes: 41 additions & 0 deletions packages/agent-interface/src/agent-candidate-code-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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") },
Expand Down Expand Up @@ -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,
}),
Expand Down Expand Up @@ -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: {
Expand All @@ -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,
};
Expand Down Expand Up @@ -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();
});
});
11 changes: 11 additions & 0 deletions packages/agent-interface/src/agent-candidate-code-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion packages/agent-interface/src/agent-candidate-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
138 changes: 138 additions & 0 deletions packages/agent-interface/src/environment-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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<AgentExactProcessStatus>;
wait(): Promise<AgentCandidateTermination>;
/** Force-stop the full process tree. Idempotent after the process exits. */
kill(): Promise<void>;
/** Each iteration replays buffered UTF-8 stdout, then continues until exit. */
stdout(): AsyncIterable<string>;
/** Each iteration replays buffered UTF-8 stderr, then continues until exit. */
stderr(): AsyncIterable<string>;
}

/** 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<Record<string, string>>;
stdin?: string;
/** Positive integer milliseconds, or zero to disable the process timeout. */
timeoutMs: number;
}

export interface AgentExactProcessManager {
list(): Promise<AgentExactProcessStatus[]>;
get(pid: number): Promise<AgentExactProcess | null>;
/** Providers must honor the abort signal when supplied. */
spawn(
input: AgentExactProcessLaunch,
options?: { signal?: AbortSignal },
): Promise<AgentExactProcess>;
}

/**
* 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<string, unknown>;
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<void>;
/** Read exact bytes or fail before content is loaded when the file exceeds maxBytes. */
readFile(
path: string,
options: { maxBytes: number; signal?: AbortSignal },
): Promise<Uint8Array>;
destroy(): Promise<void>;
}

export interface AgentExactProcessEnvironmentQuery {
/** Every supplied key/value must match persisted environment metadata exactly. */
metadata?: Record<string, unknown>;
providerOptions?: Record<string, unknown>;
}

/** 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<string, unknown>;
idempotencyKey: string;
signal?: AbortSignal;
/** Provider-native fields may narrow, but never weaken, the isolation contract. */
providerOptions?: Record<string, unknown>;
}

/** 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<AgentExactProcessEnvironment>;
/** Ordinary environments must return null. */
get(id: string): Promise<AgentExactProcessEnvironment | null>;
/** Return every matching exact environment; providers own any native pagination. */
list(query?: AgentExactProcessEnvironmentQuery): Promise<AgentExactProcessEnvironment[]>;
}

export interface CheckpointRequest {
name?: string;
metadata?: Record<string, unknown>;
Expand Down Expand Up @@ -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 {
Expand All @@ -221,6 +358,7 @@ export interface CreateAgentEnvironmentInput {

export interface AgentEnvironmentProvider {
readonly name: string;
readonly exactProcess?: AgentExactProcessProvider;
capabilities():
| AgentEnvironmentCapabilities
| Promise<AgentEnvironmentCapabilities>;
Expand Down
22 changes: 22 additions & 0 deletions packages/agent-provider-tangle/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading
Loading