@fusionkit/adapter-compute 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.
@@ -0,0 +1,20 @@
1
+ /**
2
+ * @fusionkit/adapter-compute — a ComputeSDK-shaped compute surface over
3
+ * governed runner sessions.
4
+ *
5
+ * The shape matches what ComputeSDK users already write —
6
+ * `compute.sandbox.create()`, `sandbox.runCommand(...)`,
7
+ * `sandbox.filesystem.writeFile(...)` — but the substrate is Warrant:
8
+ * every command is a signed run contract executed in a governed session
9
+ * with an offline-verifiable receipt. Honest semantics, stated plainly:
10
+ *
11
+ * - Each command runs in a *fresh* session materialized from the current
12
+ * workspace state; continuity flows through the workspace's git history,
13
+ * not through a long-lived remote process.
14
+ * - The adapter stages inputs as commits and pulls outputs back after each
15
+ * command, so sequential commands compose.
16
+ * - `filesystem.writeFile` stages input files locally for the next command;
17
+ * it is not a remote mutation (nothing exists remotely between commands).
18
+ */
19
+ export { governedCompute, GovernedSandbox, withCompute } from "./sandbox.js";
20
+ export type { CommandResult, GovernedCompute, GovernedComputeConfig, SandboxRunRecord } from "./sandbox.js";
package/dist/index.js ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * @fusionkit/adapter-compute — a ComputeSDK-shaped compute surface over
3
+ * governed runner sessions.
4
+ *
5
+ * The shape matches what ComputeSDK users already write —
6
+ * `compute.sandbox.create()`, `sandbox.runCommand(...)`,
7
+ * `sandbox.filesystem.writeFile(...)` — but the substrate is Warrant:
8
+ * every command is a signed run contract executed in a governed session
9
+ * with an offline-verifiable receipt. Honest semantics, stated plainly:
10
+ *
11
+ * - Each command runs in a *fresh* session materialized from the current
12
+ * workspace state; continuity flows through the workspace's git history,
13
+ * not through a long-lived remote process.
14
+ * - The adapter stages inputs as commits and pulls outputs back after each
15
+ * command, so sequential commands compose.
16
+ * - `filesystem.writeFile` stages input files locally for the next command;
17
+ * it is not a remote mutation (nothing exists remotely between commands).
18
+ */
19
+ export { governedCompute, GovernedSandbox, withCompute } from "./sandbox.js";
@@ -0,0 +1,89 @@
1
+ import { Handoff } from "@fusionkit/handoff";
2
+ import type { CommandHarnessConfig, GovernedRunRecord } from "@fusionkit/handoff";
3
+ import type { RunStatus, SessionIsolation } from "@fusionkit/protocol";
4
+ /**
5
+ * The shared command-harness configuration, with one compute-specific
6
+ * default applied at sandbox creation: untracked-file capture allows
7
+ * everything ("**"), because a sandbox is expected to see the files staged
8
+ * into it; secret-pattern denials still apply and are recorded in the
9
+ * manifest.
10
+ */
11
+ export type GovernedComputeConfig = CommandHarnessConfig;
12
+ export type CommandResult = {
13
+ runId: string;
14
+ status: RunStatus;
15
+ exitCode: number | undefined;
16
+ output: string;
17
+ };
18
+ export type SandboxRunRecord = GovernedRunRecord & {
19
+ sandboxId: string;
20
+ };
21
+ export type GovernedCompute = {
22
+ sandbox: {
23
+ create(): Promise<GovernedSandbox>;
24
+ };
25
+ };
26
+ /** Wiring for a sandbox: a continuation context plus its execution pool. */
27
+ export type SandboxBinding = {
28
+ context: Handoff;
29
+ pool: string;
30
+ timeoutMs?: number;
31
+ session?: SessionIsolation;
32
+ committer?: {
33
+ name: string;
34
+ email: string;
35
+ };
36
+ };
37
+ export declare class GovernedSandbox {
38
+ readonly sandboxId: string;
39
+ readonly filesystem: {
40
+ writeFile(path: string, content: string): Promise<void>;
41
+ readFile(path: string): Promise<string>;
42
+ exists(path: string): Promise<boolean>;
43
+ };
44
+ private readonly context;
45
+ private readonly pool;
46
+ private readonly timeoutMs;
47
+ private readonly session?;
48
+ private readonly committer;
49
+ private readonly workspaceDir;
50
+ private readonly records;
51
+ private destroyed;
52
+ constructor(binding: SandboxBinding);
53
+ private assertLive;
54
+ private resolveInside;
55
+ /**
56
+ * Commit staged inputs (and prior outputs) so the next capture is clean at
57
+ * a fresh base ref; that lets the post-run pull apply on the fast path.
58
+ */
59
+ private commitIfDirty;
60
+ /** Execute one command in a fresh governed session and pull its output. */
61
+ runCommand(command: string): Promise<CommandResult>;
62
+ /** One record per executed command, each backed by a signed receipt. */
63
+ runs(): SandboxRunRecord[];
64
+ /** The underlying continuation context (trace, lastEnvelope, …). */
65
+ get handoffContext(): Handoff;
66
+ /**
67
+ * Stop accepting operations. Sessions are already ephemeral; what remains
68
+ * is the workspace state and the receipts, which is the point.
69
+ */
70
+ destroy(): Promise<void>;
71
+ }
72
+ /** Create a ComputeSDK-shaped compute surface over governed sessions. */
73
+ export declare function governedCompute(config: GovernedComputeConfig): GovernedCompute;
74
+ /**
75
+ * Attach the compute surface to an existing continuation context — the
76
+ * golden-shape composition. Sandboxes created here share the context's
77
+ * workspace, policy, and trace, so tool calls, continuations, and sandbox
78
+ * commands all land in one explainable history:
79
+ *
80
+ * const h = withCompute(handoff({ workspace, plane, policy }), { pool });
81
+ * const sandbox = await h.compute.sandbox.create();
82
+ */
83
+ export declare function withCompute<H extends Handoff>(h: H, options: {
84
+ pool: string;
85
+ timeoutMs?: number;
86
+ session?: SessionIsolation;
87
+ }): H & {
88
+ compute: GovernedCompute;
89
+ };
@@ -0,0 +1,163 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { dirname, resolve } from "node:path";
4
+ import { createCommandContext, executeGovernedCommand, targets, toGovernedRunRecord } from "@fusionkit/handoff";
5
+ import { gitText, resolveInsideWorkspace } from "@fusionkit/workspace";
6
+ function git(cwd, args) {
7
+ return gitText(cwd, args);
8
+ }
9
+ /** Default per-command wait ceiling for sandbox commands. */
10
+ const DEFAULT_SANDBOX_TIMEOUT_MS = 5 * 60 * 1000;
11
+ /** Identity used for the sandbox's staging commits. */
12
+ const SANDBOX_COMMITTER = {
13
+ name: "warrant-sandbox",
14
+ email: "sandbox@warrant.local"
15
+ };
16
+ export class GovernedSandbox {
17
+ sandboxId;
18
+ filesystem;
19
+ context;
20
+ pool;
21
+ timeoutMs;
22
+ session;
23
+ committer;
24
+ workspaceDir;
25
+ records = [];
26
+ destroyed = false;
27
+ constructor(binding) {
28
+ this.sandboxId = `sbx_${randomUUID()}`;
29
+ this.context = binding.context;
30
+ this.pool = binding.pool;
31
+ this.timeoutMs = binding.timeoutMs ?? DEFAULT_SANDBOX_TIMEOUT_MS;
32
+ this.session = binding.session;
33
+ this.committer = binding.committer ?? SANDBOX_COMMITTER;
34
+ this.workspaceDir = binding.context.workspacePath;
35
+ this.filesystem = {
36
+ writeFile: async (path, content) => {
37
+ this.assertLive();
38
+ const target = this.resolveInside(path);
39
+ mkdirSync(dirname(target), { recursive: true });
40
+ writeFileSync(target, content);
41
+ },
42
+ readFile: async (path) => {
43
+ this.assertLive();
44
+ return readFileSync(this.resolveInside(path), "utf8");
45
+ },
46
+ exists: async (path) => {
47
+ this.assertLive();
48
+ return existsSync(this.resolveInside(path));
49
+ }
50
+ };
51
+ }
52
+ assertLive() {
53
+ if (this.destroyed) {
54
+ throw new Error(`sandbox ${this.sandboxId} has been destroyed`);
55
+ }
56
+ }
57
+ resolveInside(path) {
58
+ return resolveInsideWorkspace(this.workspaceDir, path);
59
+ }
60
+ /**
61
+ * Commit staged inputs (and prior outputs) so the next capture is clean at
62
+ * a fresh base ref; that lets the post-run pull apply on the fast path.
63
+ */
64
+ commitIfDirty(message) {
65
+ git(this.workspaceDir, ["add", "-A"]);
66
+ const dirty = git(this.workspaceDir, ["status", "--porcelain"]).trim();
67
+ if (dirty.length === 0)
68
+ return;
69
+ git(this.workspaceDir, [
70
+ "-c",
71
+ `user.name=${this.committer.name}`,
72
+ "-c",
73
+ `user.email=${this.committer.email}`,
74
+ "commit",
75
+ "--quiet",
76
+ "-m",
77
+ message
78
+ ]);
79
+ }
80
+ /** Execute one command in a fresh governed session and pull its output. */
81
+ async runCommand(command) {
82
+ this.assertLive();
83
+ this.commitIfDirty(`sandbox ${this.sandboxId}: stage state`);
84
+ const result = await executeGovernedCommand(this.context, {
85
+ command,
86
+ target: targets.pool(this.pool),
87
+ reason: `sandbox ${this.sandboxId} command`,
88
+ timeoutMs: this.timeoutMs,
89
+ pullResults: true,
90
+ ...(this.session ? { session: this.session } : {})
91
+ });
92
+ this.records.push({
93
+ sandboxId: this.sandboxId,
94
+ ...toGovernedRunRecord(command, result)
95
+ });
96
+ return {
97
+ runId: result.run.runId,
98
+ status: result.status,
99
+ exitCode: result.exitCode,
100
+ output: result.output
101
+ };
102
+ }
103
+ /** One record per executed command, each backed by a signed receipt. */
104
+ runs() {
105
+ return [...this.records];
106
+ }
107
+ /** The underlying continuation context (trace, lastEnvelope, …). */
108
+ get handoffContext() {
109
+ return this.context;
110
+ }
111
+ /**
112
+ * Stop accepting operations. Sessions are already ephemeral; what remains
113
+ * is the workspace state and the receipts, which is the point.
114
+ */
115
+ destroy() {
116
+ this.destroyed = true;
117
+ return Promise.resolve();
118
+ }
119
+ }
120
+ /** Create a ComputeSDK-shaped compute surface over governed sessions. */
121
+ export function governedCompute(config) {
122
+ return {
123
+ sandbox: {
124
+ create: () => {
125
+ const context = createCommandContext({
126
+ ...config,
127
+ workspace: resolve(config.workspace),
128
+ allowUntracked: config.allowUntracked ?? ["**"]
129
+ });
130
+ return Promise.resolve(new GovernedSandbox({
131
+ context,
132
+ pool: config.pool,
133
+ ...(config.timeoutMs !== undefined ? { timeoutMs: config.timeoutMs } : {}),
134
+ ...(config.session ? { session: config.session } : {})
135
+ }));
136
+ }
137
+ }
138
+ };
139
+ }
140
+ /**
141
+ * Attach the compute surface to an existing continuation context — the
142
+ * golden-shape composition. Sandboxes created here share the context's
143
+ * workspace, policy, and trace, so tool calls, continuations, and sandbox
144
+ * commands all land in one explainable history:
145
+ *
146
+ * const h = withCompute(handoff({ workspace, plane, policy }), { pool });
147
+ * const sandbox = await h.compute.sandbox.create();
148
+ */
149
+ export function withCompute(h, options) {
150
+ const compute = {
151
+ sandbox: {
152
+ create: () => Promise.resolve(new GovernedSandbox({
153
+ context: h,
154
+ pool: options.pool,
155
+ ...(options.timeoutMs !== undefined
156
+ ? { timeoutMs: options.timeoutMs }
157
+ : {}),
158
+ ...(options.session ? { session: options.session } : {})
159
+ }))
160
+ }
161
+ };
162
+ return Object.assign(h, { compute });
163
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,180 @@
1
+ import assert from "node:assert/strict";
2
+ import { spawn } from "node:child_process";
3
+ import { rmSync } from "node:fs";
4
+ import { after, before, test } from "node:test";
5
+ import { handoff, localFirst } from "@fusionkit/handoff";
6
+ import { makeRepo, startStack } from "@fusionkit/testkit";
7
+ import { resolveInsideWorkspace } from "@fusionkit/workspace";
8
+ import { governedCompute, withCompute } from "../sandbox.js";
9
+ const POOL = "eng-prod";
10
+ const FAKE_COMMAND_HASH = "0".repeat(64);
11
+ let stack;
12
+ let repoDir;
13
+ let sandbox;
14
+ before(async () => {
15
+ stack = await startStack({
16
+ pool: POOL,
17
+ startRunner: true,
18
+ backends: [
19
+ {
20
+ isolation: "vercel-sandbox",
21
+ execute: async (input) => {
22
+ const { execution } = input;
23
+ const cwd = resolveInsideWorkspace(input.repoDir, execution.cwd);
24
+ const env = { ...process.env, ...execution.env };
25
+ const chunks = [];
26
+ let capturedBytes = 0;
27
+ let killChild = () => undefined;
28
+ const push = (chunk) => {
29
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
30
+ chunks.push(buffer);
31
+ capturedBytes += buffer.byteLength;
32
+ if (execution.logMaxBytes !== undefined &&
33
+ capturedBytes > execution.logMaxBytes) {
34
+ killChild();
35
+ }
36
+ };
37
+ const exitCode = await new Promise((resolve) => {
38
+ const child = execution.kind === "argv"
39
+ ? spawn(execution.cmd, execution.args, { cwd, env })
40
+ : spawn(execution.shell, ["-c", execution.script], { cwd, env });
41
+ killChild = () => {
42
+ child.kill("SIGKILL");
43
+ };
44
+ const timer = setTimeout(() => {
45
+ child.kill("SIGKILL");
46
+ }, execution.timeoutMs);
47
+ child.stdout.on("data", push);
48
+ child.stderr.on("data", push);
49
+ child.on("error", (error) => {
50
+ chunks.push(Buffer.from(`spawn error: ${error.message}\n`, "utf8"));
51
+ clearTimeout(timer);
52
+ resolve(127);
53
+ });
54
+ child.on("close", (code) => {
55
+ clearTimeout(timer);
56
+ resolve(code ?? 1);
57
+ });
58
+ });
59
+ input.emit({
60
+ type: "command.executed",
61
+ argvHash: FAKE_COMMAND_HASH,
62
+ exitCode
63
+ });
64
+ return { exitCode, log: Buffer.concat(chunks) };
65
+ }
66
+ }
67
+ ],
68
+ policy: (policy) => {
69
+ policy.agents.allow = ["command"];
70
+ }
71
+ });
72
+ repoDir = makeRepo({ files: { "README.md": "# sandbox fixture\n" } });
73
+ const compute = governedCompute({
74
+ workspace: repoDir,
75
+ plane: { url: stack.planeUrl, adminToken: stack.adminToken },
76
+ pool: POOL,
77
+ actor: { kind: "human", id: "sandbox-user" }
78
+ });
79
+ sandbox = await compute.sandbox.create();
80
+ });
81
+ after(async () => {
82
+ await stack.stop();
83
+ rmSync(repoDir, { recursive: true, force: true });
84
+ });
85
+ test("staged files are visible to commands; outputs persist across commands", async () => {
86
+ await sandbox.filesystem.writeFile("task.md", "build the report\nwith two lines\n");
87
+ const first = await sandbox.runCommand("cat task.md | wc -l | tr -d ' ' > lines.txt && cat lines.txt");
88
+ assert.equal(first.status, "completed");
89
+ assert.equal(first.exitCode, 0);
90
+ assert.equal(first.output.trim(), "2");
91
+ // Sequential composition: the second command sees the first one's output.
92
+ const second = await sandbox.runCommand("cat lines.txt && echo done >> log.txt");
93
+ assert.equal(second.status, "completed");
94
+ assert.equal(second.output.trim(), "2");
95
+ assert.equal(await sandbox.filesystem.readFile("lines.txt"), "2\n");
96
+ assert.equal(await sandbox.filesystem.exists("log.txt"), true);
97
+ const runs = sandbox.runs();
98
+ assert.equal(runs.length, 2);
99
+ for (const run of runs) {
100
+ assert.equal(run.receiptVerified, true, "every command carries a verified receipt");
101
+ assert.match(run.contractHash, /^[0-9a-f]{64}$/);
102
+ assert.equal(run.isolation, "process");
103
+ assert.equal(run.sandboxId, sandbox.sandboxId);
104
+ }
105
+ assert.notEqual(runs[0]?.runId, runs[1]?.runId);
106
+ });
107
+ test("failing commands report their exit code and keep their receipt", async () => {
108
+ const failed = await sandbox.runCommand("ls /nonexistent-path-zz");
109
+ assert.equal(failed.status, "failed");
110
+ assert.notEqual(failed.exitCode, 0);
111
+ assert.ok(failed.output.length > 0, "stderr is captured in the session log");
112
+ const last = sandbox.runs().at(-1);
113
+ assert.ok(last);
114
+ assert.equal(last.receiptVerified, true);
115
+ });
116
+ test("paths cannot escape the sandbox workspace", async () => {
117
+ await assert.rejects(() => sandbox.filesystem.writeFile("../escape.txt", "nope"));
118
+ await assert.rejects(() => sandbox.filesystem.readFile("/etc/hostname"));
119
+ });
120
+ test("withCompute attaches the compute surface to an existing context with one shared trace", async () => {
121
+ const sharedRepo = makeRepo({ files: { "README.md": "# golden compute\n" } });
122
+ try {
123
+ const h = withCompute(handoff({
124
+ workspace: sharedRepo,
125
+ plane: { url: stack.planeUrl, adminToken: stack.adminToken },
126
+ policy: localFirst({ allowPools: [POOL] })
127
+ }), { pool: POOL });
128
+ const box = await h.compute.sandbox.create();
129
+ const result = await box.runCommand("echo golden > golden.txt && cat golden.txt");
130
+ assert.equal(result.status, "completed");
131
+ assert.equal(result.output.trim(), "golden");
132
+ assert.equal(await box.filesystem.readFile("golden.txt"), "golden\n");
133
+ // The sandbox command and the context share one trace and one summary.
134
+ const types = h.trace().map((e) => e.type);
135
+ assert.ok(types.includes("envelope.created"));
136
+ assert.ok(types.includes("results.pulled"));
137
+ const summary = await h.summary();
138
+ assert.equal(summary.runs.length, 1);
139
+ assert.equal(summary.runs[0]?.status, "completed");
140
+ }
141
+ finally {
142
+ rmSync(sharedRepo, { recursive: true, force: true });
143
+ }
144
+ });
145
+ test("session config requests vercel-sandbox without changing the sandbox API", async () => {
146
+ const microvmRepo = makeRepo({ files: { "README.md": "# microvm compute\n" } });
147
+ try {
148
+ const compute = governedCompute({
149
+ workspace: microvmRepo,
150
+ plane: { url: stack.planeUrl, adminToken: stack.adminToken },
151
+ pool: POOL,
152
+ actor: { kind: "human", id: "sandbox-user" },
153
+ session: "vercel-sandbox"
154
+ });
155
+ const box = await compute.sandbox.create();
156
+ await box.filesystem.writeFile("input.txt", "microvm\n");
157
+ const result = await box.runCommand("cat input.txt > microvm.txt && cat microvm.txt");
158
+ assert.equal(result.status, "completed");
159
+ assert.equal(result.exitCode, 0);
160
+ assert.equal(result.output.trim(), "microvm");
161
+ assert.equal(await box.filesystem.readFile("microvm.txt"), "microvm\n");
162
+ assert.equal(box.handoffContext.lastEnvelope()?.isolation, "vercel-sandbox");
163
+ const runs = box.runs();
164
+ assert.equal(runs.length, 1);
165
+ assert.equal(runs[0]?.receiptVerified, true);
166
+ assert.equal(runs[0]?.isolation, "vercel-sandbox");
167
+ }
168
+ finally {
169
+ rmSync(microvmRepo, { recursive: true, force: true });
170
+ }
171
+ });
172
+ test("destroyed sandboxes refuse further operations", async () => {
173
+ await sandbox.destroy();
174
+ await assert.rejects(() => sandbox.runCommand("echo too-late"), (error) => {
175
+ assert.ok(error instanceof Error);
176
+ assert.match(error.message, /destroyed/);
177
+ return true;
178
+ });
179
+ await assert.rejects(() => sandbox.filesystem.readFile("task.md"));
180
+ });
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@fusionkit/adapter-compute",
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/adapter-compute"
9
+ },
10
+ "description": "ComputeSDK-shaped compute surface over governed runner sessions: sandbox.create(), runCommand, and filesystem, where every operation is a signed contract with a receipt.",
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/handoff": "0.1.0",
29
+ "@fusionkit/protocol": "0.1.0",
30
+ "@fusionkit/workspace": "0.1.0",
31
+ "@fusionkit/sdk": "0.1.0"
32
+ },
33
+ "devDependencies": {
34
+ "@fusionkit/testkit": "0.1.0"
35
+ }
36
+ }