@a-dray/aglib 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/LICENSE +21 -0
- package/README.md +172 -0
- package/dist/agent.d.ts +158 -0
- package/dist/agent.js +2 -0
- package/dist/agent.js.map +1 -0
- package/dist/content.d.ts +45 -0
- package/dist/content.js +11 -0
- package/dist/content.js.map +1 -0
- package/dist/harness/adapters/acp/index.d.ts +92 -0
- package/dist/harness/adapters/acp/index.js +479 -0
- package/dist/harness/adapters/acp/index.js.map +1 -0
- package/dist/harness/adapters/acp/rpc.d.ts +23 -0
- package/dist/harness/adapters/acp/rpc.js +103 -0
- package/dist/harness/adapters/acp/rpc.js.map +1 -0
- package/dist/harness/adapters/native/compaction.d.ts +16 -0
- package/dist/harness/adapters/native/compaction.js +55 -0
- package/dist/harness/adapters/native/compaction.js.map +1 -0
- package/dist/harness/adapters/native/index.d.ts +2 -0
- package/dist/harness/adapters/native/index.js +2 -0
- package/dist/harness/adapters/native/index.js.map +1 -0
- package/dist/harness/adapters/native/loop.d.ts +25 -0
- package/dist/harness/adapters/native/loop.js +153 -0
- package/dist/harness/adapters/native/loop.js.map +1 -0
- package/dist/harness/harness.d.ts +129 -0
- package/dist/harness/harness.js +2 -0
- package/dist/harness/harness.js.map +1 -0
- package/dist/harness/index.d.ts +3 -0
- package/dist/harness/index.js +2 -0
- package/dist/harness/index.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/json.d.ts +4 -0
- package/dist/json.js +2 -0
- package/dist/json.js.map +1 -0
- package/dist/model/adapters/anthropic/index.d.ts +34 -0
- package/dist/model/adapters/anthropic/index.js +271 -0
- package/dist/model/adapters/anthropic/index.js.map +1 -0
- package/dist/model/adapters/fake/index.d.ts +27 -0
- package/dist/model/adapters/fake/index.js +37 -0
- package/dist/model/adapters/fake/index.js.map +1 -0
- package/dist/model/adapters/openai-compatible/index.d.ts +29 -0
- package/dist/model/adapters/openai-compatible/index.js +226 -0
- package/dist/model/adapters/openai-compatible/index.js.map +1 -0
- package/dist/model/conformance.d.ts +210 -0
- package/dist/model/conformance.js +427 -0
- package/dist/model/conformance.js.map +1 -0
- package/dist/model/index.d.ts +2 -0
- package/dist/model/index.js +2 -0
- package/dist/model/index.js.map +1 -0
- package/dist/model/model.d.ts +85 -0
- package/dist/model/model.js +8 -0
- package/dist/model/model.js.map +1 -0
- package/dist/result.d.ts +23 -0
- package/dist/result.js +3 -0
- package/dist/result.js.map +1 -0
- package/dist/run.d.ts +10 -0
- package/dist/run.js +477 -0
- package/dist/run.js.map +1 -0
- package/dist/sandbox/adapters/docker.d.ts +22 -0
- package/dist/sandbox/adapters/docker.js +443 -0
- package/dist/sandbox/adapters/docker.js.map +1 -0
- package/dist/sandbox/adapters/local.d.ts +11 -0
- package/dist/sandbox/adapters/local.js +284 -0
- package/dist/sandbox/adapters/local.js.map +1 -0
- package/dist/sandbox/conformance.d.ts +73 -0
- package/dist/sandbox/conformance.js +309 -0
- package/dist/sandbox/conformance.js.map +1 -0
- package/dist/sandbox/index.d.ts +1 -0
- package/dist/sandbox/index.js +2 -0
- package/dist/sandbox/index.js.map +1 -0
- package/dist/sandbox/sandbox.d.ts +172 -0
- package/dist/sandbox/sandbox.js +2 -0
- package/dist/sandbox/sandbox.js.map +1 -0
- package/dist/session/entry.d.ts +196 -0
- package/dist/session/entry.js +2 -0
- package/dist/session/entry.js.map +1 -0
- package/dist/session/index.d.ts +2 -0
- package/dist/session/index.js +2 -0
- package/dist/session/index.js.map +1 -0
- package/dist/session/log.d.ts +20 -0
- package/dist/session/log.js +24 -0
- package/dist/session/log.js.map +1 -0
- package/dist/session/messages.d.ts +42 -0
- package/dist/session/messages.js +99 -0
- package/dist/session/messages.js.map +1 -0
- package/dist/store/adapters/sqlite.d.ts +20 -0
- package/dist/store/adapters/sqlite.js +365 -0
- package/dist/store/adapters/sqlite.js.map +1 -0
- package/dist/store/conformance.d.ts +66 -0
- package/dist/store/conformance.js +580 -0
- package/dist/store/conformance.js.map +1 -0
- package/dist/store/index.d.ts +1 -0
- package/dist/store/index.js +2 -0
- package/dist/store/index.js.map +1 -0
- package/dist/store/store.d.ts +244 -0
- package/dist/store/store.js +2 -0
- package/dist/store/store.js.map +1 -0
- package/dist/tools/execute.d.ts +24 -0
- package/dist/tools/execute.js +105 -0
- package/dist/tools/execute.js.map +1 -0
- package/dist/tools/index.d.ts +2 -0
- package/dist/tools/index.js +2 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/tool.d.ts +106 -0
- package/dist/tools/tool.js +34 -0
- package/dist/tools/tool.js.map +1 -0
- package/package.json +119 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
function fail(what) {
|
|
2
|
+
throw new Error(`sandbox conformance: ${what}`);
|
|
3
|
+
}
|
|
4
|
+
function holds(condition, what) {
|
|
5
|
+
if (!condition)
|
|
6
|
+
fail(what);
|
|
7
|
+
}
|
|
8
|
+
function equals(actual, expected, what) {
|
|
9
|
+
if (actual !== expected)
|
|
10
|
+
fail(`${what} — expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
|
11
|
+
}
|
|
12
|
+
function got(result, what) {
|
|
13
|
+
if (!result.ok)
|
|
14
|
+
fail(`${what} — ${result.error.code}: ${result.error.message}`);
|
|
15
|
+
return result.value;
|
|
16
|
+
}
|
|
17
|
+
function refused(result, code, what) {
|
|
18
|
+
holds(!result.ok, `${what} — it succeeded instead`);
|
|
19
|
+
equals(result.error.code, code, what);
|
|
20
|
+
}
|
|
21
|
+
export function defineSandboxConformance(subject) {
|
|
22
|
+
const cases = [];
|
|
23
|
+
const isolating = subject.isolation !== "none";
|
|
24
|
+
const open = async (extra = {}) => got(await subject.provider().create({
|
|
25
|
+
isolation: isolating ? "required" : "none",
|
|
26
|
+
network: subject.network,
|
|
27
|
+
...extra,
|
|
28
|
+
}), "create");
|
|
29
|
+
/** Every case gets its own machine and gives it back, billable or not. */
|
|
30
|
+
const define = (name, body) => {
|
|
31
|
+
cases.push({
|
|
32
|
+
name,
|
|
33
|
+
async run() {
|
|
34
|
+
const sandbox = await open();
|
|
35
|
+
try {
|
|
36
|
+
await body(sandbox);
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
await sandbox.close();
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
};
|
|
44
|
+
// ---- What was actually provisioned --------------------------------------
|
|
45
|
+
cases.push({
|
|
46
|
+
name: "isolation is requested and either enforced or refused",
|
|
47
|
+
async run() {
|
|
48
|
+
const asked = await subject.provider().create({ isolation: "required", network: subject.network });
|
|
49
|
+
if (!isolating) {
|
|
50
|
+
// The whole reason the request is a request. A provider on the host
|
|
51
|
+
// says so by failing, not by setting a field the caller may ignore.
|
|
52
|
+
refused(asked, "unsupported", "a host provider refuses isolation rather than downgrading it");
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const sandbox = got(asked, "create with isolation required");
|
|
56
|
+
try {
|
|
57
|
+
equals(sandbox.isolation, subject.isolation, "the sandbox reports what it enforces");
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
await sandbox.close();
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
define("a machine names itself and its root", async (sandbox) => {
|
|
65
|
+
holds(sandbox.id.length > 0, "a sandbox has an id, so it can be reconnected to");
|
|
66
|
+
holds(sandbox.root.startsWith("/"), "the root is an absolute path");
|
|
67
|
+
equals(sandbox.isolation, subject.isolation, "and reports the isolation it enforces, never more");
|
|
68
|
+
});
|
|
69
|
+
if (subject.refuses?.length) {
|
|
70
|
+
for (const network of subject.refuses) {
|
|
71
|
+
cases.push({
|
|
72
|
+
name: `a '${network.mode}' network this provider cannot enforce is refused before anything is provisioned`,
|
|
73
|
+
async run() {
|
|
74
|
+
const asked = await subject.provider().create({
|
|
75
|
+
isolation: isolating ? "required" : "none",
|
|
76
|
+
network,
|
|
77
|
+
});
|
|
78
|
+
if (asked.ok) {
|
|
79
|
+
await asked.value.close();
|
|
80
|
+
fail(`'${network.mode}' was accepted by a provider that does not enforce it`);
|
|
81
|
+
}
|
|
82
|
+
equals(asked.error.code, "unsupported", "the refusal says the posture is unsupported");
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// ---- Commands -----------------------------------------------------------
|
|
88
|
+
define("a command answers with its output and its exit code", async (sandbox) => {
|
|
89
|
+
const ran = got(await sandbox.exec({ command: "echo hello" }), "exec");
|
|
90
|
+
equals(ran.stdout.trim(), "hello", "stdout carries what the command wrote");
|
|
91
|
+
equals(ran.exitCode, 0, "and the exit code it left");
|
|
92
|
+
});
|
|
93
|
+
define("a command's own failure is an outcome, not a failure of the sandbox", async (sandbox) => {
|
|
94
|
+
// The distinction the whole `Result` shape turns on: the sandbox did its
|
|
95
|
+
// job perfectly and the command inside it did not.
|
|
96
|
+
const ran = got(await sandbox.exec({ command: "echo oops >&2; exit 3" }), "exec");
|
|
97
|
+
equals(ran.exitCode, 3, "the command's own code comes back");
|
|
98
|
+
holds(`${ran.stdout}${ran.stderr}`.includes("oops"), "and what it wrote is not lost");
|
|
99
|
+
});
|
|
100
|
+
if ((subject.streams ?? "separate") === "separate") {
|
|
101
|
+
define("stdout and stderr arrive apart", async (sandbox) => {
|
|
102
|
+
const ran = got(await sandbox.exec({ command: "echo out; echo err >&2" }), "exec");
|
|
103
|
+
equals(ran.stdout.trim(), "out", "stdout is what the command wrote to stdout");
|
|
104
|
+
equals(ran.stderr.trim(), "err", "and stderr is what it wrote to stderr");
|
|
105
|
+
});
|
|
106
|
+
define("a spawned process's two streams arrive apart as well", async (sandbox) => {
|
|
107
|
+
// The port hands back both pipes, so both are read here. Read together,
|
|
108
|
+
// because a process filling one while nothing drains the other stops.
|
|
109
|
+
const started = got(await sandbox.spawn({ command: ["sh", "-c", "echo out; echo err >&2"] }), "spawn");
|
|
110
|
+
const [out, errors] = await Promise.all([
|
|
111
|
+
new Response(started.stdout).text(),
|
|
112
|
+
new Response(started.stderr).text(),
|
|
113
|
+
]);
|
|
114
|
+
equals(out.trim(), "out", "stdout is the stream the process wrote to stdout");
|
|
115
|
+
equals(errors.trim(), "err", "and stderr is the one it wrote to stderr");
|
|
116
|
+
await started.exited;
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
define("the environment a command is given reaches it", async (sandbox) => {
|
|
120
|
+
const ran = got(await sandbox.exec({ command: "echo $AGLIB_PROOF", env: { AGLIB_PROOF: "carried" } }), "exec");
|
|
121
|
+
equals(ran.stdout.trim(), "carried", "the value arrives in the command's environment");
|
|
122
|
+
});
|
|
123
|
+
define("a command runs in the working directory it names", async (sandbox) => {
|
|
124
|
+
got(await sandbox.writeFile({ path: "work/marker", content: "here" }), "writeFile");
|
|
125
|
+
const ran = got(await sandbox.exec({ command: "cat marker", cwd: "work" }), "exec");
|
|
126
|
+
equals(ran.stdout.trim(), "here", "a relative path resolves against the directory it was given");
|
|
127
|
+
});
|
|
128
|
+
define("a command given an aborted signal is cancelled rather than run", async (sandbox) => {
|
|
129
|
+
const ran = await sandbox.exec({ command: "echo ran", signal: AbortSignal.abort() });
|
|
130
|
+
refused(ran, "cancelled", "an already-cancelled command reports cancelled");
|
|
131
|
+
});
|
|
132
|
+
// ---- Files --------------------------------------------------------------
|
|
133
|
+
define("a file written is the file read back, in a directory that did not exist", async (sandbox) => {
|
|
134
|
+
got(await sandbox.writeFile({ path: "notes/deep/one.md", content: "kept" }), "writeFile");
|
|
135
|
+
equals(got(await sandbox.readFile({ path: "notes/deep/one.md" }), "readFile"), "kept", "the content survives the round trip");
|
|
136
|
+
});
|
|
137
|
+
define("a file that is not there is not-found rather than empty", async (sandbox) => {
|
|
138
|
+
// An empty string is a file that exists and says nothing. The difference
|
|
139
|
+
// matters to every caller that branches on it.
|
|
140
|
+
refused(await sandbox.readFile({ path: "nothing-here.md" }), "not-found", "a missing file is not-found");
|
|
141
|
+
});
|
|
142
|
+
if (!isolating)
|
|
143
|
+
define("a symlink under the root does not lead out of it", async (sandbox) => {
|
|
144
|
+
// Only asked of a provider that has no other boundary, and that is the
|
|
145
|
+
// whole of the rule: where the root is the only thing between a model and
|
|
146
|
+
// the machine, it has to be a real boundary rather than a lexical one.
|
|
147
|
+
// Lexically `escape/hostname` never leaves the root; on disk it was
|
|
148
|
+
// whatever the link pointed at, and `readFile`/`writeFile` are the calls an
|
|
149
|
+
// application hands to a model. A provider that isolates has a second
|
|
150
|
+
// boundary doing that job, so it is not made to pay a `realpath` per call
|
|
151
|
+
// for a confinement that is tidiness there rather than safety.
|
|
152
|
+
got(await sandbox.writeFile({ path: "inside.txt", content: "mine" }), "writeFile");
|
|
153
|
+
const linked = got(await sandbox.exec({ command: "ln -s /etc escape" }), "link to /etc");
|
|
154
|
+
equals(linked.exitCode, 0, "the link was made");
|
|
155
|
+
refused(await sandbox.readFile({ path: "escape/hostname" }), "denied", "reading through a link that leaves the root is denied");
|
|
156
|
+
refused(await sandbox.writeFile({ path: "escape/aglib-should-not-exist", content: "x" }), "denied", "writing through it is denied too");
|
|
157
|
+
// And the link is not a reason to refuse what is genuinely inside.
|
|
158
|
+
equals(got(await sandbox.readFile({ path: "inside.txt" }), "readFile"), "mine", "a real path in the root still reads");
|
|
159
|
+
});
|
|
160
|
+
define("a command cancelled while it runs is cancelled, and promptly", async (sandbox) => {
|
|
161
|
+
const giving = new AbortController();
|
|
162
|
+
const began = Date.now();
|
|
163
|
+
setTimeout(() => giving.abort(), 200);
|
|
164
|
+
const ran = await sandbox.exec({ command: "sleep 5; echo done", signal: giving.signal });
|
|
165
|
+
const took = Date.now() - began;
|
|
166
|
+
refused(ran, "cancelled", "a command the caller gave up on is cancelled, not completed");
|
|
167
|
+
// The point is the promptness. A provider that waits for the command it was
|
|
168
|
+
// told to abandon has not cancelled anything, whatever it reports.
|
|
169
|
+
holds(took < 3_000, `the answer came back in ${took}ms, after the command it abandoned`);
|
|
170
|
+
});
|
|
171
|
+
define("a path outside the root is denied rather than resolved", async (sandbox) => {
|
|
172
|
+
for (const path of ["../escape.md", "/etc/passwd"]) {
|
|
173
|
+
refused(await sandbox.readFile({ path }), "denied", `reading ${path} is denied`);
|
|
174
|
+
refused(await sandbox.writeFile({ path, content: "x" }), "denied", `writing ${path} is denied`);
|
|
175
|
+
}
|
|
176
|
+
refused(await sandbox.exec({ command: "pwd", cwd: "/etc" }), "denied", "a working directory outside the root is denied");
|
|
177
|
+
// Both methods take a working directory, so both confine it — and a
|
|
178
|
+
// spawned process is the one still running after the call that started it.
|
|
179
|
+
refused(await sandbox.spawn({ command: ["pwd"], cwd: "/etc" }), "denied", "a spawned process's working directory outside the root is denied");
|
|
180
|
+
});
|
|
181
|
+
define("a name that begins with dots is a file in the root, not a path out of it", async (sandbox) => {
|
|
182
|
+
// `..hidden` starts with two dots and leaves nothing. A guard that reads
|
|
183
|
+
// the prefix rather than the segment refuses a file it should have written.
|
|
184
|
+
got(await sandbox.writeFile({ path: "..hidden", content: "kept" }), "writeFile");
|
|
185
|
+
equals(got(await sandbox.readFile({ path: "..hidden" }), "readFile"), "kept", "and it reads back");
|
|
186
|
+
});
|
|
187
|
+
define("a file operation given an aborted signal is cancelled rather than run", async (sandbox) => {
|
|
188
|
+
// The same promise `exec` makes. A caller that has already given up is told
|
|
189
|
+
// so, rather than being handed the read it no longer wants.
|
|
190
|
+
refused(await sandbox.readFile({ path: "notes/one.md", signal: AbortSignal.abort() }), "cancelled", "an already-cancelled read reports cancelled");
|
|
191
|
+
refused(await sandbox.writeFile({ path: "notes/one.md", content: "x", signal: AbortSignal.abort() }), "cancelled", "an already-cancelled write reports cancelled");
|
|
192
|
+
});
|
|
193
|
+
// ---- Processes ----------------------------------------------------------
|
|
194
|
+
define("a spawned process hands back its streams and its exit code", async (sandbox) => {
|
|
195
|
+
const started = got(await sandbox.spawn({ command: ["sh", "-c", "echo streamed; exit 0"] }), "spawn");
|
|
196
|
+
equals((await new Response(started.stdout).text()).trim(), "streamed", "stdout is a stream the caller reads");
|
|
197
|
+
equals(await started.exited, 0, "and the process reports how it ended");
|
|
198
|
+
});
|
|
199
|
+
define("a spawned process starts in the working directory it names", async (sandbox) => {
|
|
200
|
+
got(await sandbox.writeFile({ path: "work/marker", content: "here" }), "writeFile");
|
|
201
|
+
const started = got(await sandbox.spawn({ command: ["cat", "marker"], cwd: "work" }), "spawn");
|
|
202
|
+
equals((await new Response(started.stdout).text()).trim(), "here", "a relative path resolves against the directory the process was given");
|
|
203
|
+
await started.exited;
|
|
204
|
+
});
|
|
205
|
+
define("a spawned process is given its environment without publishing it", async (sandbox) => {
|
|
206
|
+
// Both methods carry an environment, and both must carry it the same way.
|
|
207
|
+
// A value on the command line is readable by anything on the host, and an
|
|
208
|
+
// agent's environment is where its credentials are.
|
|
209
|
+
const started = got(await sandbox.spawn({ command: ["sh", "-c", "echo $AGLIB_PROOF"], env: { AGLIB_PROOF: "carried" } }), "spawn");
|
|
210
|
+
equals((await new Response(started.stdout).text()).trim(), "carried", "the value reaches the process");
|
|
211
|
+
await started.exited;
|
|
212
|
+
});
|
|
213
|
+
define("a spawned process takes what is written to it", async (sandbox) => {
|
|
214
|
+
// The method that makes changing provider a change of location: an agent
|
|
215
|
+
// speaking a protocol over stdio needs a real duplex channel, not a
|
|
216
|
+
// one-shot command.
|
|
217
|
+
const started = got(await sandbox.spawn({ command: ["sh", "-c", "read line; echo \"got $line\""] }), "spawn");
|
|
218
|
+
started.write("hello\n");
|
|
219
|
+
equals((await new Response(started.stdout).text()).trim(), "got hello", "what was written reached its stdin");
|
|
220
|
+
await started.exited;
|
|
221
|
+
});
|
|
222
|
+
define("a spawned process can be killed", async (sandbox) => {
|
|
223
|
+
const started = got(await sandbox.spawn({ command: ["sh", "-c", "sleep 30"] }), "spawn");
|
|
224
|
+
started.kill();
|
|
225
|
+
await started.exited;
|
|
226
|
+
});
|
|
227
|
+
// ---- Environment and credentials ----------------------------------------
|
|
228
|
+
const substituting = (subject.secrets ?? "plain") === "substituted";
|
|
229
|
+
cases.push({
|
|
230
|
+
name: "the environment a machine was made with reaches everything started in it",
|
|
231
|
+
async run() {
|
|
232
|
+
const sandbox = await open({ env: { CONFORMANCE_ENV: "from-create" } });
|
|
233
|
+
try {
|
|
234
|
+
equals(sandbox.secrets, subject.secrets ?? "plain", "and it says what it does with a secret");
|
|
235
|
+
const ran = got(await sandbox.exec({ command: "echo $CONFORMANCE_ENV" }), "exec");
|
|
236
|
+
equals(ran.stdout.trim(), "from-create", "a command reads what the machine was made with");
|
|
237
|
+
// Both, because they are two code paths in every adapter: one collects
|
|
238
|
+
// and one hands back pipes, and an adapter has passed the environment
|
|
239
|
+
// to one and not the other.
|
|
240
|
+
const started = got(await sandbox.spawn({ command: ["sh", "-c", "echo $CONFORMANCE_ENV"] }), "spawn");
|
|
241
|
+
equals((await new Response(started.stdout).text()).trim(), "from-create", "and so does a spawned process");
|
|
242
|
+
await started.exited;
|
|
243
|
+
}
|
|
244
|
+
finally {
|
|
245
|
+
await sandbox.close();
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
cases.push({
|
|
250
|
+
name: "a call's own environment wins over the machine's",
|
|
251
|
+
async run() {
|
|
252
|
+
const sandbox = await open({ env: { CONFORMANCE_ENV: "from-create" } });
|
|
253
|
+
try {
|
|
254
|
+
const ran = got(await sandbox.exec({ command: "echo $CONFORMANCE_ENV", env: { CONFORMANCE_ENV: "from-call" } }), "exec");
|
|
255
|
+
equals(ran.stdout.trim(), "from-call", "the narrower of the two is the one that applies");
|
|
256
|
+
}
|
|
257
|
+
finally {
|
|
258
|
+
await sandbox.close();
|
|
259
|
+
}
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
cases.push({
|
|
263
|
+
name: substituting
|
|
264
|
+
? "a secret is not readable inside the machine"
|
|
265
|
+
: "a secret arrives as an environment variable, and the machine says it is plain",
|
|
266
|
+
async run() {
|
|
267
|
+
// Distinctive enough that finding it anywhere is finding this one.
|
|
268
|
+
const value = "conformance-secret-e7f2a1";
|
|
269
|
+
const sandbox = await open({
|
|
270
|
+
secrets: [{ env: "CONFORMANCE_SECRET", value, hosts: ["example.invalid"] }],
|
|
271
|
+
});
|
|
272
|
+
try {
|
|
273
|
+
const ran = got(await sandbox.exec({ command: "echo $CONFORMANCE_SECRET" }), "exec");
|
|
274
|
+
if (substituting) {
|
|
275
|
+
// The whole claim. Something has to be there — a process needs to be
|
|
276
|
+
// able to send it — but the plaintext must not be, or "substituted"
|
|
277
|
+
// is a label rather than a property.
|
|
278
|
+
holds(!ran.stdout.includes(value), "a provider claiming to substitute must not put the value where a process can read it");
|
|
279
|
+
holds(ran.stdout.trim().length > 0, "though the variable is set, so a process has something to send");
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
equals(ran.stdout.trim(), value, "a provider that cannot substitute passes the value through");
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
finally {
|
|
286
|
+
await sandbox.close();
|
|
287
|
+
}
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
// ---- Lifecycle ----------------------------------------------------------
|
|
291
|
+
cases.push({
|
|
292
|
+
name: "a closed machine refuses, and closing it twice is what closing asked for",
|
|
293
|
+
async run() {
|
|
294
|
+
const sandbox = await open();
|
|
295
|
+
got(await sandbox.close(), "close");
|
|
296
|
+
// A hosted sandbox is billable, so a refusal to release is reported and
|
|
297
|
+
// never swallowed — but a machine already gone is the requested state.
|
|
298
|
+
got(await sandbox.close(), "close again");
|
|
299
|
+
// Every method, not only the one: a caller that closed and kept a handle
|
|
300
|
+
// has a bug, and it is worth the same answer whichever call exposes it.
|
|
301
|
+
holds(!(await sandbox.exec({ command: "echo after" })).ok, "a closed sandbox does not run commands");
|
|
302
|
+
holds(!(await sandbox.readFile({ path: "notes/one.md" })).ok, "nor read files");
|
|
303
|
+
holds(!(await sandbox.writeFile({ path: "notes/one.md", content: "x" })).ok, "nor write them");
|
|
304
|
+
holds(!(await sandbox.spawn({ command: ["echo", "after"] })).ok, "nor start processes");
|
|
305
|
+
},
|
|
306
|
+
});
|
|
307
|
+
return cases;
|
|
308
|
+
}
|
|
309
|
+
//# sourceMappingURL=conformance.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"conformance.js","sourceRoot":"","sources":["../../src/sandbox/conformance.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * What any implementation of the sandbox port must do.\n *\n * The port's central claim is that isolation is requested and either enforced\n * or refused. A provider is the only thing that can honour it, and a provider\n * that overstates what it enforces is worse than one that refuses: the caller\n * asked a question precisely so it would not have to trust an adjective.\n *\n * Two implementations ship, a directory on the host and a container, and they\n * are the same seam only if they answer the same way — down to whether a\n * command's own non-zero exit is an outcome or a failure, whether a missing\n * file is `not-found` or an empty string, and whether a path leaving the root\n * is denied or quietly resolved. Where that differs it differs in production,\n * on the provider nobody develops against.\n *\n * Inert on purpose. Each case is a name and a function that throws, so the\n * suite drags no test framework into the package:\n *\n * ```ts\n * for (const item of defineSandboxConformance(subject)) test(item.name, item.run);\n * ```\n */\nimport type { NetworkPolicy, Sandbox, SandboxError, SandboxProvider } from \"./sandbox.js\";\nimport type { Failure, Result } from \"../result.js\";\n\n/** One case: a name, and a function that throws when the contract is broken. */\nexport interface ConformanceCase {\n name: string;\n run(): Promise<void>;\n}\n\nexport interface SandboxUnderTest {\n /**\n * The provider under test. A function, not a value, because constructing one\n * may need a credential this run does not have — and a suite that is about to\n * skip every case must not have thrown to find that out.\n */\n provider(): SandboxProvider;\n /**\n * What this provider enforces when isolation is required.\n *\n * `\"none\"` is a claim, not an omission: a provider that runs on the host must\n * refuse `isolation: \"required\"` rather than hand back something labelled\n * unisolated, and this suite holds it to that.\n */\n isolation: Sandbox[\"isolation\"];\n /** The outbound posture every case is created with. */\n network: NetworkPolicy;\n /**\n * Postures this provider does not enforce. Each must be refused before\n * anything is provisioned — a policy silently ignored is the one failure a\n * caller cannot detect from the outside.\n */\n refuses?: readonly NetworkPolicy[];\n /**\n * Whether a command's two output streams arrive apart.\n *\n * Declared rather than assumed, because not every backend has them: a hosted\n * box may expose one combined stream, and an adapter over it puts everything\n * in `stdout` and leaves `stderr` empty rather than inventing a split. That\n * is a real difference to an application parsing stderr, so it is said out\n * loud here instead of discovered. Defaults to `\"separate\"`.\n */\n streams?: \"separate\" | \"combined\";\n /**\n * What this provider does with a `Secret`.\n *\n * `\"substituted\"` is a strong claim — the value never enters the box — so the\n * suite checks that a process there genuinely cannot read it. `\"plain\"` is\n * checked the other way: the value has to actually arrive, because a provider\n * that quietly dropped it would leave every agent unauthenticated with\n * nothing saying why. Defaults to `\"plain\"`.\n */\n secrets?: Sandbox[\"secrets\"];\n}\n\nfunction fail(what: string): never {\n throw new Error(`sandbox conformance: ${what}`);\n}\n\nfunction holds(condition: boolean, what: string): asserts condition {\n if (!condition) fail(what);\n}\n\nfunction equals(actual: unknown, expected: unknown, what: string): void {\n if (actual !== expected) fail(`${what} — expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);\n}\n\nfunction got<T>(result: Result<T, Failure>, what: string): T {\n if (!result.ok) fail(`${what} — ${result.error.code}: ${result.error.message}`);\n return result.value;\n}\n\nfunction refused(result: Result<unknown, SandboxError>, code: SandboxError[\"code\"], what: string): void {\n holds(!result.ok, `${what} — it succeeded instead`);\n equals(result.error.code, code, what);\n}\n\nexport function defineSandboxConformance(subject: SandboxUnderTest): readonly ConformanceCase[] {\n const cases: ConformanceCase[] = [];\n const isolating = subject.isolation !== \"none\";\n\n const open = async (extra: Parameters<SandboxProvider[\"create\"]>[0] | object = {}): Promise<Sandbox> =>\n got(\n await subject.provider().create({\n isolation: isolating ? \"required\" : \"none\",\n network: subject.network,\n ...extra,\n }),\n \"create\",\n );\n\n /** Every case gets its own machine and gives it back, billable or not. */\n const define = (name: string, body: (sandbox: Sandbox) => Promise<void>): void => {\n cases.push({\n name,\n async run() {\n const sandbox = await open();\n try {\n await body(sandbox);\n } finally {\n await sandbox.close();\n }\n },\n });\n };\n\n // ---- What was actually provisioned --------------------------------------\n\n cases.push({\n name: \"isolation is requested and either enforced or refused\",\n async run() {\n const asked = await subject.provider().create({ isolation: \"required\", network: subject.network });\n if (!isolating) {\n // The whole reason the request is a request. A provider on the host\n // says so by failing, not by setting a field the caller may ignore.\n refused(asked, \"unsupported\", \"a host provider refuses isolation rather than downgrading it\");\n return;\n }\n const sandbox = got(asked, \"create with isolation required\");\n try {\n equals(sandbox.isolation, subject.isolation, \"the sandbox reports what it enforces\");\n } finally {\n await sandbox.close();\n }\n },\n });\n\n define(\"a machine names itself and its root\", async (sandbox) => {\n holds(sandbox.id.length > 0, \"a sandbox has an id, so it can be reconnected to\");\n holds(sandbox.root.startsWith(\"/\"), \"the root is an absolute path\");\n equals(sandbox.isolation, subject.isolation, \"and reports the isolation it enforces, never more\");\n });\n\n if (subject.refuses?.length) {\n for (const network of subject.refuses) {\n cases.push({\n name: `a '${network.mode}' network this provider cannot enforce is refused before anything is provisioned`,\n async run() {\n const asked = await subject.provider().create({\n isolation: isolating ? \"required\" : \"none\",\n network,\n });\n if (asked.ok) {\n await asked.value.close();\n fail(`'${network.mode}' was accepted by a provider that does not enforce it`);\n }\n equals(asked.error.code, \"unsupported\", \"the refusal says the posture is unsupported\");\n },\n });\n }\n }\n\n // ---- Commands -----------------------------------------------------------\n\n define(\"a command answers with its output and its exit code\", async (sandbox) => {\n const ran = got(await sandbox.exec({ command: \"echo hello\" }), \"exec\");\n equals(ran.stdout.trim(), \"hello\", \"stdout carries what the command wrote\");\n equals(ran.exitCode, 0, \"and the exit code it left\");\n });\n\n define(\"a command's own failure is an outcome, not a failure of the sandbox\", async (sandbox) => {\n // The distinction the whole `Result` shape turns on: the sandbox did its\n // job perfectly and the command inside it did not.\n const ran = got(await sandbox.exec({ command: \"echo oops >&2; exit 3\" }), \"exec\");\n equals(ran.exitCode, 3, \"the command's own code comes back\");\n holds(`${ran.stdout}${ran.stderr}`.includes(\"oops\"), \"and what it wrote is not lost\");\n });\n\n if ((subject.streams ?? \"separate\") === \"separate\") {\n define(\"stdout and stderr arrive apart\", async (sandbox) => {\n const ran = got(await sandbox.exec({ command: \"echo out; echo err >&2\" }), \"exec\");\n equals(ran.stdout.trim(), \"out\", \"stdout is what the command wrote to stdout\");\n equals(ran.stderr.trim(), \"err\", \"and stderr is what it wrote to stderr\");\n });\n\n define(\"a spawned process's two streams arrive apart as well\", async (sandbox) => {\n // The port hands back both pipes, so both are read here. Read together,\n // because a process filling one while nothing drains the other stops.\n const started = got(await sandbox.spawn({ command: [\"sh\", \"-c\", \"echo out; echo err >&2\"] }), \"spawn\");\n const [out, errors] = await Promise.all([\n new Response(started.stdout).text(),\n new Response(started.stderr).text(),\n ]);\n equals(out.trim(), \"out\", \"stdout is the stream the process wrote to stdout\");\n equals(errors.trim(), \"err\", \"and stderr is the one it wrote to stderr\");\n await started.exited;\n });\n }\n\n define(\"the environment a command is given reaches it\", async (sandbox) => {\n const ran = got(await sandbox.exec({ command: \"echo $AGLIB_PROOF\", env: { AGLIB_PROOF: \"carried\" } }), \"exec\");\n equals(ran.stdout.trim(), \"carried\", \"the value arrives in the command's environment\");\n });\n\n define(\"a command runs in the working directory it names\", async (sandbox) => {\n got(await sandbox.writeFile({ path: \"work/marker\", content: \"here\" }), \"writeFile\");\n const ran = got(await sandbox.exec({ command: \"cat marker\", cwd: \"work\" }), \"exec\");\n equals(ran.stdout.trim(), \"here\", \"a relative path resolves against the directory it was given\");\n });\n\n define(\"a command given an aborted signal is cancelled rather than run\", async (sandbox) => {\n const ran = await sandbox.exec({ command: \"echo ran\", signal: AbortSignal.abort() });\n refused(ran, \"cancelled\", \"an already-cancelled command reports cancelled\");\n });\n\n // ---- Files --------------------------------------------------------------\n\n define(\"a file written is the file read back, in a directory that did not exist\", async (sandbox) => {\n got(await sandbox.writeFile({ path: \"notes/deep/one.md\", content: \"kept\" }), \"writeFile\");\n equals(got(await sandbox.readFile({ path: \"notes/deep/one.md\" }), \"readFile\"), \"kept\",\n \"the content survives the round trip\");\n });\n\n define(\"a file that is not there is not-found rather than empty\", async (sandbox) => {\n // An empty string is a file that exists and says nothing. The difference\n // matters to every caller that branches on it.\n refused(await sandbox.readFile({ path: \"nothing-here.md\" }), \"not-found\", \"a missing file is not-found\");\n });\n\n if (!isolating) define(\"a symlink under the root does not lead out of it\", async (sandbox) => {\n // Only asked of a provider that has no other boundary, and that is the\n // whole of the rule: where the root is the only thing between a model and\n // the machine, it has to be a real boundary rather than a lexical one.\n // Lexically `escape/hostname` never leaves the root; on disk it was\n // whatever the link pointed at, and `readFile`/`writeFile` are the calls an\n // application hands to a model. A provider that isolates has a second\n // boundary doing that job, so it is not made to pay a `realpath` per call\n // for a confinement that is tidiness there rather than safety.\n got(await sandbox.writeFile({ path: \"inside.txt\", content: \"mine\" }), \"writeFile\");\n const linked = got(await sandbox.exec({ command: \"ln -s /etc escape\" }), \"link to /etc\");\n equals(linked.exitCode, 0, \"the link was made\");\n refused(await sandbox.readFile({ path: \"escape/hostname\" }), \"denied\",\n \"reading through a link that leaves the root is denied\");\n refused(await sandbox.writeFile({ path: \"escape/aglib-should-not-exist\", content: \"x\" }), \"denied\",\n \"writing through it is denied too\");\n // And the link is not a reason to refuse what is genuinely inside.\n equals(got(await sandbox.readFile({ path: \"inside.txt\" }), \"readFile\"), \"mine\",\n \"a real path in the root still reads\");\n });\n\n define(\"a command cancelled while it runs is cancelled, and promptly\", async (sandbox) => {\n const giving = new AbortController();\n const began = Date.now();\n setTimeout(() => giving.abort(), 200);\n const ran = await sandbox.exec({ command: \"sleep 5; echo done\", signal: giving.signal });\n const took = Date.now() - began;\n refused(ran, \"cancelled\", \"a command the caller gave up on is cancelled, not completed\");\n // The point is the promptness. A provider that waits for the command it was\n // told to abandon has not cancelled anything, whatever it reports.\n holds(took < 3_000, `the answer came back in ${took}ms, after the command it abandoned`);\n });\n\n define(\"a path outside the root is denied rather than resolved\", async (sandbox) => {\n for (const path of [\"../escape.md\", \"/etc/passwd\"]) {\n refused(await sandbox.readFile({ path }), \"denied\", `reading ${path} is denied`);\n refused(await sandbox.writeFile({ path, content: \"x\" }), \"denied\", `writing ${path} is denied`);\n }\n refused(await sandbox.exec({ command: \"pwd\", cwd: \"/etc\" }), \"denied\", \"a working directory outside the root is denied\");\n // Both methods take a working directory, so both confine it — and a\n // spawned process is the one still running after the call that started it.\n refused(await sandbox.spawn({ command: [\"pwd\"], cwd: \"/etc\" }), \"denied\",\n \"a spawned process's working directory outside the root is denied\");\n });\n\n define(\"a name that begins with dots is a file in the root, not a path out of it\", async (sandbox) => {\n // `..hidden` starts with two dots and leaves nothing. A guard that reads\n // the prefix rather than the segment refuses a file it should have written.\n got(await sandbox.writeFile({ path: \"..hidden\", content: \"kept\" }), \"writeFile\");\n equals(got(await sandbox.readFile({ path: \"..hidden\" }), \"readFile\"), \"kept\", \"and it reads back\");\n });\n\n define(\"a file operation given an aborted signal is cancelled rather than run\", async (sandbox) => {\n // The same promise `exec` makes. A caller that has already given up is told\n // so, rather than being handed the read it no longer wants.\n refused(await sandbox.readFile({ path: \"notes/one.md\", signal: AbortSignal.abort() }), \"cancelled\",\n \"an already-cancelled read reports cancelled\");\n refused(await sandbox.writeFile({ path: \"notes/one.md\", content: \"x\", signal: AbortSignal.abort() }), \"cancelled\",\n \"an already-cancelled write reports cancelled\");\n });\n\n // ---- Processes ----------------------------------------------------------\n\n define(\"a spawned process hands back its streams and its exit code\", async (sandbox) => {\n const started = got(await sandbox.spawn({ command: [\"sh\", \"-c\", \"echo streamed; exit 0\"] }), \"spawn\");\n equals((await new Response(started.stdout).text()).trim(), \"streamed\", \"stdout is a stream the caller reads\");\n equals(await started.exited, 0, \"and the process reports how it ended\");\n });\n\n define(\"a spawned process starts in the working directory it names\", async (sandbox) => {\n got(await sandbox.writeFile({ path: \"work/marker\", content: \"here\" }), \"writeFile\");\n const started = got(await sandbox.spawn({ command: [\"cat\", \"marker\"], cwd: \"work\" }), \"spawn\");\n equals((await new Response(started.stdout).text()).trim(), \"here\",\n \"a relative path resolves against the directory the process was given\");\n await started.exited;\n });\n\n define(\"a spawned process is given its environment without publishing it\", async (sandbox) => {\n // Both methods carry an environment, and both must carry it the same way.\n // A value on the command line is readable by anything on the host, and an\n // agent's environment is where its credentials are.\n const started = got(\n await sandbox.spawn({ command: [\"sh\", \"-c\", \"echo $AGLIB_PROOF\"], env: { AGLIB_PROOF: \"carried\" } }),\n \"spawn\",\n );\n equals((await new Response(started.stdout).text()).trim(), \"carried\", \"the value reaches the process\");\n await started.exited;\n });\n\n define(\"a spawned process takes what is written to it\", async (sandbox) => {\n // The method that makes changing provider a change of location: an agent\n // speaking a protocol over stdio needs a real duplex channel, not a\n // one-shot command.\n const started = got(\n await sandbox.spawn({ command: [\"sh\", \"-c\", \"read line; echo \\\"got $line\\\"\"] }),\n \"spawn\",\n );\n started.write(\"hello\\n\");\n equals((await new Response(started.stdout).text()).trim(), \"got hello\", \"what was written reached its stdin\");\n await started.exited;\n });\n\n define(\"a spawned process can be killed\", async (sandbox) => {\n const started = got(await sandbox.spawn({ command: [\"sh\", \"-c\", \"sleep 30\"] }), \"spawn\");\n started.kill();\n await started.exited;\n });\n\n // ---- Environment and credentials ----------------------------------------\n\n const substituting = (subject.secrets ?? \"plain\") === \"substituted\";\n\n cases.push({\n name: \"the environment a machine was made with reaches everything started in it\",\n async run() {\n const sandbox = await open({ env: { CONFORMANCE_ENV: \"from-create\" } });\n try {\n equals(sandbox.secrets, subject.secrets ?? \"plain\", \"and it says what it does with a secret\");\n const ran = got(await sandbox.exec({ command: \"echo $CONFORMANCE_ENV\" }), \"exec\");\n equals(ran.stdout.trim(), \"from-create\", \"a command reads what the machine was made with\");\n\n // Both, because they are two code paths in every adapter: one collects\n // and one hands back pipes, and an adapter has passed the environment\n // to one and not the other.\n const started = got(\n await sandbox.spawn({ command: [\"sh\", \"-c\", \"echo $CONFORMANCE_ENV\"] }),\n \"spawn\",\n );\n equals(\n (await new Response(started.stdout).text()).trim(),\n \"from-create\",\n \"and so does a spawned process\",\n );\n await started.exited;\n } finally {\n await sandbox.close();\n }\n },\n });\n\n cases.push({\n name: \"a call's own environment wins over the machine's\",\n async run() {\n const sandbox = await open({ env: { CONFORMANCE_ENV: \"from-create\" } });\n try {\n const ran = got(\n await sandbox.exec({ command: \"echo $CONFORMANCE_ENV\", env: { CONFORMANCE_ENV: \"from-call\" } }),\n \"exec\",\n );\n equals(ran.stdout.trim(), \"from-call\", \"the narrower of the two is the one that applies\");\n } finally {\n await sandbox.close();\n }\n },\n });\n\n cases.push({\n name: substituting\n ? \"a secret is not readable inside the machine\"\n : \"a secret arrives as an environment variable, and the machine says it is plain\",\n async run() {\n // Distinctive enough that finding it anywhere is finding this one.\n const value = \"conformance-secret-e7f2a1\";\n const sandbox = await open({\n secrets: [{ env: \"CONFORMANCE_SECRET\", value, hosts: [\"example.invalid\"] }],\n });\n try {\n const ran = got(await sandbox.exec({ command: \"echo $CONFORMANCE_SECRET\" }), \"exec\");\n if (substituting) {\n // The whole claim. Something has to be there — a process needs to be\n // able to send it — but the plaintext must not be, or \"substituted\"\n // is a label rather than a property.\n holds(\n !ran.stdout.includes(value),\n \"a provider claiming to substitute must not put the value where a process can read it\",\n );\n holds(ran.stdout.trim().length > 0, \"though the variable is set, so a process has something to send\");\n } else {\n equals(ran.stdout.trim(), value, \"a provider that cannot substitute passes the value through\");\n }\n } finally {\n await sandbox.close();\n }\n },\n });\n\n // ---- Lifecycle ----------------------------------------------------------\n\n cases.push({\n name: \"a closed machine refuses, and closing it twice is what closing asked for\",\n async run() {\n const sandbox = await open();\n got(await sandbox.close(), \"close\");\n // A hosted sandbox is billable, so a refusal to release is reported and\n // never swallowed — but a machine already gone is the requested state.\n got(await sandbox.close(), \"close again\");\n // Every method, not only the one: a caller that closed and kept a handle\n // has a bug, and it is worth the same answer whichever call exposes it.\n holds(!(await sandbox.exec({ command: \"echo after\" })).ok, \"a closed sandbox does not run commands\");\n holds(!(await sandbox.readFile({ path: \"notes/one.md\" })).ok, \"nor read files\");\n holds(!(await sandbox.writeFile({ path: \"notes/one.md\", content: \"x\" })).ok, \"nor write them\");\n holds(!(await sandbox.spawn({ command: [\"echo\", \"after\"] })).ok, \"nor start processes\");\n },\n });\n\n return cases;\n}\n"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type { CommandResult, NetworkPolicy, Sandbox, SandboxError, SandboxProcess, SandboxProvider, Secret, } from "./sandbox.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/sandbox/index.ts"],"names":[],"mappings":"","sourcesContent":["export type {\n CommandResult, NetworkPolicy, Sandbox, SandboxError, SandboxProcess, SandboxProvider, Secret,\n} from \"./sandbox.js\";\n"]}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import type { Failure, Result } from "../result.js";
|
|
2
|
+
export interface SandboxError extends Failure {
|
|
3
|
+
code: "unavailable" | "not-found" | "denied" | "timeout" | "cancelled" | "unsupported" | "failed";
|
|
4
|
+
}
|
|
5
|
+
export type NetworkPolicy = {
|
|
6
|
+
mode: "deny-all";
|
|
7
|
+
} | {
|
|
8
|
+
mode: "web-allowlist";
|
|
9
|
+
hosts: readonly string[];
|
|
10
|
+
} | {
|
|
11
|
+
mode: "unrestricted";
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* A finished command. A non-zero `exitCode` is an outcome, not a
|
|
15
|
+
* `SandboxError`. A type alias rather than an interface so it satisfies
|
|
16
|
+
* `JsonValue` structurally and can be handed to a tool result without a cast.
|
|
17
|
+
*/
|
|
18
|
+
export type CommandResult = {
|
|
19
|
+
stdout: string;
|
|
20
|
+
stderr: string;
|
|
21
|
+
exitCode: number;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* A process still running, with its streams.
|
|
25
|
+
*
|
|
26
|
+
* `exec` waits and collects; this hands back the pipes. Both are on the port
|
|
27
|
+
* because a provider implements each natively — a remote box has a one-shot
|
|
28
|
+
* command API that is cheaper than holding a stream open, and an interactive
|
|
29
|
+
* process cannot be expressed as a one-shot call at all.
|
|
30
|
+
*/
|
|
31
|
+
export interface SandboxProcess {
|
|
32
|
+
write(chunk: string): void;
|
|
33
|
+
readonly stdout: ReadableStream<Uint8Array>;
|
|
34
|
+
readonly stderr: ReadableStream<Uint8Array>;
|
|
35
|
+
readonly exited: Promise<number>;
|
|
36
|
+
kill(): void;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* A credential a process in the sandbox needs, and where it may be sent.
|
|
40
|
+
*
|
|
41
|
+
* Named apart from plain environment because the two want different things
|
|
42
|
+
* from a provider. Configuration is meant to be readable inside the box — a
|
|
43
|
+
* model name is no use to a process that cannot read it. A credential is the
|
|
44
|
+
* opposite: the box needs to *send* it, and every provider that can arrange
|
|
45
|
+
* that without the box ever holding it should.
|
|
46
|
+
*
|
|
47
|
+
* Whether that happened is `Sandbox.secrets`, and it is a fact about the
|
|
48
|
+
* provider rather than about any one secret.
|
|
49
|
+
*/
|
|
50
|
+
export interface Secret {
|
|
51
|
+
/** The variable a process reads it from. */
|
|
52
|
+
env: string;
|
|
53
|
+
value: string;
|
|
54
|
+
/**
|
|
55
|
+
* Hosts this may be sent to. Enforced only where the value is substituted —
|
|
56
|
+
* a provider holding the plaintext in the box cannot police what reads it,
|
|
57
|
+
* and says so rather than implying otherwise. Absent means anywhere.
|
|
58
|
+
*/
|
|
59
|
+
hosts?: readonly string[];
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* One place commands run and files live.
|
|
63
|
+
*
|
|
64
|
+
* Every path is confined to `root`, and how far that confinement reaches
|
|
65
|
+
* depends on what else is holding the line. Where the root is the only
|
|
66
|
+
* boundary — a directory on the host — it is resolved against the filesystem,
|
|
67
|
+
* because a symlink inside it that points out of it is followed by every call
|
|
68
|
+
* that matters. Where a container or a machine is the boundary, the check is
|
|
69
|
+
* lexical and the containment is the container's; a `realpath` per file call
|
|
70
|
+
* would buy tidiness, not safety. The conformance suite asks only the first
|
|
71
|
+
* kind for the stronger guarantee.
|
|
72
|
+
*/
|
|
73
|
+
export interface Sandbox {
|
|
74
|
+
readonly id: string;
|
|
75
|
+
readonly root: string;
|
|
76
|
+
/** What was actually enforced. A provider never reports more than it delivers. */
|
|
77
|
+
readonly isolation: "none" | "container" | "vm";
|
|
78
|
+
/**
|
|
79
|
+
* What became of the values in `secrets`.
|
|
80
|
+
*
|
|
81
|
+
* `"substituted"` — the box holds a reference, and the provider puts the
|
|
82
|
+
* value in on the way out, to the hosts that secret named. A process here
|
|
83
|
+
* cannot read it, and neither can anything it starts or anything it writes
|
|
84
|
+
* to disk.
|
|
85
|
+
*
|
|
86
|
+
* `"plain"` — the value is in the environment, where any process here can
|
|
87
|
+
* read it. Not a weaker form of the same thing: the caller asked for one and
|
|
88
|
+
* got the other, and this is where it can find out. A caller handing a
|
|
89
|
+
* long-lived credential to code it did not write may reasonably refuse to
|
|
90
|
+
* continue.
|
|
91
|
+
*/
|
|
92
|
+
readonly secrets: "substituted" | "plain";
|
|
93
|
+
exec(input: {
|
|
94
|
+
command: string;
|
|
95
|
+
cwd?: string;
|
|
96
|
+
env?: Readonly<Record<string, string>>;
|
|
97
|
+
signal?: AbortSignal;
|
|
98
|
+
}): Promise<Result<CommandResult, SandboxError>>;
|
|
99
|
+
/**
|
|
100
|
+
* Start a process here and keep hold of it.
|
|
101
|
+
*
|
|
102
|
+
* This is the method that makes changing provider a change of *location*.
|
|
103
|
+
* A coding agent driven over a protocol runs its own shell in its own
|
|
104
|
+
* process; routing that agent's file and terminal requests through the port
|
|
105
|
+
* only moves the calls it chooses to delegate. Starting the agent itself with
|
|
106
|
+
* `spawn` moves all of it, because there is nowhere else for it to be.
|
|
107
|
+
*/
|
|
108
|
+
spawn(input: {
|
|
109
|
+
command: readonly string[];
|
|
110
|
+
cwd?: string;
|
|
111
|
+
env?: Readonly<Record<string, string>>;
|
|
112
|
+
}): Promise<Result<SandboxProcess, SandboxError>>;
|
|
113
|
+
readFile(input: {
|
|
114
|
+
path: string;
|
|
115
|
+
signal?: AbortSignal;
|
|
116
|
+
}): Promise<Result<string, SandboxError>>;
|
|
117
|
+
writeFile(input: {
|
|
118
|
+
path: string;
|
|
119
|
+
content: string;
|
|
120
|
+
signal?: AbortSignal;
|
|
121
|
+
}): Promise<Result<void, SandboxError>>;
|
|
122
|
+
/**
|
|
123
|
+
* Release it. A refusal is reported, not swallowed: a hosted sandbox is
|
|
124
|
+
* billable, so a caller whose termination did not take is still paying and
|
|
125
|
+
* deserves to be told rather than to find out on an invoice.
|
|
126
|
+
*/
|
|
127
|
+
close(): Promise<Result<void, SandboxError>>;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Isolation is requested, not declared.
|
|
131
|
+
*
|
|
132
|
+
* A caller asks for `"required"` and a provider that runs on the host answers
|
|
133
|
+
* `unsupported` rather than handing back something that merely says it is not
|
|
134
|
+
* isolated. That is the difference between a contract and a disclaimer: the
|
|
135
|
+
* old shape let a caller ignore a boolean, this one cannot be ignored.
|
|
136
|
+
*/
|
|
137
|
+
export interface SandboxProvider {
|
|
138
|
+
readonly id: string;
|
|
139
|
+
create(input: {
|
|
140
|
+
isolation: "required" | "none";
|
|
141
|
+
/**
|
|
142
|
+
* Required, and deliberately not optional. This runs whatever a model
|
|
143
|
+
* types, so outbound access is a decision someone has to make rather than
|
|
144
|
+
* something a provider default can hand over quietly.
|
|
145
|
+
*/
|
|
146
|
+
network: NetworkPolicy;
|
|
147
|
+
/**
|
|
148
|
+
* Set for everything started here, and inherited by whatever those start.
|
|
149
|
+
*
|
|
150
|
+
* Here rather than only on `exec` and `spawn` because a provider may have
|
|
151
|
+
* to arrange it when the box is made: an agent that runs its own shell is
|
|
152
|
+
* started once, and what it hands to its children is settled before any
|
|
153
|
+
* call this port makes. A per-call `env` still wins over this for that call.
|
|
154
|
+
*
|
|
155
|
+
* Configuration, not credentials — those are `secrets`, so that a provider
|
|
156
|
+
* able to keep them out of the box is given the chance to.
|
|
157
|
+
*/
|
|
158
|
+
env?: Readonly<Record<string, string>>;
|
|
159
|
+
/**
|
|
160
|
+
* Credentials for processes here.
|
|
161
|
+
*
|
|
162
|
+
* Every provider accepts these; what differs is whether the value ends up
|
|
163
|
+
* inside the box, which the sandbox then reports as `secrets`. Passing one
|
|
164
|
+
* here rather than as `env` costs a provider that cannot substitute
|
|
165
|
+
* nothing, and is the only way to benefit from one that can.
|
|
166
|
+
*/
|
|
167
|
+
secrets?: readonly Secret[];
|
|
168
|
+
/** Reattach to an existing sandbox by id instead of creating one. */
|
|
169
|
+
reconnect?: string;
|
|
170
|
+
signal?: AbortSignal;
|
|
171
|
+
}): Promise<Result<Sandbox, SandboxError>>;
|
|
172
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sandbox.js","sourceRoot":"","sources":["../../src/sandbox/sandbox.ts"],"names":[],"mappings":"","sourcesContent":["import type { Failure, Result } from \"../result.js\";\n\nexport interface SandboxError extends Failure {\n code: \"unavailable\" | \"not-found\" | \"denied\" | \"timeout\" | \"cancelled\" | \"unsupported\" | \"failed\";\n}\n\nexport type NetworkPolicy =\n | { mode: \"deny-all\" }\n | { mode: \"web-allowlist\"; hosts: readonly string[] }\n | { mode: \"unrestricted\" };\n\n/**\n * A finished command. A non-zero `exitCode` is an outcome, not a\n * `SandboxError`. A type alias rather than an interface so it satisfies\n * `JsonValue` structurally and can be handed to a tool result without a cast.\n */\nexport type CommandResult = { stdout: string; stderr: string; exitCode: number };\n\n/**\n * A process still running, with its streams.\n *\n * `exec` waits and collects; this hands back the pipes. Both are on the port\n * because a provider implements each natively — a remote box has a one-shot\n * command API that is cheaper than holding a stream open, and an interactive\n * process cannot be expressed as a one-shot call at all.\n */\nexport interface SandboxProcess {\n write(chunk: string): void;\n readonly stdout: ReadableStream<Uint8Array>;\n readonly stderr: ReadableStream<Uint8Array>;\n readonly exited: Promise<number>;\n kill(): void;\n}\n\n/**\n * A credential a process in the sandbox needs, and where it may be sent.\n *\n * Named apart from plain environment because the two want different things\n * from a provider. Configuration is meant to be readable inside the box — a\n * model name is no use to a process that cannot read it. A credential is the\n * opposite: the box needs to *send* it, and every provider that can arrange\n * that without the box ever holding it should.\n *\n * Whether that happened is `Sandbox.secrets`, and it is a fact about the\n * provider rather than about any one secret.\n */\nexport interface Secret {\n /** The variable a process reads it from. */\n env: string;\n value: string;\n /**\n * Hosts this may be sent to. Enforced only where the value is substituted —\n * a provider holding the plaintext in the box cannot police what reads it,\n * and says so rather than implying otherwise. Absent means anywhere.\n */\n hosts?: readonly string[];\n}\n\n/**\n * One place commands run and files live.\n *\n * Every path is confined to `root`, and how far that confinement reaches\n * depends on what else is holding the line. Where the root is the only\n * boundary — a directory on the host — it is resolved against the filesystem,\n * because a symlink inside it that points out of it is followed by every call\n * that matters. Where a container or a machine is the boundary, the check is\n * lexical and the containment is the container's; a `realpath` per file call\n * would buy tidiness, not safety. The conformance suite asks only the first\n * kind for the stronger guarantee.\n */\nexport interface Sandbox {\n readonly id: string;\n readonly root: string;\n /** What was actually enforced. A provider never reports more than it delivers. */\n readonly isolation: \"none\" | \"container\" | \"vm\";\n /**\n * What became of the values in `secrets`.\n *\n * `\"substituted\"` — the box holds a reference, and the provider puts the\n * value in on the way out, to the hosts that secret named. A process here\n * cannot read it, and neither can anything it starts or anything it writes\n * to disk.\n *\n * `\"plain\"` — the value is in the environment, where any process here can\n * read it. Not a weaker form of the same thing: the caller asked for one and\n * got the other, and this is where it can find out. A caller handing a\n * long-lived credential to code it did not write may reasonably refuse to\n * continue.\n */\n readonly secrets: \"substituted\" | \"plain\";\n exec(input: { command: string; cwd?: string; env?: Readonly<Record<string, string>>; signal?: AbortSignal }):\n Promise<Result<CommandResult, SandboxError>>;\n /**\n * Start a process here and keep hold of it.\n *\n * This is the method that makes changing provider a change of *location*.\n * A coding agent driven over a protocol runs its own shell in its own\n * process; routing that agent's file and terminal requests through the port\n * only moves the calls it chooses to delegate. Starting the agent itself with\n * `spawn` moves all of it, because there is nowhere else for it to be.\n */\n spawn(input: { command: readonly string[]; cwd?: string; env?: Readonly<Record<string, string>> }):\n Promise<Result<SandboxProcess, SandboxError>>;\n readFile(input: { path: string; signal?: AbortSignal }): Promise<Result<string, SandboxError>>;\n writeFile(input: { path: string; content: string; signal?: AbortSignal }): Promise<Result<void, SandboxError>>;\n /**\n * Release it. A refusal is reported, not swallowed: a hosted sandbox is\n * billable, so a caller whose termination did not take is still paying and\n * deserves to be told rather than to find out on an invoice.\n */\n close(): Promise<Result<void, SandboxError>>;\n}\n\n/**\n * Isolation is requested, not declared.\n *\n * A caller asks for `\"required\"` and a provider that runs on the host answers\n * `unsupported` rather than handing back something that merely says it is not\n * isolated. That is the difference between a contract and a disclaimer: the\n * old shape let a caller ignore a boolean, this one cannot be ignored.\n */\nexport interface SandboxProvider {\n readonly id: string;\n create(input: {\n isolation: \"required\" | \"none\";\n /**\n * Required, and deliberately not optional. This runs whatever a model\n * types, so outbound access is a decision someone has to make rather than\n * something a provider default can hand over quietly.\n */\n network: NetworkPolicy;\n /**\n * Set for everything started here, and inherited by whatever those start.\n *\n * Here rather than only on `exec` and `spawn` because a provider may have\n * to arrange it when the box is made: an agent that runs its own shell is\n * started once, and what it hands to its children is settled before any\n * call this port makes. A per-call `env` still wins over this for that call.\n *\n * Configuration, not credentials — those are `secrets`, so that a provider\n * able to keep them out of the box is given the chance to.\n */\n env?: Readonly<Record<string, string>>;\n /**\n * Credentials for processes here.\n *\n * Every provider accepts these; what differs is whether the value ends up\n * inside the box, which the sandbox then reports as `secrets`. Passing one\n * here rather than as `env` costs a provider that cannot substitute\n * nothing, and is the only way to benefit from one that can.\n */\n secrets?: readonly Secret[];\n /** Reattach to an existing sandbox by id instead of creating one. */\n reconnect?: string;\n signal?: AbortSignal;\n }): Promise<Result<Sandbox, SandboxError>>;\n}\n"]}
|