@fusionkit/runner 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agents.d.ts +15 -0
- package/dist/agents.js +48 -0
- package/dist/backend.d.ts +32 -0
- package/dist/backend.js +1 -0
- package/dist/egress.d.ts +34 -0
- package/dist/egress.js +110 -0
- package/dist/execution.d.ts +45 -0
- package/dist/execution.js +147 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +12 -0
- package/dist/mock-agent.d.ts +1 -0
- package/dist/mock-agent.js +53 -0
- package/dist/process-backend.d.ts +17 -0
- package/dist/process-backend.js +85 -0
- package/dist/runner.d.ts +55 -0
- package/dist/runner.js +308 -0
- package/dist/session.d.ts +31 -0
- package/dist/session.js +54 -0
- package/dist/test/execution.test.d.ts +1 -0
- package/dist/test/execution.test.js +102 -0
- package/package.json +32 -0
package/dist/agents.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { AgentKind } from "@fusionkit/protocol";
|
|
2
|
+
export type AgentCommand = {
|
|
3
|
+
cmd: string;
|
|
4
|
+
args: string[];
|
|
5
|
+
};
|
|
6
|
+
export type AgentContext = {
|
|
7
|
+
/** Absolute path to the built-in mock agent script (dist). */
|
|
8
|
+
mockScriptPath: string;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Agent adapters build the command line for a vendor harness, unmodified.
|
|
12
|
+
* The labs' RL investment lands in their CLIs; Warrant wraps them in a
|
|
13
|
+
* governed session rather than reimplementing them.
|
|
14
|
+
*/
|
|
15
|
+
export declare function buildAgentCommand(kind: AgentKind, prompt: string, ctx: AgentContext): AgentCommand;
|
package/dist/agents.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent adapters build the command line for a vendor harness, unmodified.
|
|
3
|
+
* The labs' RL investment lands in their CLIs; Warrant wraps them in a
|
|
4
|
+
* governed session rather than reimplementing them.
|
|
5
|
+
*/
|
|
6
|
+
export function buildAgentCommand(kind, prompt, ctx) {
|
|
7
|
+
switch (kind) {
|
|
8
|
+
case "claude-code":
|
|
9
|
+
return {
|
|
10
|
+
// The vendor CLI invocation, wrapped as-is. These names and flags
|
|
11
|
+
// are the vendor's contract, not Warrant's to abstract away.
|
|
12
|
+
cmd: "claude",
|
|
13
|
+
args: ["-p", prompt, "--permission-mode", "acceptEdits"]
|
|
14
|
+
};
|
|
15
|
+
case "codex":
|
|
16
|
+
return {
|
|
17
|
+
cmd: "codex",
|
|
18
|
+
args: ["exec", "--skip-git-repo-check", prompt]
|
|
19
|
+
};
|
|
20
|
+
case "pi":
|
|
21
|
+
// Pi is a host-runtime harness with no vendor CLI to wrap: it runs only
|
|
22
|
+
// through the AI SDK harness session backend, which ignores this argv
|
|
23
|
+
// (exactly as the harness path ignores the claude-code argv). The argv
|
|
24
|
+
// is a non-spawnable placeholder that exists only so the prepared
|
|
25
|
+
// execution has a stable shape to hash; the process backend refuses to
|
|
26
|
+
// spawn pi outright, so this command line is never executed.
|
|
27
|
+
return {
|
|
28
|
+
cmd: "pi",
|
|
29
|
+
args: ["--harness-only"]
|
|
30
|
+
};
|
|
31
|
+
case "mock":
|
|
32
|
+
return {
|
|
33
|
+
cmd: process.execPath,
|
|
34
|
+
args: [ctx.mockScriptPath, prompt]
|
|
35
|
+
};
|
|
36
|
+
case "command":
|
|
37
|
+
// The task itself is the harness: one governed shell command. Used by
|
|
38
|
+
// app-owned loops (AI SDK adapter) and the compute adapter.
|
|
39
|
+
return {
|
|
40
|
+
cmd: "sh",
|
|
41
|
+
args: ["-c", prompt]
|
|
42
|
+
};
|
|
43
|
+
default: {
|
|
44
|
+
const exhausted = kind;
|
|
45
|
+
throw new Error(`unsupported agent kind: ${String(exhausted)}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { RunContract, RunEvent, SessionIsolation } from "@fusionkit/protocol";
|
|
2
|
+
import type { BackendExecutionKind, PreparedExecution } from "./execution.js";
|
|
3
|
+
/** Everything a backend needs to execute one governed session. */
|
|
4
|
+
export type SessionExecution = {
|
|
5
|
+
contract: RunContract;
|
|
6
|
+
/** Materialized workspace on the runner host. */
|
|
7
|
+
repoDir: string;
|
|
8
|
+
secrets: {
|
|
9
|
+
name: string;
|
|
10
|
+
value: string;
|
|
11
|
+
}[];
|
|
12
|
+
/** The backend-ready invocation prepared once by the runner. */
|
|
13
|
+
execution: PreparedExecution;
|
|
14
|
+
emit: (event: RunEvent) => void;
|
|
15
|
+
};
|
|
16
|
+
export type SessionBackendResult = {
|
|
17
|
+
exitCode: number;
|
|
18
|
+
log: Buffer;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* A session isolation backend. The runner owns workspace materialization,
|
|
22
|
+
* output collection, event flushing, and receipt signing; the backend owns
|
|
23
|
+
* only the execution boundary itself. The built-in backend is "process";
|
|
24
|
+
* stronger tiers (hermetic interpreter, microVMs) are injected so the
|
|
25
|
+
* runner kernel stays dependency-free.
|
|
26
|
+
*/
|
|
27
|
+
export type SessionBackend = {
|
|
28
|
+
readonly isolation: SessionIsolation;
|
|
29
|
+
/** Execution kinds this backend can execute. Undefined means all. */
|
|
30
|
+
supports?(kind: BackendExecutionKind, contract: RunContract): boolean;
|
|
31
|
+
execute(input: SessionExecution): Promise<SessionBackendResult>;
|
|
32
|
+
};
|
package/dist/backend.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/egress.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export type EgressEvent = {
|
|
2
|
+
host: string;
|
|
3
|
+
decision: "allowed" | "blocked";
|
|
4
|
+
};
|
|
5
|
+
export type EgressProxy = {
|
|
6
|
+
port: number;
|
|
7
|
+
close(): Promise<void>;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Deny-by-default egress proxy for agent sessions. The session environment
|
|
11
|
+
* points HTTP(S)_PROXY at this proxy; CONNECT tunnels and absolute-form HTTP
|
|
12
|
+
* requests are allowed only for allowlisted hosts, and every decision is
|
|
13
|
+
* reported as a network event.
|
|
14
|
+
*
|
|
15
|
+
* Honest limitation (documented in the spec): this is process-level
|
|
16
|
+
* enforcement. A malicious binary can ignore proxy variables; container or
|
|
17
|
+
* microVM network namespaces close that gap and are the roadmap isolation
|
|
18
|
+
* modes (the hermetic and Vercel Sandbox backends already do). Every allowed
|
|
19
|
+
* and blocked attempt is still recorded.
|
|
20
|
+
*
|
|
21
|
+
* The proxy is intentionally implemented on Node's http primitives rather
|
|
22
|
+
* than a third-party proxy library: it is a security enforcement point, so
|
|
23
|
+
* its full behavior lives here, auditable, and does no more than allowlist
|
|
24
|
+
* checks plus pass-through.
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* Parse a CONNECT authority (`host:port`) into host and port, handling
|
|
28
|
+
* bracketed IPv6 literals (`[2001:db8::1]:443`). Defaults to port 443.
|
|
29
|
+
*/
|
|
30
|
+
export declare function parseConnectAuthority(authority: string): {
|
|
31
|
+
host: string;
|
|
32
|
+
port: number;
|
|
33
|
+
};
|
|
34
|
+
export declare function startEgressProxy(allowHosts: string[], defaultDeny: boolean, onEvent: (event: EgressEvent) => void): Promise<EgressProxy>;
|
package/dist/egress.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { createServer, request as httpRequest } from "node:http";
|
|
2
|
+
import { connect as netConnect } from "node:net";
|
|
3
|
+
/**
|
|
4
|
+
* Deny-by-default egress proxy for agent sessions. The session environment
|
|
5
|
+
* points HTTP(S)_PROXY at this proxy; CONNECT tunnels and absolute-form HTTP
|
|
6
|
+
* requests are allowed only for allowlisted hosts, and every decision is
|
|
7
|
+
* reported as a network event.
|
|
8
|
+
*
|
|
9
|
+
* Honest limitation (documented in the spec): this is process-level
|
|
10
|
+
* enforcement. A malicious binary can ignore proxy variables; container or
|
|
11
|
+
* microVM network namespaces close that gap and are the roadmap isolation
|
|
12
|
+
* modes (the hermetic and Vercel Sandbox backends already do). Every allowed
|
|
13
|
+
* and blocked attempt is still recorded.
|
|
14
|
+
*
|
|
15
|
+
* The proxy is intentionally implemented on Node's http primitives rather
|
|
16
|
+
* than a third-party proxy library: it is a security enforcement point, so
|
|
17
|
+
* its full behavior lives here, auditable, and does no more than allowlist
|
|
18
|
+
* checks plus pass-through.
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* Parse a CONNECT authority (`host:port`) into host and port, handling
|
|
22
|
+
* bracketed IPv6 literals (`[2001:db8::1]:443`). Defaults to port 443.
|
|
23
|
+
*/
|
|
24
|
+
export function parseConnectAuthority(authority) {
|
|
25
|
+
const trimmed = authority.trim();
|
|
26
|
+
if (trimmed.startsWith("[")) {
|
|
27
|
+
const close = trimmed.indexOf("]");
|
|
28
|
+
if (close !== -1) {
|
|
29
|
+
const host = trimmed.slice(1, close);
|
|
30
|
+
const rest = trimmed.slice(close + 1);
|
|
31
|
+
const port = rest.startsWith(":") ? Number(rest.slice(1)) : 443;
|
|
32
|
+
return { host, port: Number.isFinite(port) ? port : 443 };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const lastColon = trimmed.lastIndexOf(":");
|
|
36
|
+
if (lastColon === -1)
|
|
37
|
+
return { host: trimmed, port: 443 };
|
|
38
|
+
const port = Number(trimmed.slice(lastColon + 1) || "443");
|
|
39
|
+
return {
|
|
40
|
+
host: trimmed.slice(0, lastColon),
|
|
41
|
+
port: Number.isFinite(port) ? port : 443
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
export function startEgressProxy(allowHosts, defaultDeny, onEvent) {
|
|
45
|
+
const allowed = new Set(allowHosts);
|
|
46
|
+
const isAllowed = (host) => !defaultDeny || allowed.has(host);
|
|
47
|
+
const server = createServer((req, res) => {
|
|
48
|
+
let host = "";
|
|
49
|
+
try {
|
|
50
|
+
host = new URL(req.url ?? "").hostname;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
res.writeHead(400);
|
|
54
|
+
res.end("proxy requires absolute-form URLs");
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (!isAllowed(host)) {
|
|
58
|
+
onEvent({ host, decision: "blocked" });
|
|
59
|
+
res.writeHead(403);
|
|
60
|
+
res.end("blocked by warrant egress policy");
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
onEvent({ host, decision: "allowed" });
|
|
64
|
+
const target = new URL(req.url ?? "");
|
|
65
|
+
const upstream = httpRequest({
|
|
66
|
+
hostname: target.hostname,
|
|
67
|
+
port: target.port || 80,
|
|
68
|
+
path: `${target.pathname}${target.search}`,
|
|
69
|
+
method: req.method,
|
|
70
|
+
headers: req.headers
|
|
71
|
+
}, (upstreamRes) => {
|
|
72
|
+
res.writeHead(upstreamRes.statusCode ?? 502, upstreamRes.headers);
|
|
73
|
+
upstreamRes.pipe(res);
|
|
74
|
+
});
|
|
75
|
+
upstream.on("error", () => {
|
|
76
|
+
res.writeHead(502);
|
|
77
|
+
res.end("upstream error");
|
|
78
|
+
});
|
|
79
|
+
req.pipe(upstream);
|
|
80
|
+
});
|
|
81
|
+
server.on("connect", (req, socket) => {
|
|
82
|
+
const { host, port } = parseConnectAuthority(req.url ?? "");
|
|
83
|
+
if (!host || !isAllowed(host)) {
|
|
84
|
+
onEvent({ host: host ?? "unknown", decision: "blocked" });
|
|
85
|
+
socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
|
|
86
|
+
socket.destroy();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
onEvent({ host, decision: "allowed" });
|
|
90
|
+
const upstream = netConnect(port, host, () => {
|
|
91
|
+
socket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
|
92
|
+
upstream.pipe(socket);
|
|
93
|
+
socket.pipe(upstream);
|
|
94
|
+
});
|
|
95
|
+
upstream.on("error", () => socket.destroy());
|
|
96
|
+
socket.on("error", () => upstream.destroy());
|
|
97
|
+
});
|
|
98
|
+
return new Promise((resolve) => {
|
|
99
|
+
server.listen(0, "127.0.0.1", () => {
|
|
100
|
+
const address = server.address();
|
|
101
|
+
const port = typeof address === "object" && address !== null ? address.port : 0;
|
|
102
|
+
resolve({
|
|
103
|
+
port,
|
|
104
|
+
close: () => new Promise((done) => {
|
|
105
|
+
server.close(() => done());
|
|
106
|
+
})
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { type ExecutionSpec, type RunContract } from "@fusionkit/protocol";
|
|
2
|
+
export type PreparedExecution = {
|
|
3
|
+
kind: "argv";
|
|
4
|
+
cmd: string;
|
|
5
|
+
args: string[];
|
|
6
|
+
cwd: string;
|
|
7
|
+
timeoutMs: number;
|
|
8
|
+
logMaxBytes?: number;
|
|
9
|
+
env: Record<string, string>;
|
|
10
|
+
egressProxy: boolean;
|
|
11
|
+
} | {
|
|
12
|
+
kind: "shell";
|
|
13
|
+
script: string;
|
|
14
|
+
shell: "sh" | "bash";
|
|
15
|
+
cwd: string;
|
|
16
|
+
timeoutMs: number;
|
|
17
|
+
logMaxBytes?: number;
|
|
18
|
+
env: Record<string, string>;
|
|
19
|
+
egressProxy: boolean;
|
|
20
|
+
};
|
|
21
|
+
export type BackendExecutionKind = PreparedExecution["kind"];
|
|
22
|
+
export type PrepareExecutionInput = {
|
|
23
|
+
contract: RunContract;
|
|
24
|
+
mockScriptPath: string;
|
|
25
|
+
};
|
|
26
|
+
/** Session wall-clock ceiling when neither execution nor contract sets a timeout. */
|
|
27
|
+
export declare const DEFAULT_TIMEOUT_MS: number;
|
|
28
|
+
/**
|
|
29
|
+
* Resolve a prepared execution env into the concrete session environment:
|
|
30
|
+
* released secrets are injected by name, `env.secrets` placeholders are
|
|
31
|
+
* substituted, and a placeholder whose secret was not released is omitted
|
|
32
|
+
* entirely (never leaked as a literal marker). Every backend uses this one
|
|
33
|
+
* resolution so the semantics cannot drift between isolation tiers.
|
|
34
|
+
*/
|
|
35
|
+
export declare function resolveSessionEnv(env: Record<string, string>, secrets: {
|
|
36
|
+
name: string;
|
|
37
|
+
value: string;
|
|
38
|
+
}[]): Record<string, string>;
|
|
39
|
+
/** The execution intent of a contract, whether explicit or defaulted. */
|
|
40
|
+
export declare function executionSpecFor(contract: RunContract): ExecutionSpec;
|
|
41
|
+
export declare function prepareExecution(input: PrepareExecutionInput): PreparedExecution;
|
|
42
|
+
export declare function executionHash(execution: PreparedExecution): string;
|
|
43
|
+
export declare function requireShellExecution(execution: PreparedExecution): Extract<PreparedExecution, {
|
|
44
|
+
kind: "shell";
|
|
45
|
+
}>;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { defaultExecutionSpec, hashCanonical } from "@fusionkit/protocol";
|
|
2
|
+
import { buildAgentCommand } from "./agents.js";
|
|
3
|
+
/** Session wall-clock ceiling when neither execution nor contract sets a timeout. */
|
|
4
|
+
export const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
|
|
5
|
+
/**
|
|
6
|
+
* Marker the runner writes into the prepared env for `env.secrets` mappings;
|
|
7
|
+
* backends resolve it against the released secrets via `resolveSessionEnv`.
|
|
8
|
+
* The value never reaches the session: it is replaced (or dropped) before
|
|
9
|
+
* any process, interpreter, or sandbox sees the environment.
|
|
10
|
+
*/
|
|
11
|
+
const SECRET_PLACEHOLDER_PREFIX = "__WARRANT_SECRET__:";
|
|
12
|
+
/**
|
|
13
|
+
* Resolve a prepared execution env into the concrete session environment:
|
|
14
|
+
* released secrets are injected by name, `env.secrets` placeholders are
|
|
15
|
+
* substituted, and a placeholder whose secret was not released is omitted
|
|
16
|
+
* entirely (never leaked as a literal marker). Every backend uses this one
|
|
17
|
+
* resolution so the semantics cannot drift between isolation tiers.
|
|
18
|
+
*/
|
|
19
|
+
export function resolveSessionEnv(env, secrets) {
|
|
20
|
+
const resolved = {};
|
|
21
|
+
for (const secret of secrets)
|
|
22
|
+
resolved[secret.name] = secret.value;
|
|
23
|
+
for (const [key, value] of Object.entries(env)) {
|
|
24
|
+
if (value.startsWith(SECRET_PLACEHOLDER_PREFIX)) {
|
|
25
|
+
const secretName = value.slice(SECRET_PLACEHOLDER_PREFIX.length);
|
|
26
|
+
const secret = secrets.find((item) => item.name === secretName);
|
|
27
|
+
if (secret)
|
|
28
|
+
resolved[key] = secret.value;
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
resolved[key] = value;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return resolved;
|
|
35
|
+
}
|
|
36
|
+
/** The execution intent of a contract, whether explicit or defaulted. */
|
|
37
|
+
export function executionSpecFor(contract) {
|
|
38
|
+
return contract.execution ?? defaultExecutionSpec(contract.agent, contract.task.prompt);
|
|
39
|
+
}
|
|
40
|
+
function timeoutMsFor(contract, spec) {
|
|
41
|
+
if (spec.timeoutMs !== undefined)
|
|
42
|
+
return spec.timeoutMs;
|
|
43
|
+
if (contract.budget.maxDurationMin !== undefined) {
|
|
44
|
+
return contract.budget.maxDurationMin * 60 * 1000;
|
|
45
|
+
}
|
|
46
|
+
return DEFAULT_TIMEOUT_MS;
|
|
47
|
+
}
|
|
48
|
+
function cwdFor(spec) {
|
|
49
|
+
return spec.kind === "agent" ? "." : spec.cwd ?? ".";
|
|
50
|
+
}
|
|
51
|
+
function envFor(spec) {
|
|
52
|
+
const policy = spec.env;
|
|
53
|
+
const env = {};
|
|
54
|
+
for (const key of policy?.inherit ?? []) {
|
|
55
|
+
const value = process.env[key];
|
|
56
|
+
if (value !== undefined)
|
|
57
|
+
env[key] = value;
|
|
58
|
+
}
|
|
59
|
+
Object.assign(env, policy?.vars ?? {});
|
|
60
|
+
for (const secret of policy?.secrets ?? []) {
|
|
61
|
+
env[secret.env] = `${SECRET_PLACEHOLDER_PREFIX}${secret.secretName}`;
|
|
62
|
+
}
|
|
63
|
+
return { env, egressProxy: policy?.egressProxy ?? true };
|
|
64
|
+
}
|
|
65
|
+
function logMaxBytesFor(spec) {
|
|
66
|
+
return spec.log?.maxBytes;
|
|
67
|
+
}
|
|
68
|
+
function prepareAgentExecution(spec, ctx, contract) {
|
|
69
|
+
const command = buildAgentCommand(spec.agent.kind, spec.prompt, ctx);
|
|
70
|
+
const { env, egressProxy } = envFor(spec);
|
|
71
|
+
return {
|
|
72
|
+
kind: "argv",
|
|
73
|
+
cmd: command.cmd,
|
|
74
|
+
args: command.args,
|
|
75
|
+
cwd: cwdFor(spec),
|
|
76
|
+
timeoutMs: timeoutMsFor(contract, spec),
|
|
77
|
+
logMaxBytes: logMaxBytesFor(spec),
|
|
78
|
+
env,
|
|
79
|
+
egressProxy
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
export function prepareExecution(input) {
|
|
83
|
+
const { contract, mockScriptPath } = input;
|
|
84
|
+
const spec = executionSpecFor(contract);
|
|
85
|
+
switch (spec.kind) {
|
|
86
|
+
case "agent":
|
|
87
|
+
return prepareAgentExecution(spec, { mockScriptPath }, contract);
|
|
88
|
+
case "argv": {
|
|
89
|
+
const { env, egressProxy } = envFor(spec);
|
|
90
|
+
return {
|
|
91
|
+
kind: "argv",
|
|
92
|
+
cmd: spec.command,
|
|
93
|
+
args: spec.args,
|
|
94
|
+
cwd: cwdFor(spec),
|
|
95
|
+
timeoutMs: timeoutMsFor(contract, spec),
|
|
96
|
+
logMaxBytes: logMaxBytesFor(spec),
|
|
97
|
+
env,
|
|
98
|
+
egressProxy
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
case "shell": {
|
|
102
|
+
const { env, egressProxy } = envFor(spec);
|
|
103
|
+
return {
|
|
104
|
+
kind: "shell",
|
|
105
|
+
script: spec.script,
|
|
106
|
+
shell: spec.shell ?? "sh",
|
|
107
|
+
cwd: cwdFor(spec),
|
|
108
|
+
timeoutMs: timeoutMsFor(contract, spec),
|
|
109
|
+
logMaxBytes: logMaxBytesFor(spec),
|
|
110
|
+
env,
|
|
111
|
+
egressProxy
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
default: {
|
|
115
|
+
const exhausted = spec;
|
|
116
|
+
throw new Error(`unsupported execution spec: ${String(exhausted)}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
export function executionHash(execution) {
|
|
121
|
+
switch (execution.kind) {
|
|
122
|
+
case "argv":
|
|
123
|
+
return hashCanonical({
|
|
124
|
+
kind: "argv",
|
|
125
|
+
cmd: execution.cmd,
|
|
126
|
+
args: execution.args,
|
|
127
|
+
cwd: execution.cwd
|
|
128
|
+
});
|
|
129
|
+
case "shell":
|
|
130
|
+
return hashCanonical({
|
|
131
|
+
kind: "shell",
|
|
132
|
+
shell: execution.shell,
|
|
133
|
+
script: execution.script,
|
|
134
|
+
cwd: execution.cwd
|
|
135
|
+
});
|
|
136
|
+
default: {
|
|
137
|
+
const exhausted = execution;
|
|
138
|
+
throw new Error(`unsupported prepared execution: ${String(exhausted)}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
export function requireShellExecution(execution) {
|
|
143
|
+
if (execution.kind !== "shell") {
|
|
144
|
+
throw new Error(`expected shell execution, got ${execution.kind}`);
|
|
145
|
+
}
|
|
146
|
+
return execution;
|
|
147
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fusionkit/runner — outbound-only runner: claims signed contracts,
|
|
3
|
+
* materializes workspaces, runs vendor agent harnesses inside governed
|
|
4
|
+
* sessions, and signs receipts.
|
|
5
|
+
*
|
|
6
|
+
* The public surface is deliberately small: the Runner itself, the
|
|
7
|
+
* SessionBackend seam that isolation tiers implement, and the execution
|
|
8
|
+
* helpers those backends share. Everything else is runner-internal.
|
|
9
|
+
*/
|
|
10
|
+
export { Runner } from "./runner.js";
|
|
11
|
+
export { CapabilityMismatchError } from "./session.js";
|
|
12
|
+
export type { SessionBackend, SessionBackendResult, SessionExecution } from "./backend.js";
|
|
13
|
+
export { executionHash, executionSpecFor, prepareExecution, requireShellExecution, resolveSessionEnv } from "./execution.js";
|
|
14
|
+
export type { BackendExecutionKind } from "./execution.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fusionkit/runner — outbound-only runner: claims signed contracts,
|
|
3
|
+
* materializes workspaces, runs vendor agent harnesses inside governed
|
|
4
|
+
* sessions, and signs receipts.
|
|
5
|
+
*
|
|
6
|
+
* The public surface is deliberately small: the Runner itself, the
|
|
7
|
+
* SessionBackend seam that isolation tiers implement, and the execution
|
|
8
|
+
* helpers those backends share. Everything else is runner-internal.
|
|
9
|
+
*/
|
|
10
|
+
export { Runner } from "./runner.js";
|
|
11
|
+
export { CapabilityMismatchError } from "./session.js";
|
|
12
|
+
export { executionHash, executionSpecFor, prepareExecution, requireShellExecution, resolveSessionEnv } from "./execution.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in mock agent used for tests and demos without vendor CLIs or
|
|
3
|
+
* API keys. It behaves like a tiny coding agent:
|
|
4
|
+
*
|
|
5
|
+
* - appends its task to MOCK_AGENT.md in the workspace
|
|
6
|
+
* - reports whether the MOCK_SECRET env var was injected (never its value)
|
|
7
|
+
* - when the task mentions "network", attempts an egress call through the
|
|
8
|
+
* session proxy so governed network enforcement can be observed
|
|
9
|
+
* - exits non-zero when the task mentions "fail"
|
|
10
|
+
*/
|
|
11
|
+
import { appendFileSync } from "node:fs";
|
|
12
|
+
import { request } from "node:http";
|
|
13
|
+
// A host that is never on any test/demo allowlist, so the probe deterministically
|
|
14
|
+
// exercises deny-by-default egress. Override with MOCK_PROBE_HOST if needed.
|
|
15
|
+
const PROBE_HOST = process.env.MOCK_PROBE_HOST ?? "denied.example.com";
|
|
16
|
+
const prompt = process.argv[2] ?? "";
|
|
17
|
+
appendFileSync("MOCK_AGENT.md", `## task\n${prompt}\n\nsecret:${process.env.MOCK_SECRET ? "present" : "absent"}\n`);
|
|
18
|
+
function tryNetwork() {
|
|
19
|
+
const proxy = process.env.HTTP_PROXY;
|
|
20
|
+
if (!proxy)
|
|
21
|
+
return Promise.resolve();
|
|
22
|
+
const proxyUrl = new URL(proxy);
|
|
23
|
+
return new Promise((resolve) => {
|
|
24
|
+
const req = request({
|
|
25
|
+
hostname: proxyUrl.hostname,
|
|
26
|
+
port: Number(proxyUrl.port),
|
|
27
|
+
path: `http://${PROBE_HOST}/probe`,
|
|
28
|
+
method: "GET",
|
|
29
|
+
headers: { host: PROBE_HOST }
|
|
30
|
+
}, (res) => {
|
|
31
|
+
console.log(`network probe status: ${res.statusCode}`);
|
|
32
|
+
res.resume();
|
|
33
|
+
res.on("end", resolve);
|
|
34
|
+
});
|
|
35
|
+
req.on("error", () => {
|
|
36
|
+
console.log("network probe failed to reach proxy");
|
|
37
|
+
resolve();
|
|
38
|
+
});
|
|
39
|
+
req.end();
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
async function main() {
|
|
43
|
+
console.log(`mock agent received task: ${prompt}`);
|
|
44
|
+
if (prompt.includes("network")) {
|
|
45
|
+
await tryNetwork();
|
|
46
|
+
}
|
|
47
|
+
if (prompt.includes("fail")) {
|
|
48
|
+
console.error("mock agent failing as instructed");
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
console.log("mock agent done");
|
|
52
|
+
}
|
|
53
|
+
void main();
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { SessionBackend, SessionBackendResult, SessionExecution } from "./backend.js";
|
|
2
|
+
import type { BackendExecutionKind } from "./execution.js";
|
|
3
|
+
/**
|
|
4
|
+
* The built-in backend: the harness runs as a child process with a
|
|
5
|
+
* scrubbed environment, injected secrets, and deny-by-default egress
|
|
6
|
+
* through the session proxy.
|
|
7
|
+
*
|
|
8
|
+
* Honest limitation (documented in the spec): this is process-level
|
|
9
|
+
* enforcement. A malicious binary can ignore proxy variables; the
|
|
10
|
+
* hermetic and microVM backends close that gap. Every allowed and
|
|
11
|
+
* blocked attempt is still recorded.
|
|
12
|
+
*/
|
|
13
|
+
export declare class ProcessSessionBackend implements SessionBackend {
|
|
14
|
+
readonly isolation: "process";
|
|
15
|
+
supports(_kind: BackendExecutionKind, contract: SessionExecution["contract"]): boolean;
|
|
16
|
+
execute(input: SessionExecution): Promise<SessionBackendResult>;
|
|
17
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { resolveInsideWorkspace } from "@fusionkit/workspace";
|
|
3
|
+
import { startEgressProxy } from "./egress.js";
|
|
4
|
+
import { executionHash, resolveSessionEnv } from "./execution.js";
|
|
5
|
+
/** Minimal PATH/HOME used only when the host process has none set. */
|
|
6
|
+
const FALLBACK_PATH = "/usr/bin:/bin";
|
|
7
|
+
const FALLBACK_HOME = "/tmp";
|
|
8
|
+
/** Exit code reported when the harness binary cannot be spawned. */
|
|
9
|
+
const SPAWN_ERROR_EXIT_CODE = 127;
|
|
10
|
+
/**
|
|
11
|
+
* The built-in backend: the harness runs as a child process with a
|
|
12
|
+
* scrubbed environment, injected secrets, and deny-by-default egress
|
|
13
|
+
* through the session proxy.
|
|
14
|
+
*
|
|
15
|
+
* Honest limitation (documented in the spec): this is process-level
|
|
16
|
+
* enforcement. A malicious binary can ignore proxy variables; the
|
|
17
|
+
* hermetic and microVM backends close that gap. Every allowed and
|
|
18
|
+
* blocked attempt is still recorded.
|
|
19
|
+
*/
|
|
20
|
+
export class ProcessSessionBackend {
|
|
21
|
+
isolation = "process";
|
|
22
|
+
supports(_kind, contract) {
|
|
23
|
+
// Pi is a host-runtime harness with no vendor CLI: it runs only through
|
|
24
|
+
// the AI SDK harness backend, never as a spawned process. Refusing it
|
|
25
|
+
// here is the single enforcement point for "pi never runs as a process",
|
|
26
|
+
// so a runner that lacks a pi harness backend fails closed instead of
|
|
27
|
+
// trying to spawn the placeholder command.
|
|
28
|
+
return contract.agent.kind !== "pi";
|
|
29
|
+
}
|
|
30
|
+
async execute(input) {
|
|
31
|
+
const { contract, repoDir, secrets, execution, emit } = input;
|
|
32
|
+
const proxy = execution.egressProxy
|
|
33
|
+
? await startEgressProxy(contract.network.allowHosts, contract.network.defaultDeny, ({ host, decision }) => emit({ type: "network.connected", host, decision }))
|
|
34
|
+
: undefined;
|
|
35
|
+
const env = {
|
|
36
|
+
PATH: process.env.PATH ?? FALLBACK_PATH,
|
|
37
|
+
HOME: process.env.HOME ?? FALLBACK_HOME
|
|
38
|
+
};
|
|
39
|
+
if (proxy) {
|
|
40
|
+
env.HTTP_PROXY = `http://127.0.0.1:${proxy.port}`;
|
|
41
|
+
env.HTTPS_PROXY = `http://127.0.0.1:${proxy.port}`;
|
|
42
|
+
env.http_proxy = `http://127.0.0.1:${proxy.port}`;
|
|
43
|
+
env.https_proxy = `http://127.0.0.1:${proxy.port}`;
|
|
44
|
+
}
|
|
45
|
+
Object.assign(env, resolveSessionEnv(execution.env, secrets));
|
|
46
|
+
const { cmd, args } = execution.kind === "argv"
|
|
47
|
+
? { cmd: execution.cmd, args: execution.args }
|
|
48
|
+
: { cmd: execution.shell, args: ["-c", execution.script] };
|
|
49
|
+
const cwd = resolveInsideWorkspace(repoDir, execution.cwd);
|
|
50
|
+
const chunks = [];
|
|
51
|
+
let capturedBytes = 0;
|
|
52
|
+
const exitCode = await new Promise((resolve) => {
|
|
53
|
+
const child = spawn(cmd, args, { cwd, env });
|
|
54
|
+
const push = (chunk) => {
|
|
55
|
+
chunks.push(chunk);
|
|
56
|
+
capturedBytes += chunk.byteLength;
|
|
57
|
+
if (execution.logMaxBytes !== undefined && capturedBytes > execution.logMaxBytes) {
|
|
58
|
+
child.kill("SIGKILL");
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
const timer = setTimeout(() => {
|
|
62
|
+
child.kill("SIGKILL");
|
|
63
|
+
}, execution.timeoutMs);
|
|
64
|
+
child.stdout.on("data", push);
|
|
65
|
+
child.stderr.on("data", push);
|
|
66
|
+
child.on("error", (error) => {
|
|
67
|
+
chunks.push(Buffer.from(`spawn error: ${error.message}\n`, "utf8"));
|
|
68
|
+
clearTimeout(timer);
|
|
69
|
+
// 127 is the conventional shell exit code for "command not found".
|
|
70
|
+
resolve(SPAWN_ERROR_EXIT_CODE);
|
|
71
|
+
});
|
|
72
|
+
child.on("close", (code) => {
|
|
73
|
+
clearTimeout(timer);
|
|
74
|
+
resolve(code ?? 1);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
await proxy?.close();
|
|
78
|
+
emit({
|
|
79
|
+
type: "command.executed",
|
|
80
|
+
argvHash: executionHash(execution),
|
|
81
|
+
exitCode
|
|
82
|
+
});
|
|
83
|
+
return { exitCode, log: Buffer.concat(chunks) };
|
|
84
|
+
}
|
|
85
|
+
}
|
package/dist/runner.d.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { SessionBackend } from "./backend.js";
|
|
2
|
+
export type RunnerOptions = {
|
|
3
|
+
planeUrl: string;
|
|
4
|
+
pool: string;
|
|
5
|
+
dataDir: string;
|
|
6
|
+
enrollToken?: string;
|
|
7
|
+
pollIntervalMs?: number;
|
|
8
|
+
mockScriptPath?: string;
|
|
9
|
+
/** How long to keep retrying enrollment while the plane is unreachable. */
|
|
10
|
+
enrollRetryMs?: number;
|
|
11
|
+
/**
|
|
12
|
+
* Session isolation backends beyond the built-in "process" backend
|
|
13
|
+
* (e.g. hermetic interpreter or microVM). Injected so the runner kernel
|
|
14
|
+
* stays dependency-free.
|
|
15
|
+
*/
|
|
16
|
+
backends?: SessionBackend[];
|
|
17
|
+
/**
|
|
18
|
+
* How many claimed runs this runner executes at the same time.
|
|
19
|
+
* Defaults to 1, which preserves the strictly sequential claim loop.
|
|
20
|
+
* Each execution already owns all of its state (event chain, temp
|
|
21
|
+
* session dir), so concurrency is purely a claim-loop property — this
|
|
22
|
+
* is the single knob for one runner host; scale-out across hosts is
|
|
23
|
+
* more enrolled runners in the pool.
|
|
24
|
+
*/
|
|
25
|
+
concurrency?: number;
|
|
26
|
+
};
|
|
27
|
+
type StoredIdentity = {
|
|
28
|
+
runnerId: string;
|
|
29
|
+
runnerToken: string;
|
|
30
|
+
pool: string;
|
|
31
|
+
};
|
|
32
|
+
export declare class Runner {
|
|
33
|
+
private readonly options;
|
|
34
|
+
private readonly client;
|
|
35
|
+
private identity?;
|
|
36
|
+
private publicKeyPem;
|
|
37
|
+
private privateKeyPem;
|
|
38
|
+
private stopped;
|
|
39
|
+
constructor(options: RunnerOptions);
|
|
40
|
+
ensureEnrolled(): Promise<StoredIdentity>;
|
|
41
|
+
/**
|
|
42
|
+
* Enroll against the plane, retrying while it is unreachable. Runners
|
|
43
|
+
* routinely start before the plane in container deployments; a transport
|
|
44
|
+
* failure here is a startup-ordering problem, not a fatal one. An invalid
|
|
45
|
+
* enroll token is rejected by the plane with a response and never retried.
|
|
46
|
+
*/
|
|
47
|
+
private enrollWithRetry;
|
|
48
|
+
/** Poll once; execute at most one claimed run. Returns the run id if work was done. */
|
|
49
|
+
runOnce(): Promise<string | undefined>;
|
|
50
|
+
start(): Promise<void>;
|
|
51
|
+
stop(): void;
|
|
52
|
+
private execute;
|
|
53
|
+
private buildReceipt;
|
|
54
|
+
}
|
|
55
|
+
export {};
|
package/dist/runner.js
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { appendEvent, contractHash, generateEd25519KeyPair, hashCanonical, keyIdFromPublicPem, signReceipt } from "@fusionkit/protocol";
|
|
7
|
+
import { PlaneClient } from "@fusionkit/sdk";
|
|
8
|
+
import { materializeWorkspace } from "@fusionkit/workspace";
|
|
9
|
+
import { runSession } from "./session.js";
|
|
10
|
+
/** Default time budget for enrollment retries while the plane is unreachable. */
|
|
11
|
+
const DEFAULT_ENROLL_RETRY_MS = 60_000;
|
|
12
|
+
/** Wait between enrollment retry attempts. */
|
|
13
|
+
const ENROLL_RETRY_INTERVAL_MS = 2_000;
|
|
14
|
+
/** Default idle poll interval between claim attempts. */
|
|
15
|
+
const DEFAULT_POLL_INTERVAL_MS = 1_000;
|
|
16
|
+
/** Attestation tier reported by this software runner: honestly "mock". */
|
|
17
|
+
const RUNNER_ATTESTATION_TIER = "mock";
|
|
18
|
+
const DEFAULT_MOCK_SCRIPT = fileURLToPath(new URL("./mock-agent.js", import.meta.url));
|
|
19
|
+
const REDACTED_SECRET_PREFIX = "[REDACTED:";
|
|
20
|
+
function redactSecrets(buffer, secrets) {
|
|
21
|
+
let text = buffer.toString("utf8");
|
|
22
|
+
for (const secret of secrets) {
|
|
23
|
+
if (secret.value.length === 0)
|
|
24
|
+
continue;
|
|
25
|
+
text = text.split(secret.value).join(`${REDACTED_SECRET_PREFIX}${secret.name}]`);
|
|
26
|
+
}
|
|
27
|
+
return Buffer.from(text, "utf8");
|
|
28
|
+
}
|
|
29
|
+
export class Runner {
|
|
30
|
+
options;
|
|
31
|
+
client;
|
|
32
|
+
identity;
|
|
33
|
+
publicKeyPem = "";
|
|
34
|
+
privateKeyPem = "";
|
|
35
|
+
stopped = false;
|
|
36
|
+
constructor(options) {
|
|
37
|
+
this.options = options;
|
|
38
|
+
this.client = new PlaneClient(options.planeUrl);
|
|
39
|
+
}
|
|
40
|
+
async ensureEnrolled() {
|
|
41
|
+
if (this.identity)
|
|
42
|
+
return this.identity;
|
|
43
|
+
const dir = this.options.dataDir;
|
|
44
|
+
mkdirSync(dir, { recursive: true });
|
|
45
|
+
const identityPath = join(dir, "identity.json");
|
|
46
|
+
const pubPath = join(dir, "runner.pub.pem");
|
|
47
|
+
const keyPath = join(dir, "runner.key.pem");
|
|
48
|
+
if (existsSync(identityPath) && existsSync(pubPath) && existsSync(keyPath)) {
|
|
49
|
+
this.identity = JSON.parse(readFileSync(identityPath, "utf8"));
|
|
50
|
+
this.publicKeyPem = readFileSync(pubPath, "utf8");
|
|
51
|
+
this.privateKeyPem = readFileSync(keyPath, "utf8");
|
|
52
|
+
return this.identity;
|
|
53
|
+
}
|
|
54
|
+
if (!this.options.enrollToken) {
|
|
55
|
+
throw new Error("runner is not enrolled and no enroll token was provided");
|
|
56
|
+
}
|
|
57
|
+
const keys = generateEd25519KeyPair();
|
|
58
|
+
const enrolled = await this.enrollWithRetry(keys.publicKeyPem);
|
|
59
|
+
const identity = {
|
|
60
|
+
runnerId: enrolled.runnerId,
|
|
61
|
+
runnerToken: enrolled.runnerToken,
|
|
62
|
+
pool: this.options.pool
|
|
63
|
+
};
|
|
64
|
+
writeFileSync(identityPath, JSON.stringify(identity, null, 2), {
|
|
65
|
+
mode: 0o600
|
|
66
|
+
});
|
|
67
|
+
writeFileSync(pubPath, keys.publicKeyPem, { mode: 0o600 });
|
|
68
|
+
writeFileSync(keyPath, keys.privateKeyPem, { mode: 0o600 });
|
|
69
|
+
this.identity = identity;
|
|
70
|
+
this.publicKeyPem = keys.publicKeyPem;
|
|
71
|
+
this.privateKeyPem = keys.privateKeyPem;
|
|
72
|
+
return identity;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Enroll against the plane, retrying while it is unreachable. Runners
|
|
76
|
+
* routinely start before the plane in container deployments; a transport
|
|
77
|
+
* failure here is a startup-ordering problem, not a fatal one. An invalid
|
|
78
|
+
* enroll token is rejected by the plane with a response and never retried.
|
|
79
|
+
*/
|
|
80
|
+
async enrollWithRetry(publicKeyPem) {
|
|
81
|
+
const deadline = Date.now() + (this.options.enrollRetryMs ?? DEFAULT_ENROLL_RETRY_MS);
|
|
82
|
+
const enrollToken = this.options.enrollToken;
|
|
83
|
+
if (!enrollToken)
|
|
84
|
+
throw new Error("no enroll token was provided");
|
|
85
|
+
for (;;) {
|
|
86
|
+
try {
|
|
87
|
+
return await this.client.enroll({
|
|
88
|
+
enrollToken,
|
|
89
|
+
publicKeyPem,
|
|
90
|
+
pool: this.options.pool
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
const transport = error instanceof TypeError;
|
|
95
|
+
if (!transport || Date.now() >= deadline)
|
|
96
|
+
throw error;
|
|
97
|
+
console.error(`plane not reachable at ${this.options.planeUrl}; retrying enrollment...`);
|
|
98
|
+
await new Promise((resolve) => setTimeout(resolve, ENROLL_RETRY_INTERVAL_MS));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/** Poll once; execute at most one claimed run. Returns the run id if work was done. */
|
|
103
|
+
async runOnce() {
|
|
104
|
+
const identity = await this.ensureEnrolled();
|
|
105
|
+
const claim = await this.client.claim({
|
|
106
|
+
runnerToken: identity.runnerToken,
|
|
107
|
+
pool: identity.pool
|
|
108
|
+
});
|
|
109
|
+
if ("empty" in claim)
|
|
110
|
+
return undefined;
|
|
111
|
+
await this.execute(claim);
|
|
112
|
+
return claim.runId;
|
|
113
|
+
}
|
|
114
|
+
async start() {
|
|
115
|
+
this.stopped = false;
|
|
116
|
+
const interval = this.options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
117
|
+
const concurrency = Math.max(1, Math.floor(this.options.concurrency ?? 1));
|
|
118
|
+
const inFlight = new Set();
|
|
119
|
+
const sleep = () => new Promise((resolve) => setTimeout(resolve, interval));
|
|
120
|
+
while (!this.stopped) {
|
|
121
|
+
if (inFlight.size >= concurrency) {
|
|
122
|
+
// At capacity: wait for any execution to settle before claiming
|
|
123
|
+
// again. The executions never reject (errors are caught below).
|
|
124
|
+
await Promise.race(inFlight);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
const identity = await this.ensureEnrolled();
|
|
129
|
+
const claim = await this.client.claim({
|
|
130
|
+
runnerToken: identity.runnerToken,
|
|
131
|
+
pool: identity.pool
|
|
132
|
+
});
|
|
133
|
+
if ("empty" in claim) {
|
|
134
|
+
await sleep();
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const task = this.execute(claim)
|
|
138
|
+
.catch((error) => {
|
|
139
|
+
console.error(`runner error: ${error instanceof Error ? error.message : String(error)}`);
|
|
140
|
+
})
|
|
141
|
+
.finally(() => {
|
|
142
|
+
inFlight.delete(task);
|
|
143
|
+
});
|
|
144
|
+
inFlight.add(task);
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
console.error(`runner error: ${error instanceof Error ? error.message : String(error)}`);
|
|
148
|
+
await sleep();
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// stop() interrupts claiming, not execution: drain what was claimed so
|
|
152
|
+
// every accepted run still ends in a posted receipt.
|
|
153
|
+
await Promise.all(inFlight);
|
|
154
|
+
}
|
|
155
|
+
stop() {
|
|
156
|
+
this.stopped = true;
|
|
157
|
+
}
|
|
158
|
+
async execute(claim) {
|
|
159
|
+
const { contract, claimToken, runId } = claim;
|
|
160
|
+
const genesis = contractHash(contract);
|
|
161
|
+
const chain = [...claim.events];
|
|
162
|
+
let flushedThrough = chain.length;
|
|
163
|
+
const emitLocal = (event) => appendEvent(chain, event, genesis);
|
|
164
|
+
const flush = async () => {
|
|
165
|
+
const pending = chain.slice(flushedThrough);
|
|
166
|
+
if (pending.length === 0)
|
|
167
|
+
return;
|
|
168
|
+
await this.client.postEvents(runId, claimToken, pending);
|
|
169
|
+
flushedThrough = chain.length;
|
|
170
|
+
};
|
|
171
|
+
const sessionDir = mkdtempSync(join(tmpdir(), "warrant-session-"));
|
|
172
|
+
try {
|
|
173
|
+
const repoDir = await materializeWorkspace(sessionDir, contract.workspace, (hash) => this.client.getBlob(hash));
|
|
174
|
+
const manifestHash = hashCanonical(contract.workspace);
|
|
175
|
+
emitLocal({ type: "workspace.materialized", manifestHash });
|
|
176
|
+
await flush();
|
|
177
|
+
const session = await runSession({
|
|
178
|
+
contract,
|
|
179
|
+
repoDir,
|
|
180
|
+
secrets: claim.secrets,
|
|
181
|
+
mockScriptPath: this.options.mockScriptPath ?? DEFAULT_MOCK_SCRIPT,
|
|
182
|
+
emit: (event) => void emitLocal(event),
|
|
183
|
+
backends: this.options.backends ?? []
|
|
184
|
+
});
|
|
185
|
+
const artifactHashes = [];
|
|
186
|
+
let diffHash = "";
|
|
187
|
+
const diff = redactSecrets(session.output.diff, claim.secrets);
|
|
188
|
+
const log = redactSecrets(session.log, claim.secrets);
|
|
189
|
+
if (diff.length > 0) {
|
|
190
|
+
diffHash = await this.client.putBlob(diff, claimToken);
|
|
191
|
+
emitLocal({ type: "artifact.created", kind: "diff", hash: diffHash });
|
|
192
|
+
emitLocal({
|
|
193
|
+
type: "boundary.crossed",
|
|
194
|
+
direction: "out",
|
|
195
|
+
contentHash: diffHash,
|
|
196
|
+
dataClass: "code-diff"
|
|
197
|
+
});
|
|
198
|
+
artifactHashes.push(diffHash);
|
|
199
|
+
}
|
|
200
|
+
if (log.length > 0) {
|
|
201
|
+
const logHash = await this.client.putBlob(log, claimToken);
|
|
202
|
+
emitLocal({ type: "artifact.created", kind: "log", hash: logHash });
|
|
203
|
+
emitLocal({
|
|
204
|
+
type: "boundary.crossed",
|
|
205
|
+
direction: "out",
|
|
206
|
+
contentHash: logHash,
|
|
207
|
+
dataClass: "session-log"
|
|
208
|
+
});
|
|
209
|
+
artifactHashes.push(logHash);
|
|
210
|
+
}
|
|
211
|
+
if (session.exitCode === 0) {
|
|
212
|
+
emitLocal({ type: "run.completed" });
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
emitLocal({
|
|
216
|
+
type: "run.failed",
|
|
217
|
+
failure: "session_failed",
|
|
218
|
+
message: `agent exited with code ${session.exitCode}`
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
await flush();
|
|
222
|
+
const receipt = this.buildReceipt({
|
|
223
|
+
runId,
|
|
224
|
+
contract,
|
|
225
|
+
chain,
|
|
226
|
+
genesis,
|
|
227
|
+
manifestHash,
|
|
228
|
+
diffHash,
|
|
229
|
+
artifactHashes,
|
|
230
|
+
isolation: session.isolation,
|
|
231
|
+
status: session.exitCode === 0 ? "completed" : "failed"
|
|
232
|
+
});
|
|
233
|
+
await this.client.complete(runId, claimToken, receipt);
|
|
234
|
+
}
|
|
235
|
+
finally {
|
|
236
|
+
rmSync(sessionDir, { recursive: true, force: true });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
buildReceipt(input) {
|
|
240
|
+
const identity = this.identity;
|
|
241
|
+
if (!identity)
|
|
242
|
+
throw new Error("runner identity missing");
|
|
243
|
+
const { chain } = input;
|
|
244
|
+
const head = chain[chain.length - 1];
|
|
245
|
+
if (!head)
|
|
246
|
+
throw new Error("event chain is empty");
|
|
247
|
+
const secretsReleased = chain
|
|
248
|
+
.filter((e) => e.event.type === "secret.released")
|
|
249
|
+
.map((e) => e.event.type === "secret.released"
|
|
250
|
+
? { name: e.event.name, scope: e.event.scope, ts: e.ts }
|
|
251
|
+
: { name: "", scope: "", ts: "" });
|
|
252
|
+
// Dedupe distinct (host, decision) pairs without string-packing the key,
|
|
253
|
+
// so hosts containing ":" (IPv6 literals) are handled correctly.
|
|
254
|
+
const networkSeen = new Map();
|
|
255
|
+
for (const entry of chain) {
|
|
256
|
+
if (entry.event.type === "network.connected") {
|
|
257
|
+
const decisions = networkSeen.get(entry.event.host) ?? new Set();
|
|
258
|
+
decisions.add(entry.event.decision);
|
|
259
|
+
networkSeen.set(entry.event.host, decisions);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
const networkAccessed = [...networkSeen.entries()].flatMap(([host, decisions]) => [...decisions].map((decision) => ({ host, decision })));
|
|
263
|
+
const boundaryDisclosures = chain
|
|
264
|
+
.filter((e) => e.event.type === "boundary.crossed")
|
|
265
|
+
.map((e) => e.event.type === "boundary.crossed"
|
|
266
|
+
? {
|
|
267
|
+
direction: e.event.direction,
|
|
268
|
+
contentHash: e.event.contentHash,
|
|
269
|
+
dataClass: e.event.dataClass
|
|
270
|
+
}
|
|
271
|
+
: { direction: "out", contentHash: "", dataClass: "" });
|
|
272
|
+
const runnerIdentity = {
|
|
273
|
+
runnerId: identity.runnerId,
|
|
274
|
+
keyId: keyIdFromPublicPem(this.publicKeyPem),
|
|
275
|
+
pool: identity.pool,
|
|
276
|
+
// Honest labeling: a software runner offers no hardware attestation,
|
|
277
|
+
// so the tier is "mock" until a TEE-backed runner reports otherwise.
|
|
278
|
+
attestationTier: RUNNER_ATTESTATION_TIER,
|
|
279
|
+
isolation: input.isolation
|
|
280
|
+
};
|
|
281
|
+
const first = chain[0];
|
|
282
|
+
const unsigned = {
|
|
283
|
+
version: "warrant.receipt.v1",
|
|
284
|
+
runId: input.runId,
|
|
285
|
+
contractHash: input.genesis,
|
|
286
|
+
runner: runnerIdentity,
|
|
287
|
+
startedAt: first ? first.ts : head.ts,
|
|
288
|
+
endedAt: head.ts,
|
|
289
|
+
status: input.status,
|
|
290
|
+
eventsHead: head.hash,
|
|
291
|
+
eventCount: chain.length,
|
|
292
|
+
workspaceIn: {
|
|
293
|
+
baseRef: input.contract.workspace.baseRef,
|
|
294
|
+
manifestHash: input.manifestHash
|
|
295
|
+
},
|
|
296
|
+
workspaceOut: {
|
|
297
|
+
diffHash: input.diffHash,
|
|
298
|
+
artifactHashes: input.artifactHashes
|
|
299
|
+
},
|
|
300
|
+
secretsReleased,
|
|
301
|
+
networkAccessed,
|
|
302
|
+
modelsUsed: [],
|
|
303
|
+
boundaryDisclosures,
|
|
304
|
+
signatures: []
|
|
305
|
+
};
|
|
306
|
+
return signReceipt(unsigned, this.privateKeyPem, this.publicKeyPem, "runner");
|
|
307
|
+
}
|
|
308
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { RunContract, RunEvent, SessionIsolation } from "@fusionkit/protocol";
|
|
2
|
+
import type { WorkspaceOutput } from "@fusionkit/workspace";
|
|
3
|
+
import type { SessionBackend } from "./backend.js";
|
|
4
|
+
export type SessionResult = {
|
|
5
|
+
exitCode: number;
|
|
6
|
+
log: Buffer;
|
|
7
|
+
output: WorkspaceOutput;
|
|
8
|
+
isolation: SessionIsolation;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Run the agent harness inside a governed session. The runner owns the
|
|
12
|
+
* boundary plumbing (workspace materialization, output capture, events);
|
|
13
|
+
* the selected backend owns only how the harness is isolated. An event is
|
|
14
|
+
* emitted for every observable boundary action regardless of backend.
|
|
15
|
+
*/
|
|
16
|
+
export declare function runSession(input: {
|
|
17
|
+
contract: RunContract;
|
|
18
|
+
repoDir: string;
|
|
19
|
+
secrets: {
|
|
20
|
+
name: string;
|
|
21
|
+
value: string;
|
|
22
|
+
}[];
|
|
23
|
+
mockScriptPath: string;
|
|
24
|
+
emit: (event: RunEvent) => void;
|
|
25
|
+
backends: SessionBackend[];
|
|
26
|
+
}): Promise<SessionResult>;
|
|
27
|
+
/** A requested isolation tier or agent is not available on this runner. */
|
|
28
|
+
export declare class CapabilityMismatchError extends Error {
|
|
29
|
+
readonly code: "capability_mismatch";
|
|
30
|
+
constructor(message: string);
|
|
31
|
+
}
|
package/dist/session.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { collectOutput } from "@fusionkit/workspace";
|
|
2
|
+
import { prepareExecution } from "./execution.js";
|
|
3
|
+
import { ProcessSessionBackend } from "./process-backend.js";
|
|
4
|
+
/**
|
|
5
|
+
* Run the agent harness inside a governed session. The runner owns the
|
|
6
|
+
* boundary plumbing (workspace materialization, output capture, events);
|
|
7
|
+
* the selected backend owns only how the harness is isolated. An event is
|
|
8
|
+
* emitted for every observable boundary action regardless of backend.
|
|
9
|
+
*/
|
|
10
|
+
export async function runSession(input) {
|
|
11
|
+
const { contract, repoDir, secrets, mockScriptPath, emit, backends } = input;
|
|
12
|
+
const requested = contract.isolation ?? "process";
|
|
13
|
+
const matching = backends.filter((b) => b.isolation === requested);
|
|
14
|
+
if (matching.length > 1) {
|
|
15
|
+
// Fail fast instead of silently letting the first registration shadow
|
|
16
|
+
// the rest: composite backends (e.g. the AI SDK harness driver, which
|
|
17
|
+
// embeds the plain vercel-sandbox backend as its fallback) are the one
|
|
18
|
+
// sanctioned way to combine behaviors within a tier.
|
|
19
|
+
throw new CapabilityMismatchError(`runner has ${matching.length} backends for isolation "${requested}"; register exactly one per tier`);
|
|
20
|
+
}
|
|
21
|
+
const backend = matching[0] ?? (requested === "process" ? new ProcessSessionBackend() : undefined);
|
|
22
|
+
if (!backend) {
|
|
23
|
+
throw new CapabilityMismatchError(`runner has no backend for isolation "${requested}"`);
|
|
24
|
+
}
|
|
25
|
+
const execution = prepareExecution({ contract, mockScriptPath });
|
|
26
|
+
if (backend.supports && !backend.supports(execution.kind, contract)) {
|
|
27
|
+
throw new CapabilityMismatchError(`backend "${requested}" cannot run ${execution.kind} execution for agent "${contract.agent.kind}"`);
|
|
28
|
+
}
|
|
29
|
+
const result = await backend.execute({
|
|
30
|
+
contract,
|
|
31
|
+
repoDir,
|
|
32
|
+
secrets,
|
|
33
|
+
execution,
|
|
34
|
+
emit
|
|
35
|
+
});
|
|
36
|
+
const output = collectOutput(repoDir, contract.workspace.baseRef);
|
|
37
|
+
for (const file of output.changedFiles) {
|
|
38
|
+
emit({ type: "file.changed", path: file.path, contentHash: file.contentHash });
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
exitCode: result.exitCode,
|
|
42
|
+
log: result.log,
|
|
43
|
+
output,
|
|
44
|
+
isolation: backend.isolation
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** A requested isolation tier or agent is not available on this runner. */
|
|
48
|
+
export class CapabilityMismatchError extends Error {
|
|
49
|
+
code = "capability_mismatch";
|
|
50
|
+
constructor(message) {
|
|
51
|
+
super(message);
|
|
52
|
+
this.name = "CapabilityMismatchError";
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { executionSpecFor, executionHash, prepareExecution, requireShellExecution } from "../execution.js";
|
|
4
|
+
import { ProcessSessionBackend } from "../process-backend.js";
|
|
5
|
+
function contractFixture(overrides = {}) {
|
|
6
|
+
const contract = {
|
|
7
|
+
version: "warrant.contract.v1",
|
|
8
|
+
runId: "run_test",
|
|
9
|
+
issuedAt: "2026-06-11T00:00:00.000Z",
|
|
10
|
+
issuer: { keyId: "ed25519:0000000000000000", role: "plane" },
|
|
11
|
+
requestedBy: { kind: "human", id: "alice" },
|
|
12
|
+
agent: { kind: "command" },
|
|
13
|
+
task: { prompt: "echo hi" },
|
|
14
|
+
runner: { pool: "default" },
|
|
15
|
+
workspace: {
|
|
16
|
+
version: "warrant.manifest.v1",
|
|
17
|
+
baseRef: "abc",
|
|
18
|
+
bundleHash: "1".repeat(64),
|
|
19
|
+
untrackedFiles: [],
|
|
20
|
+
deniedPatterns: [],
|
|
21
|
+
deniedPaths: []
|
|
22
|
+
},
|
|
23
|
+
policyHash: "2".repeat(64),
|
|
24
|
+
secrets: [],
|
|
25
|
+
network: { defaultDeny: true, allowHosts: [] },
|
|
26
|
+
budget: {},
|
|
27
|
+
disclosure: "minimal-context",
|
|
28
|
+
expiresAt: "2026-06-11T01:00:00.000Z",
|
|
29
|
+
signatures: [],
|
|
30
|
+
...overrides
|
|
31
|
+
};
|
|
32
|
+
return contract;
|
|
33
|
+
}
|
|
34
|
+
test("command contracts default to explicit shell execution", () => {
|
|
35
|
+
const contract = contractFixture();
|
|
36
|
+
assert.deepEqual(executionSpecFor(contract), {
|
|
37
|
+
kind: "shell",
|
|
38
|
+
script: "echo hi"
|
|
39
|
+
});
|
|
40
|
+
const execution = prepareExecution({
|
|
41
|
+
contract,
|
|
42
|
+
mockScriptPath: "/tmp/mock-agent.js"
|
|
43
|
+
});
|
|
44
|
+
const shell = requireShellExecution(execution);
|
|
45
|
+
assert.equal(shell.script, "echo hi");
|
|
46
|
+
assert.equal(shell.shell, "sh");
|
|
47
|
+
assert.equal(shell.timeoutMs, 10 * 60 * 1000);
|
|
48
|
+
});
|
|
49
|
+
test("agent executions prepare vendor argv without shell wrapping", () => {
|
|
50
|
+
const contract = contractFixture({
|
|
51
|
+
agent: { kind: "mock" },
|
|
52
|
+
task: { prompt: "do work" },
|
|
53
|
+
execution: { kind: "agent", agent: { kind: "mock" }, prompt: "do work" }
|
|
54
|
+
});
|
|
55
|
+
const execution = prepareExecution({
|
|
56
|
+
contract,
|
|
57
|
+
mockScriptPath: "/tmp/mock-agent.js"
|
|
58
|
+
});
|
|
59
|
+
assert.equal(execution.kind, "argv");
|
|
60
|
+
if (execution.kind === "argv") {
|
|
61
|
+
assert.equal(execution.cmd, process.execPath);
|
|
62
|
+
assert.deepEqual(execution.args, ["/tmp/mock-agent.js", "do work"]);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
test("pi prepares a non-spawnable placeholder argv (harness-only)", () => {
|
|
66
|
+
// The harness backend needs a prepared execution (env, timeout, hash) just
|
|
67
|
+
// like claude-code, and ignores the argv. Preparation must therefore
|
|
68
|
+
// succeed; the placeholder argv exists only to hash. The process backend
|
|
69
|
+
// (tested separately) is what refuses to spawn pi.
|
|
70
|
+
const contract = contractFixture({
|
|
71
|
+
agent: { kind: "pi" },
|
|
72
|
+
task: { prompt: "fix the bug" },
|
|
73
|
+
execution: { kind: "agent", agent: { kind: "pi" }, prompt: "fix the bug" }
|
|
74
|
+
});
|
|
75
|
+
const execution = prepareExecution({ contract, mockScriptPath: "/tmp/mock-agent.js" });
|
|
76
|
+
assert.equal(execution.kind, "argv");
|
|
77
|
+
assert.match(executionHash(execution), /^[0-9a-f]{64}$/);
|
|
78
|
+
});
|
|
79
|
+
test("the process backend refuses to spawn pi", () => {
|
|
80
|
+
const backend = new ProcessSessionBackend();
|
|
81
|
+
const piContract = contractFixture({
|
|
82
|
+
agent: { kind: "pi" },
|
|
83
|
+
execution: { kind: "agent", agent: { kind: "pi" }, prompt: "fix the bug" }
|
|
84
|
+
});
|
|
85
|
+
const commandContract = contractFixture();
|
|
86
|
+
assert.equal(backend.supports?.("argv", piContract), false);
|
|
87
|
+
assert.equal(backend.supports?.("shell", commandContract), true);
|
|
88
|
+
});
|
|
89
|
+
test("executionHash records the prepared execution shape", () => {
|
|
90
|
+
const shell = prepareExecution({
|
|
91
|
+
contract: contractFixture({ execution: { kind: "shell", script: "echo hi" } }),
|
|
92
|
+
mockScriptPath: "/tmp/mock-agent.js"
|
|
93
|
+
});
|
|
94
|
+
const argv = prepareExecution({
|
|
95
|
+
contract: contractFixture({
|
|
96
|
+
execution: { kind: "argv", command: "echo", args: ["hi"] }
|
|
97
|
+
}),
|
|
98
|
+
mockScriptPath: "/tmp/mock-agent.js"
|
|
99
|
+
});
|
|
100
|
+
assert.notEqual(executionHash(shell), executionHash(argv));
|
|
101
|
+
assert.match(executionHash(shell), /^[0-9a-f]{64}$/);
|
|
102
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@fusionkit/runner",
|
|
3
|
+
"private": false,
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/velum-labs/handoffkit.git",
|
|
8
|
+
"directory": "packages/runner"
|
|
9
|
+
},
|
|
10
|
+
"description": "Warrant runner: outbound-only execution of vendor agent harnesses inside governed sessions with deny-by-default egress.",
|
|
11
|
+
"license": "UNLICENSED",
|
|
12
|
+
"type": "module",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"default": "./dist/index.js"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist"
|
|
21
|
+
],
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"registry": "https://registry.npmjs.org",
|
|
24
|
+
"access": "public",
|
|
25
|
+
"provenance": true
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@fusionkit/protocol": "0.1.0",
|
|
29
|
+
"@fusionkit/workspace": "0.1.0",
|
|
30
|
+
"@fusionkit/sdk": "0.1.0"
|
|
31
|
+
}
|
|
32
|
+
}
|