@dynamicagents/plugins 0.13.0 → 0.15.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/README.md +14 -14
- package/dist/claude-code/cards.d.ts +4 -0
- package/dist/claude-code/cards.js +22 -0
- package/dist/claude-code/config.d.ts +10 -83
- package/dist/claude-code/config.js +0 -2
- package/dist/claude-code/credentials.d.ts +32 -50
- package/dist/claude-code/credentials.js +115 -74
- package/dist/claude-code/daemon/index.d.ts +24 -0
- package/dist/claude-code/daemon/index.js +203 -0
- package/dist/claude-code/egress.d.ts +35 -117
- package/dist/claude-code/egress.js +80 -325
- package/dist/claude-code/events.d.ts +14 -17
- package/dist/claude-code/events.js +19 -15
- package/dist/claude-code/harness-wire.d.ts +45 -0
- package/dist/claude-code/harness-wire.js +47 -0
- package/dist/claude-code/index.d.ts +19 -29
- package/dist/claude-code/index.js +17 -25
- package/dist/claude-code/language-model.d.ts +19 -0
- package/dist/claude-code/language-model.js +71 -0
- package/dist/claude-code/model.d.ts +77 -70
- package/dist/claude-code/model.js +328 -304
- package/dist/claude-code/placeholder.d.ts +12 -0
- package/dist/claude-code/placeholder.js +12 -0
- package/dist/claude-code/spec.d.ts +4 -4
- package/dist/claude-code/spec.js +4 -4
- package/dist/claude-code/verified.d.ts +1 -1
- package/dist/claude-code/verified.js +1 -1
- package/dist/computer/client.d.ts +88 -0
- package/dist/computer/client.js +6 -0
- package/dist/computer/context.d.ts +7 -7
- package/dist/computer/context.js +14 -18
- package/dist/computer/gate.d.ts +4 -23
- package/dist/computer/gate.js +1 -37
- package/dist/computer/index.d.ts +44 -43
- package/dist/computer/index.js +29 -50
- package/dist/computer/paths.d.ts +5 -27
- package/dist/computer/paths.js +7 -30
- package/dist/computer/proxy.d.ts +17 -31
- package/dist/computer/proxy.js +9 -44
- package/dist/computer/render.d.ts +3 -40
- package/dist/computer/render.js +3 -36
- package/dist/computer/sandbox.d.ts +55 -0
- package/dist/computer/sandbox.js +236 -0
- package/dist/computer/shell.d.ts +36 -0
- package/dist/computer/shell.js +41 -0
- package/dist/computer/tools-exec.js +3 -9
- package/dist/computer/tools-file.d.ts +1 -1
- package/dist/computer/tools-file.js +1 -1
- package/dist/{repo → repo-github}/checkout.d.ts +3 -4
- package/dist/{repo → repo-github}/checkout.js +3 -4
- package/dist/{repo → repo-github}/context.d.ts +14 -32
- package/dist/{repo → repo-github}/context.js +46 -81
- package/dist/{repo → repo-github}/index.d.ts +30 -13
- package/dist/{repo → repo-github}/index.js +25 -6
- package/dist/repo-github/mcp.d.ts +95 -0
- package/dist/repo-github/mcp.js +107 -0
- package/dist/repo-github/sandbox-egress.d.ts +68 -0
- package/dist/repo-github/sandbox-egress.js +272 -0
- package/dist/repo-github/sandbox-git.d.ts +30 -0
- package/dist/repo-github/sandbox-git.js +113 -0
- package/dist/{repo → repo-github}/tools-checks.js +43 -4
- package/dist/{repo → repo-github}/tools-clone.js +2 -21
- package/dist/{repo → repo-github}/tools-pr.d.ts +0 -1
- package/dist/{repo → repo-github}/tools-pr.js +85 -20
- package/dist/{repo → repo-github}/tools-review.d.ts +6 -0
- package/dist/{repo → repo-github}/tools-review.js +62 -176
- package/dist/{repo → repo-github}/url.d.ts +11 -0
- package/dist/{repo → repo-github}/url.js +12 -1
- package/dist/{workspace → sandbox}/advisory.d.ts +16 -35
- package/dist/{workspace → sandbox}/advisory.js +2 -29
- package/dist/sandbox/agent-exec.d.ts +31 -0
- package/dist/sandbox/agent-exec.js +62 -0
- package/dist/sandbox/ca-trust.d.ts +9 -0
- package/dist/sandbox/ca-trust.js +21 -0
- package/dist/sandbox/constants.d.ts +22 -0
- package/dist/sandbox/constants.js +22 -0
- package/dist/sandbox/daemon/composite.d.ts +9 -0
- package/dist/sandbox/daemon/composite.js +35 -0
- package/dist/sandbox/daemon/index.d.ts +47 -0
- package/dist/sandbox/daemon/index.js +116 -0
- package/dist/sandbox/egress.d.ts +81 -0
- package/dist/sandbox/egress.js +109 -0
- package/dist/sandbox/exec.d.ts +57 -0
- package/dist/sandbox/exec.js +52 -0
- package/dist/sandbox/format.d.ts +2 -0
- package/dist/sandbox/format.js +7 -0
- package/dist/sandbox/header.d.ts +50 -0
- package/dist/sandbox/header.js +94 -0
- package/dist/sandbox/index.d.ts +17 -0
- package/dist/sandbox/index.js +17 -0
- package/dist/{workspace/install.d.ts → sandbox/install-plan.d.ts} +10 -1
- package/dist/{workspace/install.js → sandbox/install-plan.js} +22 -4
- package/dist/sandbox/name.d.ts +27 -0
- package/dist/sandbox/name.js +32 -0
- package/dist/sandbox/object.d.ts +244 -0
- package/dist/sandbox/object.js +1139 -0
- package/dist/scratch/index.d.ts +1 -9
- package/dist/scratch/index.js +2 -18
- package/package.json +26 -22
- package/dist/claude-code/github.d.ts +0 -39
- package/dist/claude-code/github.js +0 -82
- package/dist/claude-code/run.d.ts +0 -474
- package/dist/claude-code/run.js +0 -901
- package/dist/claude-code/session.d.ts +0 -131
- package/dist/claude-code/session.js +0 -285
- package/dist/workspace/ca-trust.d.ts +0 -95
- package/dist/workspace/ca-trust.js +0 -312
- package/dist/workspace/container-deps.d.ts +0 -54
- package/dist/workspace/container-deps.js +0 -224
- package/dist/workspace/container-fault.d.ts +0 -38
- package/dist/workspace/container-fault.js +0 -73
- package/dist/workspace/exec.d.ts +0 -94
- package/dist/workspace/exec.js +0 -153
- package/dist/workspace/format.d.ts +0 -36
- package/dist/workspace/format.js +0 -57
- package/dist/workspace/git-host.d.ts +0 -79
- package/dist/workspace/git-host.js +0 -226
- package/dist/workspace/git-identity.d.ts +0 -57
- package/dist/workspace/git-identity.js +0 -147
- package/dist/workspace/index.d.ts +0 -49
- package/dist/workspace/index.js +0 -58
- package/dist/workspace/install-job.d.ts +0 -121
- package/dist/workspace/install-job.js +0 -866
- package/dist/workspace/object.d.ts +0 -502
- package/dist/workspace/object.js +0 -1427
- package/dist/workspace/open.d.ts +0 -95
- package/dist/workspace/open.js +0 -63
- package/dist/workspace/read.d.ts +0 -16
- package/dist/workspace/read.js +0 -22
- package/dist/workspace/shell.d.ts +0 -45
- package/dist/workspace/shell.js +0 -80
- package/dist/workspace/sync.d.ts +0 -111
- package/dist/workspace/sync.js +0 -124
- package/dist/workspace/wake.d.ts +0 -19
- package/dist/workspace/wake.js +0 -10
- /package/dist/{repo → repo-github}/tools-checks.d.ts +0 -0
- /package/dist/{repo → repo-github}/tools-clone.d.ts +0 -0
- /package/dist/{repo → repo-github}/tools-worktree.d.ts +0 -0
- /package/dist/{repo → repo-github}/tools-worktree.js +0 -0
- /package/dist/{repo → repo-github}/tools-worktrees.d.ts +0 -0
- /package/dist/{repo → repo-github}/tools-worktrees.js +0 -0
|
@@ -0,0 +1,1139 @@
|
|
|
1
|
+
import { DurableObject } from "cloudflare:workers";
|
|
2
|
+
import { ContainerHarness, containerAgent } from "agents/harness/container";
|
|
3
|
+
import { installScheduler, namedDeadline } from "@dynamicagents/core/alarm";
|
|
4
|
+
import { DirectoryBackup, Files } from "@cloudflare/sandbox";
|
|
5
|
+
import { asAgent, AGENT_GID, AGENT_UID, shellQuote } from "./agent-exec.js";
|
|
6
|
+
import { deriveAdvisories } from "./advisory.js";
|
|
7
|
+
import { egressPlan } from "./egress.js";
|
|
8
|
+
import { humanMs } from "./format.js";
|
|
9
|
+
import { TRUST_CA_COMMAND } from "./ca-trust.js";
|
|
10
|
+
import { CONTROL_SESSION, CONTROL_WAKE, EGRESS_READY, RUN_DIR, SANDBOX_CONTRACT } from "./constants.js";
|
|
11
|
+
import { isUnder, readRunHeader, SANDBOX_ROOT, withRunHeader } from "./header.js";
|
|
12
|
+
import { DEFAULT_INSTALL_PLAN, installFingerprint, resolveInstallCommand } from "./install-plan.js";
|
|
13
|
+
/**
|
|
14
|
+
* A Durable Object driving one container through `agents`' `ContainerHarness`.
|
|
15
|
+
*
|
|
16
|
+
* The checkout is the container's own disk. The object holds the durable
|
|
17
|
+
* record — sessions, operations, the transcript, each CLI's resume files — and
|
|
18
|
+
* dials the daemon in the container; the container never dials back. What the
|
|
19
|
+
* harness does not do is closed here, from outside it, and each rule below
|
|
20
|
+
* names what it closes. The approach is measured in the harness spike's
|
|
21
|
+
* `RESULTS.md`; the whole contract is in this subpath's README.
|
|
22
|
+
*/
|
|
23
|
+
/** The harness's own workspace-snapshot record: read at its next start. Pinned by the contract spec. */
|
|
24
|
+
const HARNESS_WORKSPACE_KEY = "container-harness:workspace";
|
|
25
|
+
const RUNTIME_READY_KEY = "sandbox:runtime-ready";
|
|
26
|
+
const CHECKPOINT_KEY = "sandbox:checkpoint";
|
|
27
|
+
const INSTALL_KEY = "sandbox:install";
|
|
28
|
+
const INSTALL_ARMED_KEY = "sandbox:install-armed";
|
|
29
|
+
const INSTALL_QUEUE_KEY = "sandbox:install-queue";
|
|
30
|
+
/** An install has finished since the last checkpoint, which the queue's end owes it. */
|
|
31
|
+
const CHECKPOINT_OWED_KEY = "sandbox:checkpoint-owed";
|
|
32
|
+
const LAST_USED_KEY = "sandbox:last-used";
|
|
33
|
+
const IDLE_STOP_MS = 5 * 60_000;
|
|
34
|
+
/** The harness's own stop is a backstop behind {@link IDLE_STOP_MS}: its longest allowed window. */
|
|
35
|
+
const HARNESS_IDLE_MS = 6 * 3_600_000 - 2 * 60_000;
|
|
36
|
+
const EGRESS_WATCH_MS = 10_000;
|
|
37
|
+
const INSTALL_POLL_MS = 15_000;
|
|
38
|
+
/** Past its deadline by this much with no exit written, an install was killed before it could write one. */
|
|
39
|
+
const INSTALL_GRACE_MS = 60_000;
|
|
40
|
+
/**
|
|
41
|
+
* One marker per checkout whose files are on this disk, on the root filesystem
|
|
42
|
+
* a snapshot keeps. A directory proves nothing: `/workspace` is in every image.
|
|
43
|
+
*/
|
|
44
|
+
const CHECKOUT_MARKS = "/var/lib/da/checkouts";
|
|
45
|
+
/**
|
|
46
|
+
* Per checkout, what its last finished install was for and which
|
|
47
|
+
* `node_modules` it made, on the same disk: it goes with that disk into a
|
|
48
|
+
* snapshot, and into another sandbox seeded from one.
|
|
49
|
+
*/
|
|
50
|
+
const INSTALL_MARKS = "/var/lib/da/installs";
|
|
51
|
+
/** The harness sends SIGKILL this long after SIGINT, then syncs once more. */
|
|
52
|
+
const CLI_KILL_MS = 5_000;
|
|
53
|
+
const CLI_SYNC_MS = 5_000;
|
|
54
|
+
const POLL_WAIT_MS = 20_000;
|
|
55
|
+
export class SandboxObjectBase extends DurableObject {
|
|
56
|
+
/** The object's container. A seam so specs can hand in a fake one. */
|
|
57
|
+
containerFor(ctx) {
|
|
58
|
+
if (!ctx.container) {
|
|
59
|
+
throw new Error("this object has no container: give its class a `containers` entry");
|
|
60
|
+
}
|
|
61
|
+
return ctx.container;
|
|
62
|
+
}
|
|
63
|
+
#cfg;
|
|
64
|
+
#egress;
|
|
65
|
+
#container;
|
|
66
|
+
#harness;
|
|
67
|
+
#wake;
|
|
68
|
+
#idle;
|
|
69
|
+
#egressWatch;
|
|
70
|
+
#installPoll;
|
|
71
|
+
#runtimePreparing;
|
|
72
|
+
/** Commands, file calls and submissions under way: each holds the container up. */
|
|
73
|
+
#running = 0;
|
|
74
|
+
/** A stop under way. Work that would start the container waits it out. */
|
|
75
|
+
#stopping;
|
|
76
|
+
/** Reclaimed: its storage is gone, and this instance is about to be. */
|
|
77
|
+
#gone = false;
|
|
78
|
+
constructor(ctx, env) {
|
|
79
|
+
super(ctx, env);
|
|
80
|
+
this.#cfg = this.sandboxConfig();
|
|
81
|
+
this.#egress = egressPlan(this.#cfg.egress.rules, ctx.id.name === undefined
|
|
82
|
+
? undefined
|
|
83
|
+
: `${this.#cfg.egress.self}/${ctx.id.name}`);
|
|
84
|
+
const clash = Object.keys(this.#cfg.env ?? {}).filter((key) => Object.hasOwn(this.#egress.env, key));
|
|
85
|
+
if (clash.length > 0)
|
|
86
|
+
throw new Error(`[${this.#cfg.label}] env sets ${clash.join(", ")}, which an egress rule sets`);
|
|
87
|
+
this.#container = this.containerFor(ctx);
|
|
88
|
+
this.#harness = new ContainerHarness({
|
|
89
|
+
container: this.#container,
|
|
90
|
+
agent: containerAgent({
|
|
91
|
+
image: this.#image(),
|
|
92
|
+
adapter: this.#cfg.adapter,
|
|
93
|
+
// The daemon's environment, which each CLI turn inherits.
|
|
94
|
+
env: { ...this.#cfg.env, ...this.#egress.env },
|
|
95
|
+
egress: [...this.#egress.http]
|
|
96
|
+
}),
|
|
97
|
+
egress: this.#cfg.egress.entrypoint,
|
|
98
|
+
instance: this.#cfg.instance,
|
|
99
|
+
idleTimeoutMs: HARNESS_IDLE_MS,
|
|
100
|
+
onContainerLost: "fail"
|
|
101
|
+
});
|
|
102
|
+
this.#wake = installScheduler(this, {
|
|
103
|
+
callbacks: {
|
|
104
|
+
sandboxIdle: () => this.#onIdle(),
|
|
105
|
+
egressWatch: () => this.#onEgressWatch(),
|
|
106
|
+
installPoll: () => this.#onInstallPoll()
|
|
107
|
+
},
|
|
108
|
+
onError: (err) => {
|
|
109
|
+
console.error(`[${this.#cfg.label}] a scheduled callback failed for good`, {
|
|
110
|
+
id: this.ctx.id.toString(),
|
|
111
|
+
err: String(err)
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
this.#wake.lifecycle.use(this.#harness);
|
|
116
|
+
const deadline = (key, callback) => namedDeadline({
|
|
117
|
+
storage: this.ctx.storage,
|
|
118
|
+
scheduler: this.#wake.scheduler,
|
|
119
|
+
key,
|
|
120
|
+
callback
|
|
121
|
+
});
|
|
122
|
+
this.#idle = deadline("sandbox:idle-id", "sandboxIdle");
|
|
123
|
+
this.#egressWatch = deadline("sandbox:egress-watch-id", "egressWatch");
|
|
124
|
+
this.#installPoll = deadline("sandbox:install-poll-id", "installPoll");
|
|
125
|
+
/**
|
|
126
|
+
* Started here rather than on first use. A request that wakes a fresh
|
|
127
|
+
* instance and reaches the container without starting the harness's
|
|
128
|
+
* lifecycle leaves it with no inactivity timeout — `onStart` is what re-arms
|
|
129
|
+
* it — and it stops within seconds of the object going quiet (observed). An
|
|
130
|
+
* egress lease or a pool read wakes the object exactly like that.
|
|
131
|
+
*/
|
|
132
|
+
void ctx.blockConcurrencyWhile(async () => {
|
|
133
|
+
this.#migrate();
|
|
134
|
+
await this.#wake.start();
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
#image() {
|
|
138
|
+
const key = this.#cfg.image ?? "app";
|
|
139
|
+
const image = this.#container.images[key];
|
|
140
|
+
if (!image)
|
|
141
|
+
throw new Error(`[${this.#cfg.label}] no image named ${key}`);
|
|
142
|
+
return image;
|
|
143
|
+
}
|
|
144
|
+
/** What the harness keys its workspace snapshot on, for a custom image. */
|
|
145
|
+
#snapshotKey() {
|
|
146
|
+
return `image:${this.#image()}`;
|
|
147
|
+
}
|
|
148
|
+
#migrate() {
|
|
149
|
+
const sql = this.ctx.storage.sql;
|
|
150
|
+
sql.exec(`CREATE TABLE IF NOT EXISTS sandbox_runs (
|
|
151
|
+
run_id TEXT PRIMARY KEY,
|
|
152
|
+
session_id TEXT NOT NULL,
|
|
153
|
+
cwd TEXT NOT NULL,
|
|
154
|
+
created_at INTEGER NOT NULL,
|
|
155
|
+
stopped_at INTEGER
|
|
156
|
+
)`);
|
|
157
|
+
// Which run submitted each operation: runs that share a session share its
|
|
158
|
+
// operations, and a stop is one run's.
|
|
159
|
+
sql.exec(`CREATE TABLE IF NOT EXISTS sandbox_operations (
|
|
160
|
+
operation_id TEXT PRIMARY KEY,
|
|
161
|
+
run_id TEXT NOT NULL
|
|
162
|
+
)`);
|
|
163
|
+
sql.exec(`CREATE TABLE IF NOT EXISTS sandbox_checkouts (
|
|
164
|
+
dir TEXT PRIMARY KEY,
|
|
165
|
+
kind TEXT NOT NULL,
|
|
166
|
+
repo TEXT,
|
|
167
|
+
backup TEXT,
|
|
168
|
+
noted_at INTEGER NOT NULL
|
|
169
|
+
)`);
|
|
170
|
+
}
|
|
171
|
+
// --- the container ---------------------------------------------------------
|
|
172
|
+
/**
|
|
173
|
+
* The container, running and ready for a command or a CLI turn.
|
|
174
|
+
*
|
|
175
|
+
* A stopped one is started by a `control:wake` prompt on the root session,
|
|
176
|
+
* which the daemon answers without a CLI: the harness starts containers only
|
|
177
|
+
* for a prompt. Then, once per container, the egress the harness does not
|
|
178
|
+
* register, and any checkout an image change left behind.
|
|
179
|
+
*/
|
|
180
|
+
async ensureRunning() {
|
|
181
|
+
await this.#touch();
|
|
182
|
+
if (!this.#container.running) {
|
|
183
|
+
const woke = await this.#harness
|
|
184
|
+
.session(CONTROL_SESSION)
|
|
185
|
+
.prompt(CONTROL_WAKE);
|
|
186
|
+
if (woke.status !== "done") {
|
|
187
|
+
throw new Error(`[${this.#cfg.label}] the container did not start: ${woke.reason ?? "no reason given"}`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const runtimeId = await this.#prepareRuntime();
|
|
191
|
+
return { runtimeId };
|
|
192
|
+
}
|
|
193
|
+
/** What only a new container needs, done once per runtime id. */
|
|
194
|
+
async #prepareRuntime() {
|
|
195
|
+
const { runtimeId } = await this.#harness.container();
|
|
196
|
+
if (!runtimeId)
|
|
197
|
+
throw new Error(`[${this.#cfg.label}] the harness has no container on record`);
|
|
198
|
+
if ((await this.ctx.storage.get(RUNTIME_READY_KEY)) === runtimeId)
|
|
199
|
+
return runtimeId;
|
|
200
|
+
this.#runtimePreparing ??= this.#prepare(runtimeId).finally(() => {
|
|
201
|
+
this.#runtimePreparing = undefined;
|
|
202
|
+
});
|
|
203
|
+
await this.#runtimePreparing;
|
|
204
|
+
return runtimeId;
|
|
205
|
+
}
|
|
206
|
+
async #prepare(runtimeId) {
|
|
207
|
+
const container = this.#container;
|
|
208
|
+
await this.#rootExec([
|
|
209
|
+
"sh",
|
|
210
|
+
"-c",
|
|
211
|
+
`mkdir -p ${RUN_DIR} && chown ${AGENT_UID}:${AGENT_GID} ${RUN_DIR}`
|
|
212
|
+
]);
|
|
213
|
+
const https = this.#egress.https;
|
|
214
|
+
if (https.length > 0) {
|
|
215
|
+
const fetcher = this.#cfg.egress.entrypoint({
|
|
216
|
+
props: { routes: [...https] }
|
|
217
|
+
});
|
|
218
|
+
for (const route of https)
|
|
219
|
+
await container.interceptOutboundHttps(route.host, fetcher);
|
|
220
|
+
const trust = await this.#rootExec(["sh", "-c", TRUST_CA_COMMAND]);
|
|
221
|
+
if (!/^TRUSTED$/m.test(trust.stdout)) {
|
|
222
|
+
throw new Error(`[${this.#cfg.label}] the interception CA was not trusted: ${trust.stdout.trim()}`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
await this.#restoreMissing();
|
|
226
|
+
// As the agent, whose directory it is: a write as root would follow a
|
|
227
|
+
// symlink the agent left there. A marker the agent forges only starts its
|
|
228
|
+
// own CLI early.
|
|
229
|
+
await this.#agentExec([
|
|
230
|
+
"sh",
|
|
231
|
+
"-c",
|
|
232
|
+
`printf %s ${shellQuote(runtimeId)} > ${EGRESS_READY}`
|
|
233
|
+
]);
|
|
234
|
+
await this.ctx.storage.put(RUNTIME_READY_KEY, runtimeId);
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* The harness starts containers on its own too — a follow-up after a lost one,
|
|
238
|
+
* a snapshot retry — and nothing here is told. While a run is open, this looks
|
|
239
|
+
* again every few seconds, so a CLI waiting on {@link EGRESS_READY} is not
|
|
240
|
+
* left to its timeout.
|
|
241
|
+
*/
|
|
242
|
+
async #onEgressWatch() {
|
|
243
|
+
if ((await this.#harness.pending()).length === 0)
|
|
244
|
+
return;
|
|
245
|
+
if (this.#container.running)
|
|
246
|
+
await this.#prepareRuntime().catch((err) => {
|
|
247
|
+
console.warn(`[${this.#cfg.label}] could not prepare a new container`, {
|
|
248
|
+
id: this.ctx.id.toString(),
|
|
249
|
+
err: String(err)
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
await this.#egressWatch.set(new Date(Date.now() + EGRESS_WATCH_MS));
|
|
253
|
+
}
|
|
254
|
+
/** A command as the agent, with no capabilities: the host's `env`, its own over that, and every egress rule's over both. */
|
|
255
|
+
async exec(argv, options = {}) {
|
|
256
|
+
await this.ensureRunning();
|
|
257
|
+
return this.#run(asAgent(argv, { ...this.#cfg.env, ...options.env, ...this.#egress.env }, options.cwd), options.timeoutMs ? { timeoutMs: options.timeoutMs } : {});
|
|
258
|
+
}
|
|
259
|
+
/** `work`, counted as running, so no stop starts under it. */
|
|
260
|
+
async #hold(work) {
|
|
261
|
+
this.#running += 1;
|
|
262
|
+
try {
|
|
263
|
+
return await work();
|
|
264
|
+
}
|
|
265
|
+
finally {
|
|
266
|
+
this.#running -= 1;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
/** Wait out a stop in progress. */
|
|
270
|
+
async #admit() {
|
|
271
|
+
while (this.#stopping)
|
|
272
|
+
await this.#stopping.catch(() => undefined);
|
|
273
|
+
if (this.#gone)
|
|
274
|
+
throw new Error(`[${this.#cfg.label}] this sandbox was just reclaimed: call it again`);
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* `stop`, with no other stop beside it and new work held until it is done.
|
|
278
|
+
* The check-then-set has no `await` between, so two cannot both pass.
|
|
279
|
+
*/
|
|
280
|
+
async #exclusive(stop) {
|
|
281
|
+
while (this.#stopping)
|
|
282
|
+
await this.#stopping.catch(() => undefined);
|
|
283
|
+
const run = stop();
|
|
284
|
+
this.#stopping = run;
|
|
285
|
+
try {
|
|
286
|
+
return await run;
|
|
287
|
+
}
|
|
288
|
+
finally {
|
|
289
|
+
if (this.#stopping === run)
|
|
290
|
+
this.#stopping = undefined;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
/** Why the container may not stop now, if it may not. */
|
|
294
|
+
async #busy() {
|
|
295
|
+
if ((await this.#harness.pending()).length > 0)
|
|
296
|
+
return "a run is still open";
|
|
297
|
+
if (this.#running > 0)
|
|
298
|
+
return "a command is still running";
|
|
299
|
+
const install = await this.ctx.storage.get(INSTALL_KEY);
|
|
300
|
+
if (install?.state === "running")
|
|
301
|
+
return "an install is still running";
|
|
302
|
+
return undefined;
|
|
303
|
+
}
|
|
304
|
+
async #rootExec(argv) {
|
|
305
|
+
return this.#run(argv, {});
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* As the agent, in a container already running. Anything that reads or
|
|
309
|
+
* writes a path the agent controls goes through here, never as root.
|
|
310
|
+
*/
|
|
311
|
+
async #agentExec(argv) {
|
|
312
|
+
return this.#run(asAgent(argv), {});
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* `argv` as the container's exec starts it, which is as root. No environment
|
|
316
|
+
* is passed here: an agent's travels in its argv, after the drop
|
|
317
|
+
* (`./agent-exec.ts`).
|
|
318
|
+
*/
|
|
319
|
+
async #run(argv, options) {
|
|
320
|
+
return this.#hold(async () => {
|
|
321
|
+
// No `cwd`: see `./agent-exec.ts`.
|
|
322
|
+
const process = await this.#container.exec([...argv], {
|
|
323
|
+
...(options.timeoutMs
|
|
324
|
+
? { signal: AbortSignal.timeout(options.timeoutMs) }
|
|
325
|
+
: {}),
|
|
326
|
+
stdout: "pipe",
|
|
327
|
+
stderr: "pipe"
|
|
328
|
+
});
|
|
329
|
+
const output = await process.output();
|
|
330
|
+
const decode = (bytes) => new TextDecoder().decode(bytes);
|
|
331
|
+
return {
|
|
332
|
+
exitCode: output.exitCode,
|
|
333
|
+
stdout: decode(output.stdout),
|
|
334
|
+
stderr: decode(output.stderr)
|
|
335
|
+
};
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
// --- files -----------------------------------------------------------------
|
|
339
|
+
#files() {
|
|
340
|
+
return new Files(this.#container);
|
|
341
|
+
}
|
|
342
|
+
#confine(path) {
|
|
343
|
+
if (!isUnder(path, SANDBOX_ROOT)) {
|
|
344
|
+
throw new Error(`${path} is outside ${SANDBOX_ROOT}, which is all a sandbox serves`);
|
|
345
|
+
}
|
|
346
|
+
return path;
|
|
347
|
+
}
|
|
348
|
+
async readFile(path) {
|
|
349
|
+
this.#confine(path);
|
|
350
|
+
await this.ensureRunning();
|
|
351
|
+
return this.#hold(async () => {
|
|
352
|
+
try {
|
|
353
|
+
const response = await this.#files().readFile(path, {
|
|
354
|
+
user: `${AGENT_UID}:${AGENT_GID}`
|
|
355
|
+
});
|
|
356
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
357
|
+
}
|
|
358
|
+
catch (err) {
|
|
359
|
+
if (isNotFound(err))
|
|
360
|
+
return null;
|
|
361
|
+
throw err;
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
async writeFile(path, content) {
|
|
366
|
+
this.#confine(path);
|
|
367
|
+
await this.ensureRunning();
|
|
368
|
+
await this.#hold(() => this.#files().writeFile(path, content, {
|
|
369
|
+
user: `${AGENT_UID}:${AGENT_GID}`
|
|
370
|
+
}));
|
|
371
|
+
}
|
|
372
|
+
async stat(path) {
|
|
373
|
+
this.#confine(path);
|
|
374
|
+
await this.ensureRunning();
|
|
375
|
+
return this.#hold(async () => {
|
|
376
|
+
try {
|
|
377
|
+
const stat = await this.#files().stat(path, {
|
|
378
|
+
user: `${AGENT_UID}:${AGENT_GID}`
|
|
379
|
+
});
|
|
380
|
+
return {
|
|
381
|
+
type: stat.type,
|
|
382
|
+
size: Number(stat.size),
|
|
383
|
+
modifiedAt: stat.modifiedAt.getTime()
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
catch (err) {
|
|
387
|
+
if (isNotFound(err))
|
|
388
|
+
return null;
|
|
389
|
+
throw err;
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
async readDir(path) {
|
|
394
|
+
this.#confine(path);
|
|
395
|
+
await this.ensureRunning();
|
|
396
|
+
return this.#hold(async () => (await this.#files().readDirectory(path, {
|
|
397
|
+
user: `${AGENT_UID}:${AGENT_GID}`
|
|
398
|
+
})).map((entry) => ({ name: entry.name, type: entry.type })));
|
|
399
|
+
}
|
|
400
|
+
async mkdir(path) {
|
|
401
|
+
this.#confine(path);
|
|
402
|
+
await this.ensureRunning();
|
|
403
|
+
await this.#hold(() => this.#files().mkdir(path, {
|
|
404
|
+
recursive: true,
|
|
405
|
+
user: `${AGENT_UID}:${AGENT_GID}`
|
|
406
|
+
}));
|
|
407
|
+
}
|
|
408
|
+
async rm(path, options = {}) {
|
|
409
|
+
this.#confine(path);
|
|
410
|
+
await this.ensureRunning();
|
|
411
|
+
await this.#hold(() => this.#files().remove(path, {
|
|
412
|
+
force: true,
|
|
413
|
+
...(options.recursive ? { recursive: true } : {}),
|
|
414
|
+
user: `${AGENT_UID}:${AGENT_GID}`
|
|
415
|
+
}));
|
|
416
|
+
}
|
|
417
|
+
// --- runs ------------------------------------------------------------------
|
|
418
|
+
/**
|
|
419
|
+
* The harness session a run lives in, made on first ask.
|
|
420
|
+
*
|
|
421
|
+
* The harness refuses a session id it did not mint, so a run's own id maps to
|
|
422
|
+
* one here. A run may instead carry on another run's conversation: `continues`
|
|
423
|
+
* shares its session, and `forkOf` branches a new one from it. Either only
|
|
424
|
+
* works inside one object, since the conversation's files are this one's, and
|
|
425
|
+
* only in the same `cwd`, since the CLI files a conversation by directory.
|
|
426
|
+
*/
|
|
427
|
+
async createRun(runId, options) {
|
|
428
|
+
this.#confine(options.cwd);
|
|
429
|
+
await this.#touch();
|
|
430
|
+
const existing = this.#runRow(runId);
|
|
431
|
+
if (existing)
|
|
432
|
+
return { sessionId: existing.session_id, created: false };
|
|
433
|
+
if (options.forkOf !== undefined && options.continues !== undefined)
|
|
434
|
+
throw new Error("a run either continues another run or forks it, not both");
|
|
435
|
+
const of = options.forkOf ?? options.continues;
|
|
436
|
+
let sessionId;
|
|
437
|
+
if (of !== undefined) {
|
|
438
|
+
const from = this.#runRow(of);
|
|
439
|
+
if (!from)
|
|
440
|
+
throw new Error(`no run ${of} in this sandbox to carry on`);
|
|
441
|
+
if (from.cwd !== options.cwd)
|
|
442
|
+
throw new Error(`run ${of} worked in ${from.cwd}, so a run carrying it on cannot work in ${options.cwd}`);
|
|
443
|
+
sessionId =
|
|
444
|
+
options.forkOf !== undefined
|
|
445
|
+
? (await this.#harness.sessions.fork(from.session_id)).id
|
|
446
|
+
: from.session_id;
|
|
447
|
+
}
|
|
448
|
+
else {
|
|
449
|
+
sessionId = (await this.#harness.sessions.create()).id;
|
|
450
|
+
}
|
|
451
|
+
this.ctx.storage.sql.exec("INSERT INTO sandbox_runs (run_id, session_id, cwd, created_at) VALUES (?, ?, ?, ?)", runId, sessionId, options.cwd, Date.now());
|
|
452
|
+
return { sessionId, created: true };
|
|
453
|
+
}
|
|
454
|
+
#runRow(runId) {
|
|
455
|
+
return this.ctx.storage.sql
|
|
456
|
+
.exec("SELECT session_id, cwd, stopped_at FROM sandbox_runs WHERE run_id = ?", runId)
|
|
457
|
+
.toArray()[0];
|
|
458
|
+
}
|
|
459
|
+
/** Record `operationId` as `runId`'s, before it is submitted; refuse one another run submitted. */
|
|
460
|
+
#own(runId, operationId) {
|
|
461
|
+
const sql = this.ctx.storage.sql;
|
|
462
|
+
sql.exec("INSERT OR IGNORE INTO sandbox_operations (operation_id, run_id) VALUES (?, ?)", operationId, runId);
|
|
463
|
+
const owner = sql
|
|
464
|
+
.exec("SELECT run_id FROM sandbox_operations WHERE operation_id = ?", operationId)
|
|
465
|
+
.one().run_id;
|
|
466
|
+
if (owner !== runId)
|
|
467
|
+
throw new Error(`operation ${operationId} is run ${owner}'s, so run ${runId} cannot submit it`);
|
|
468
|
+
}
|
|
469
|
+
/** Refuse a turn of a run that was stopped: it takes no more. */
|
|
470
|
+
#refuseStopped(runId) {
|
|
471
|
+
if (this.#runRow(runId)?.stopped_at != null)
|
|
472
|
+
throw new Error(`run ${runId} was stopped, so it takes no more turns`);
|
|
473
|
+
}
|
|
474
|
+
#session(runId) {
|
|
475
|
+
const row = this.#runRow(runId);
|
|
476
|
+
if (!row)
|
|
477
|
+
throw new Error(`no run ${runId} in this sandbox`);
|
|
478
|
+
return this.#harness.session(row.session_id);
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Durably submit one turn of a run. `operationId` is the caller's, so a
|
|
482
|
+
* retried submit is the same operation; a settled one comes back as it ended,
|
|
483
|
+
* which is why a caller resubmitting after a lost container uses a new one.
|
|
484
|
+
*
|
|
485
|
+
* Every turn runs in the run's `cwd`: a turn with no run header gets one, and
|
|
486
|
+
* a header naming another directory is refused.
|
|
487
|
+
*
|
|
488
|
+
* A stopped run takes no turn, checked before and after the turn is queued:
|
|
489
|
+
* a submission still starting the container when its run is stopped would
|
|
490
|
+
* otherwise start a CLI after the stop, with nobody left polling it.
|
|
491
|
+
*/
|
|
492
|
+
async submit(runId, text, operationId) {
|
|
493
|
+
const row = this.#runRow(runId);
|
|
494
|
+
if (!row)
|
|
495
|
+
throw new Error(`no run ${runId} in this sandbox`);
|
|
496
|
+
const { header, prompt } = readRunHeader(text);
|
|
497
|
+
if (header && header.cwd !== row.cwd) {
|
|
498
|
+
throw new Error(`run ${runId} works in ${row.cwd}, so a turn of it cannot run in ${header.cwd}`);
|
|
499
|
+
}
|
|
500
|
+
const turn = header ? text : withRunHeader({ cwd: row.cwd }, prompt);
|
|
501
|
+
this.#refuseStopped(runId);
|
|
502
|
+
this.#own(runId, operationId);
|
|
503
|
+
await this.ensureRunning();
|
|
504
|
+
this.#refuseStopped(runId);
|
|
505
|
+
const session = this.#harness.session(row.session_id);
|
|
506
|
+
const receipt = await this.#hold(() => session.submit(turn, { operationId }));
|
|
507
|
+
if (this.#runRow(runId)?.stopped_at != null) {
|
|
508
|
+
await session.abort(receipt.operationId);
|
|
509
|
+
throw new Error(`run ${runId} was stopped, so it takes no more turns`);
|
|
510
|
+
}
|
|
511
|
+
await this.#egressWatch.set(new Date(Date.now() + EGRESS_WATCH_MS));
|
|
512
|
+
return { operationId: receipt.operationId, accepted: receipt.accepted };
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* One operation's state, waiting up to `waitMs` for it to settle.
|
|
516
|
+
*
|
|
517
|
+
* Only stored messages reach here: the harness keeps a run's messages and
|
|
518
|
+
* nothing it streamed live. Those of this operation only, so a forked run does
|
|
519
|
+
* not report its parent's history, each with a digest, because a message is
|
|
520
|
+
* replaced in place as its tool calls finish.
|
|
521
|
+
*
|
|
522
|
+
* `from` skips that many of them, so a long run is not sent whole on every
|
|
523
|
+
* poll; pass the last poll's `next`. A message replaced behind `from` is not
|
|
524
|
+
* sent again, so a caller passes it only for an adapter that never replaces
|
|
525
|
+
* one.
|
|
526
|
+
*/
|
|
527
|
+
async poll(runId, operationId, waitMs = POLL_WAIT_MS, from = 0) {
|
|
528
|
+
await this.#touch();
|
|
529
|
+
const session = this.#session(runId);
|
|
530
|
+
let result;
|
|
531
|
+
try {
|
|
532
|
+
result = await session.wait(operationId, AbortSignal.timeout(Math.min(waitMs, POLL_WAIT_MS)));
|
|
533
|
+
}
|
|
534
|
+
catch (err) {
|
|
535
|
+
if (!isAbort(err))
|
|
536
|
+
throw err;
|
|
537
|
+
}
|
|
538
|
+
if (!result && this.#container.running)
|
|
539
|
+
await this.#prepareRuntime().catch(() => undefined);
|
|
540
|
+
const own = (await session.messages()).filter((message) => message.operationId === operationId && message.role === "assistant");
|
|
541
|
+
const messages = [];
|
|
542
|
+
for (const message of own.slice(from)) {
|
|
543
|
+
messages.push({
|
|
544
|
+
id: message.id,
|
|
545
|
+
digest: await digest(message.parts),
|
|
546
|
+
message
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
return {
|
|
550
|
+
contract: SANDBOX_CONTRACT,
|
|
551
|
+
...(result ? { result } : {}),
|
|
552
|
+
messages,
|
|
553
|
+
next: Math.max(from, own.length)
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* Stop a run and let its CLI finish leaving.
|
|
558
|
+
*
|
|
559
|
+
* The harness settles an abort at once, while the CLI is still exiting — a
|
|
560
|
+
* SIGINT, a SIGKILL a few seconds later, then one last sync of its session
|
|
561
|
+
* files. Committing or stopping before that loses what it wrote last, or
|
|
562
|
+
* races git's index lock. So this waits for no CLI to be left, and one sync.
|
|
563
|
+
*
|
|
564
|
+
* A stopped run takes no more turns. Its conversation goes on, if it does, in
|
|
565
|
+
* a run that `continues` it — whose operations share the session, so only
|
|
566
|
+
* this run's own are aborted.
|
|
567
|
+
*/
|
|
568
|
+
async stopRun(runId) {
|
|
569
|
+
await this.#touch();
|
|
570
|
+
const row = this.#runRow(runId);
|
|
571
|
+
if (!row)
|
|
572
|
+
throw new Error(`no run ${runId} in this sandbox`);
|
|
573
|
+
const sql = this.ctx.storage.sql;
|
|
574
|
+
// Marked first, so a submission of it still on its way in refuses itself.
|
|
575
|
+
sql.exec("UPDATE sandbox_runs SET stopped_at = ? WHERE run_id = ? AND stopped_at IS NULL", Date.now(), runId);
|
|
576
|
+
const own = new Set(sql
|
|
577
|
+
.exec("SELECT operation_id FROM sandbox_operations WHERE run_id = ?", runId)
|
|
578
|
+
.toArray()
|
|
579
|
+
.map((each) => each.operation_id));
|
|
580
|
+
const session = this.#harness.session(row.session_id);
|
|
581
|
+
let stopped = false;
|
|
582
|
+
for (const open of await this.#harness.pending({
|
|
583
|
+
session: row.session_id
|
|
584
|
+
})) {
|
|
585
|
+
if (own.has(open.operationId))
|
|
586
|
+
stopped = (await session.abort(open.operationId)) || stopped;
|
|
587
|
+
}
|
|
588
|
+
if (this.#container.running) {
|
|
589
|
+
const until = Date.now() + CLI_KILL_MS + 10_000;
|
|
590
|
+
while (Date.now() < until && (await this.#cliAlive()))
|
|
591
|
+
await sleep(500);
|
|
592
|
+
await sleep(CLI_SYNC_MS);
|
|
593
|
+
}
|
|
594
|
+
return { stopped };
|
|
595
|
+
}
|
|
596
|
+
async #cliAlive() {
|
|
597
|
+
const probe = await this.#rootExec([
|
|
598
|
+
"sh",
|
|
599
|
+
"-c",
|
|
600
|
+
// `[n]` so the pattern does not match this probe's own command line.
|
|
601
|
+
"for p in /proc/[0-9]*; do tr '\\0' ' ' < $p/cmdline 2>/dev/null | grep -q -- '--output-format stream-jso[n]' && exit 0; done; exit 1"
|
|
602
|
+
]);
|
|
603
|
+
return probe.exitCode === 0;
|
|
604
|
+
}
|
|
605
|
+
// --- snapshots and backups ---------------------------------------------------
|
|
606
|
+
/**
|
|
607
|
+
* Snapshot the container now, as the start the harness will make next.
|
|
608
|
+
*
|
|
609
|
+
* The harness snapshots only when it stops a container, so one that dies mid-run
|
|
610
|
+
* starts from whatever it last stopped as. Writing its record is what makes a
|
|
611
|
+
* checkpoint the next start; the contract spec pins that it reads it.
|
|
612
|
+
*/
|
|
613
|
+
async checkpoint() {
|
|
614
|
+
if (!this.#container.running)
|
|
615
|
+
return undefined;
|
|
616
|
+
const snapshot = await this.#container.snapshotContainer({
|
|
617
|
+
name: `checkpoint-${Date.now()}`
|
|
618
|
+
});
|
|
619
|
+
const seed = {
|
|
620
|
+
key: this.#snapshotKey(),
|
|
621
|
+
snapshot: { id: snapshot.id }
|
|
622
|
+
};
|
|
623
|
+
await this.ctx.storage.put(HARNESS_WORKSPACE_KEY, {
|
|
624
|
+
...seed,
|
|
625
|
+
usedAt: Date.now()
|
|
626
|
+
});
|
|
627
|
+
await this.ctx.storage.put(CHECKPOINT_KEY, seed);
|
|
628
|
+
return seed;
|
|
629
|
+
}
|
|
630
|
+
/** The last checkpoint, for another sandbox of this class to start from. */
|
|
631
|
+
async snapshotRecord() {
|
|
632
|
+
return this.ctx.storage.get(CHECKPOINT_KEY);
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* Start this sandbox's next container from another's checkpoint.
|
|
636
|
+
*
|
|
637
|
+
* Refused while it runs — the record is read at a start — and for a snapshot
|
|
638
|
+
* of another image, which a container cannot start from.
|
|
639
|
+
*/
|
|
640
|
+
async seedFrom(seed) {
|
|
641
|
+
await this.#touch();
|
|
642
|
+
if (this.#container.running)
|
|
643
|
+
return { seeded: false, reason: "the container is running" };
|
|
644
|
+
if (seed.key !== this.#snapshotKey()) {
|
|
645
|
+
return { seeded: false, reason: "the snapshot is of another image" };
|
|
646
|
+
}
|
|
647
|
+
await this.ctx.storage.put(HARNESS_WORKSPACE_KEY, {
|
|
648
|
+
...seed,
|
|
649
|
+
usedAt: Date.now()
|
|
650
|
+
});
|
|
651
|
+
return { seeded: true };
|
|
652
|
+
}
|
|
653
|
+
#backups() {
|
|
654
|
+
const backups = this.#cfg.backups;
|
|
655
|
+
if (!backups)
|
|
656
|
+
return undefined;
|
|
657
|
+
return new DirectoryBackup(this.#container, backups.gateway, {
|
|
658
|
+
binding: backups.binding,
|
|
659
|
+
...(backups.prefix ? { prefix: backups.prefix } : {})
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* Every checkout on this disk to R2, replacing its last backup. Only a marked
|
|
664
|
+
* one: an unmarked directory is the image's, and backing it up would replace
|
|
665
|
+
* the checkout's last backup with nothing.
|
|
666
|
+
*/
|
|
667
|
+
async #backupAll() {
|
|
668
|
+
const backups = this.#backups();
|
|
669
|
+
if (!backups)
|
|
670
|
+
return;
|
|
671
|
+
for (const row of this.#checkouts()) {
|
|
672
|
+
if (!(await this.#marked(row.dir)))
|
|
673
|
+
continue;
|
|
674
|
+
const record = await backups.backup({
|
|
675
|
+
dir: row.dir,
|
|
676
|
+
exclude: ["**/node_modules"]
|
|
677
|
+
});
|
|
678
|
+
this.ctx.storage.sql.exec("UPDATE sandbox_checkouts SET backup = ? WHERE dir = ?", JSON.stringify(record), row.dir);
|
|
679
|
+
if (row.backup) {
|
|
680
|
+
await backups
|
|
681
|
+
.delete(JSON.parse(row.backup))
|
|
682
|
+
.catch(() => undefined);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* Bring back any recorded checkout a start lost — an image change drops the
|
|
688
|
+
* snapshot, markers with it — and reinstall what came back without its
|
|
689
|
+
* `node_modules`.
|
|
690
|
+
*/
|
|
691
|
+
async #restoreMissing() {
|
|
692
|
+
const backups = this.#backups();
|
|
693
|
+
if (!backups)
|
|
694
|
+
return;
|
|
695
|
+
for (const row of this.#checkouts()) {
|
|
696
|
+
if (!row.backup || (await this.#marked(row.dir)))
|
|
697
|
+
continue;
|
|
698
|
+
await backups.restore(JSON.parse(row.backup));
|
|
699
|
+
await this.#mark(row.dir);
|
|
700
|
+
console.info(`[${this.#cfg.label}] restored a checkout from its backup`, {
|
|
701
|
+
id: this.ctx.id.toString(),
|
|
702
|
+
dir: row.dir
|
|
703
|
+
});
|
|
704
|
+
if (row.kind === "repo") {
|
|
705
|
+
await this.#beginInstall({
|
|
706
|
+
dir: row.dir,
|
|
707
|
+
...(row.repo ? { repo: row.repo } : {})
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
#mark(dir) {
|
|
713
|
+
return this.#rootExec([
|
|
714
|
+
"sh",
|
|
715
|
+
"-c",
|
|
716
|
+
`mkdir -p ${CHECKOUT_MARKS} && printf %s ${shellQuote(dir)} > ${shellQuote(markOf(dir))}`
|
|
717
|
+
]);
|
|
718
|
+
}
|
|
719
|
+
async #marked(dir) {
|
|
720
|
+
return (await this.#rootExec(["test", "-f", markOf(dir)])).exitCode === 0;
|
|
721
|
+
}
|
|
722
|
+
/**
|
|
723
|
+
* What `dir`'s last finished install was for, while the `node_modules` it
|
|
724
|
+
* made is the one there: a tree removed and made again — by an install that
|
|
725
|
+
* failed part-way, say — is another directory, and its mark is not its own.
|
|
726
|
+
* Asked as the agent, who can read the mark and not write it:
|
|
727
|
+
* `node_modules` is the agent's, and may be a symlink.
|
|
728
|
+
*/
|
|
729
|
+
async #installedAs(dir) {
|
|
730
|
+
const read = await this.#agentExec([
|
|
731
|
+
"sh",
|
|
732
|
+
"-c",
|
|
733
|
+
`stat -c %i ${shellQuote(`${dir}/node_modules`)} && cat ${shellQuote(installMarkOf(dir))}`
|
|
734
|
+
]);
|
|
735
|
+
if (read.exitCode !== 0)
|
|
736
|
+
return undefined;
|
|
737
|
+
const [tree = "", mark = ""] = read.stdout.split("\n");
|
|
738
|
+
const space = mark.indexOf(" ");
|
|
739
|
+
return space > 0 && mark.slice(0, space) === tree.trim()
|
|
740
|
+
? mark.slice(space + 1).trim()
|
|
741
|
+
: undefined;
|
|
742
|
+
}
|
|
743
|
+
/** The `node_modules` an install left in `dir`, by its inode. */
|
|
744
|
+
async #treeOf(dir) {
|
|
745
|
+
const read = await this.#agentExec([
|
|
746
|
+
"stat",
|
|
747
|
+
"-c",
|
|
748
|
+
"%i",
|
|
749
|
+
`${dir}/node_modules`
|
|
750
|
+
]);
|
|
751
|
+
return read.exitCode === 0 ? read.stdout.trim() : undefined;
|
|
752
|
+
}
|
|
753
|
+
/**
|
|
754
|
+
* Back up and stop the container. Its disk is the next start, as a snapshot.
|
|
755
|
+
*
|
|
756
|
+
* Refused while anything is working — an open run, a command, an install:
|
|
757
|
+
* stopping the harness re-wakes every open session, which starts the
|
|
758
|
+
* container again. Work that arrives meanwhile waits for the stop, then
|
|
759
|
+
* starts a new container.
|
|
760
|
+
*/
|
|
761
|
+
async release() {
|
|
762
|
+
return this.#exclusive(async () => {
|
|
763
|
+
const busy = await this.#busy();
|
|
764
|
+
if (busy)
|
|
765
|
+
return { released: false, reason: busy };
|
|
766
|
+
if (this.#container.running) {
|
|
767
|
+
await this.#backupAll();
|
|
768
|
+
await this.#harness.stop();
|
|
769
|
+
}
|
|
770
|
+
await this.#idle.clear();
|
|
771
|
+
return { released: true };
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
/** Mark a use — after any stop, so a reclaim cannot erase what this call writes. */
|
|
775
|
+
async #touch() {
|
|
776
|
+
await this.#admit();
|
|
777
|
+
const now = Date.now();
|
|
778
|
+
await this.ctx.storage.put(LAST_USED_KEY, now);
|
|
779
|
+
await this.#idle.set(new Date(now + (this.#cfg.idleStopMs ?? IDLE_STOP_MS)));
|
|
780
|
+
}
|
|
781
|
+
/** Stop a container nobody is using. Anything still working moves the deadline instead. */
|
|
782
|
+
async #onIdle() {
|
|
783
|
+
const released = await this.release();
|
|
784
|
+
if (!released.released) {
|
|
785
|
+
await this.#idle.set(new Date(Date.now() + (this.#cfg.idleStopMs ?? IDLE_STOP_MS)));
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
// --- checkouts and the install -------------------------------------------------
|
|
789
|
+
async noteCheckout(record) {
|
|
790
|
+
this.#confine(record.dir);
|
|
791
|
+
this.ctx.storage.sql.exec(`INSERT INTO sandbox_checkouts (dir, kind, repo, noted_at) VALUES (?, ?, ?, ?)
|
|
792
|
+
ON CONFLICT(dir) DO UPDATE SET kind = excluded.kind, repo = excluded.repo, noted_at = excluded.noted_at`, record.dir, record.kind, record.repo ?? null, Date.now());
|
|
793
|
+
// Noted once it exists, so the mark is what says this disk holds it.
|
|
794
|
+
const present = this.#container.running &&
|
|
795
|
+
(await this.#rootExec(["test", "-d", record.dir])).exitCode === 0;
|
|
796
|
+
if (present)
|
|
797
|
+
await this.#mark(record.dir);
|
|
798
|
+
return { dir: record.dir, present };
|
|
799
|
+
}
|
|
800
|
+
/**
|
|
801
|
+
* The newest recorded checkout, a repository's or a scratchpad's. Starts no
|
|
802
|
+
* container and marks no use, so a sweep asking every sandbox does not keep
|
|
803
|
+
* them alive: a sandbox answers from its record, since a start restores what
|
|
804
|
+
* its disk lost, until a running container is prepared — then only if its
|
|
805
|
+
* `.git` is there.
|
|
806
|
+
*/
|
|
807
|
+
async checkoutDir() {
|
|
808
|
+
const row = this.ctx.storage.sql
|
|
809
|
+
.exec("SELECT dir FROM sandbox_checkouts ORDER BY noted_at DESC LIMIT 1")
|
|
810
|
+
.toArray()[0];
|
|
811
|
+
if (!row)
|
|
812
|
+
return undefined;
|
|
813
|
+
// Until this container is prepared, a start may still be restoring what
|
|
814
|
+
// its disk lost: the record is the answer then too.
|
|
815
|
+
if (!this.#container.running)
|
|
816
|
+
return row.dir;
|
|
817
|
+
const { runtimeId } = await this.#harness.container();
|
|
818
|
+
if (!runtimeId ||
|
|
819
|
+
(await this.ctx.storage.get(RUNTIME_READY_KEY)) !== runtimeId)
|
|
820
|
+
return row.dir;
|
|
821
|
+
const present = await this.#agentExec(["test", "-d", `${row.dir}/.git`]);
|
|
822
|
+
return present.exitCode === 0 ? row.dir : undefined;
|
|
823
|
+
}
|
|
824
|
+
#checkouts() {
|
|
825
|
+
return this.ctx.storage.sql
|
|
826
|
+
.exec("SELECT dir, kind, repo, backup FROM sandbox_checkouts")
|
|
827
|
+
.toArray();
|
|
828
|
+
}
|
|
829
|
+
/**
|
|
830
|
+
* Install a checkout's dependencies, in the background.
|
|
831
|
+
*
|
|
832
|
+
* The command runs detached in the container and writes its exit code to a
|
|
833
|
+
* file, which the alarm reads: a container process outlives the request that
|
|
834
|
+
* started it, and the file outlives the object that is waiting for it.
|
|
835
|
+
*/
|
|
836
|
+
async startInstall(target) {
|
|
837
|
+
this.#confine(target.dir);
|
|
838
|
+
await this.ensureRunning();
|
|
839
|
+
return this.#beginInstall(target);
|
|
840
|
+
}
|
|
841
|
+
/**
|
|
842
|
+
* The install, in a container already prepared. One at a time: package
|
|
843
|
+
* managers in one tree race, and the checkpoint after the last must not
|
|
844
|
+
* catch another still writing. A second checkout waits its turn.
|
|
845
|
+
*/
|
|
846
|
+
async #beginInstall(target) {
|
|
847
|
+
const current = await this.ctx.storage.get(INSTALL_KEY);
|
|
848
|
+
if (current?.state === "running") {
|
|
849
|
+
const armed = await this.ctx.storage.get(INSTALL_ARMED_KEY);
|
|
850
|
+
const queue = (await this.ctx.storage.get(INSTALL_QUEUE_KEY)) ?? [];
|
|
851
|
+
if (armed?.dir !== target.dir &&
|
|
852
|
+
!queue.some((queued) => queued.dir === target.dir))
|
|
853
|
+
await this.ctx.storage.put(INSTALL_QUEUE_KEY, [...queue, target]);
|
|
854
|
+
return current;
|
|
855
|
+
}
|
|
856
|
+
const probe = this.#probe();
|
|
857
|
+
const resolution = await resolveInstallCommand(probe, target.dir, this.#cfg.installPlan, target.repo);
|
|
858
|
+
if (resolution.kind === "skip") {
|
|
859
|
+
const skipped = {
|
|
860
|
+
state: "skipped",
|
|
861
|
+
reason: resolution.reason
|
|
862
|
+
};
|
|
863
|
+
await this.ctx.storage.put(INSTALL_KEY, skipped);
|
|
864
|
+
await this.#nextInstall(false);
|
|
865
|
+
return skipped;
|
|
866
|
+
}
|
|
867
|
+
// What this disk already holds needs no install: a fetch that moved no
|
|
868
|
+
// dependency, or a tree a seed brought.
|
|
869
|
+
const files = await installFingerprint(probe, target.dir, resolution);
|
|
870
|
+
// The environment the install runs under shapes what it installs —
|
|
871
|
+
// `NODE_ENV` drops the dev dependencies — so it is part of what a tree is for.
|
|
872
|
+
const fingerprint = files === null ? null : `${files}:${await digestEnv(this.#cfg.env)}`;
|
|
873
|
+
if (fingerprint !== null &&
|
|
874
|
+
(await this.#installedAs(target.dir)) === fingerprint) {
|
|
875
|
+
const now = Date.now();
|
|
876
|
+
const current = {
|
|
877
|
+
state: "done",
|
|
878
|
+
command: resolution.command,
|
|
879
|
+
exitCode: 0,
|
|
880
|
+
finishedAt: now,
|
|
881
|
+
ms: 0
|
|
882
|
+
};
|
|
883
|
+
await this.ctx.storage.put(INSTALL_KEY, current);
|
|
884
|
+
await this.#nextInstall(false);
|
|
885
|
+
return current;
|
|
886
|
+
}
|
|
887
|
+
// Unmarked while it runs, so an install cut short leaves no tree that
|
|
888
|
+
// passes for a finished one.
|
|
889
|
+
await this.#rootExec(["rm", "-f", installMarkOf(target.dir)]);
|
|
890
|
+
const timeoutMs = this.#cfg.installPlan.timeoutMs ?? DEFAULT_INSTALL_PLAN.timeoutMs;
|
|
891
|
+
const job = `${Date.now().toString(36)}`;
|
|
892
|
+
const { exit, log } = installFiles(job);
|
|
893
|
+
// `timeout` ends a hung install; the deadline below settles one whose shell
|
|
894
|
+
// was killed before it could write its exit.
|
|
895
|
+
const script = `cd ${shellQuote(target.dir)} && ` +
|
|
896
|
+
`{ timeout --kill-after=10 ${Math.ceil(timeoutMs / 1000)} sh -c ${shellQuote(resolution.command)}; } > ${log} 2>&1; ` +
|
|
897
|
+
`echo $? > ${exit}`;
|
|
898
|
+
// The host's environment and no rule's: an install reaches what it reaches
|
|
899
|
+
// anonymously, as it would anywhere else.
|
|
900
|
+
await this.#container.exec(asAgent(["sh", "-c", script], this.#cfg.env), {
|
|
901
|
+
stdout: "ignore",
|
|
902
|
+
stderr: "ignore"
|
|
903
|
+
});
|
|
904
|
+
const running = {
|
|
905
|
+
state: "running",
|
|
906
|
+
command: resolution.command,
|
|
907
|
+
startedAt: Date.now()
|
|
908
|
+
};
|
|
909
|
+
await this.ctx.storage.put(INSTALL_KEY, running);
|
|
910
|
+
await this.ctx.storage.put(INSTALL_ARMED_KEY, {
|
|
911
|
+
...target,
|
|
912
|
+
job,
|
|
913
|
+
fingerprint,
|
|
914
|
+
limitMs: timeoutMs,
|
|
915
|
+
runtimeId: (await this.#harness.container()).runtimeId ?? ""
|
|
916
|
+
});
|
|
917
|
+
await this.#installPoll.set(new Date(Date.now() + INSTALL_POLL_MS));
|
|
918
|
+
return running;
|
|
919
|
+
}
|
|
920
|
+
/** Start the next queued install, or — the queue empty after a good one — checkpoint. */
|
|
921
|
+
async #nextInstall(succeeded) {
|
|
922
|
+
// Owed until the queue's end, whatever the installs after this one do: one
|
|
923
|
+
// that finds its tree current installs nothing, and must not drop it.
|
|
924
|
+
if (succeeded)
|
|
925
|
+
await this.ctx.storage.put(CHECKPOINT_OWED_KEY, true);
|
|
926
|
+
const [next, ...rest] = (await this.ctx.storage.get(INSTALL_QUEUE_KEY)) ?? [];
|
|
927
|
+
if (next) {
|
|
928
|
+
await this.ctx.storage.put(INSTALL_QUEUE_KEY, rest);
|
|
929
|
+
await this.#beginInstall(next);
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
// What a later start begins from, node_modules included.
|
|
933
|
+
if (await this.ctx.storage.get(CHECKPOINT_OWED_KEY)) {
|
|
934
|
+
await this.checkpoint();
|
|
935
|
+
await this.ctx.storage.delete(CHECKPOINT_OWED_KEY);
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
async #onInstallPoll() {
|
|
939
|
+
const state = await this.ctx.storage.get(INSTALL_KEY);
|
|
940
|
+
if (state?.state !== "running")
|
|
941
|
+
return;
|
|
942
|
+
const armed = await this.ctx.storage.get(INSTALL_ARMED_KEY);
|
|
943
|
+
const fail = async (error, tail) => {
|
|
944
|
+
await this.ctx.storage.put(INSTALL_KEY, {
|
|
945
|
+
state: "failed",
|
|
946
|
+
command: state.command,
|
|
947
|
+
error,
|
|
948
|
+
finishedAt: Date.now(),
|
|
949
|
+
...(tail ? { tail } : {})
|
|
950
|
+
});
|
|
951
|
+
// The container is gone, and the queue with it: a new one restores and
|
|
952
|
+
// installs again from its own start.
|
|
953
|
+
if (!this.#container.running) {
|
|
954
|
+
await this.ctx.storage.delete(INSTALL_QUEUE_KEY);
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
await this.#nextInstall(false);
|
|
958
|
+
};
|
|
959
|
+
if (!this.#container.running || !armed) {
|
|
960
|
+
await fail("the container stopped while it installed");
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
// Another container is not running it: the disk it was writing is gone
|
|
964
|
+
// with the one that was, so it runs again here.
|
|
965
|
+
if ((await this.#harness.container()).runtimeId !== armed.runtimeId) {
|
|
966
|
+
await this.ctx.storage.put(INSTALL_KEY, {
|
|
967
|
+
state: "failed",
|
|
968
|
+
command: state.command,
|
|
969
|
+
error: "the container it ran in was replaced",
|
|
970
|
+
finishedAt: Date.now()
|
|
971
|
+
});
|
|
972
|
+
await this.#beginInstall({
|
|
973
|
+
dir: armed.dir,
|
|
974
|
+
...(armed.repo ? { repo: armed.repo } : {})
|
|
975
|
+
});
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
const { exit: exitFile, log } = installFiles(armed.job);
|
|
979
|
+
// As the agent: the files are in its directory, and a root read would
|
|
980
|
+
// follow a symlink it left there into a file only root may read.
|
|
981
|
+
const done = await this.#agentExec([
|
|
982
|
+
"sh",
|
|
983
|
+
"-c",
|
|
984
|
+
`cat ${exitFile} 2>/dev/null; echo; tail -c 2000 ${log} 2>/dev/null`
|
|
985
|
+
]);
|
|
986
|
+
const [exit = "", ...tail] = done.stdout.split("\n");
|
|
987
|
+
if (exit.trim() === "") {
|
|
988
|
+
if (Date.now() > state.startedAt + armed.limitMs + INSTALL_GRACE_MS) {
|
|
989
|
+
await fail("the install was killed before it could report how it ended", tail.join("\n").trim());
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
await this.#installPoll.set(new Date(Date.now() + INSTALL_POLL_MS));
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
const exitCode = Number(exit.trim());
|
|
996
|
+
const finishedAt = Date.now();
|
|
997
|
+
if (exitCode !== 0) {
|
|
998
|
+
await fail(exitCode === 124
|
|
999
|
+
? `ran past its limit of ${humanMs(armed.limitMs)}`
|
|
1000
|
+
: `exited ${exitCode}`, tail.join("\n").trim());
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
1003
|
+
const tree = armed.fingerprint ? await this.#treeOf(armed.dir) : undefined;
|
|
1004
|
+
if (armed.fingerprint && tree) {
|
|
1005
|
+
await this.#rootExec([
|
|
1006
|
+
"sh",
|
|
1007
|
+
"-c",
|
|
1008
|
+
`mkdir -p ${INSTALL_MARKS} && printf '%s %s' ${shellQuote(tree)} ${shellQuote(armed.fingerprint)} > ${shellQuote(installMarkOf(armed.dir))}`
|
|
1009
|
+
]);
|
|
1010
|
+
}
|
|
1011
|
+
await this.ctx.storage.put(INSTALL_KEY, {
|
|
1012
|
+
state: "done",
|
|
1013
|
+
command: state.command,
|
|
1014
|
+
exitCode,
|
|
1015
|
+
finishedAt,
|
|
1016
|
+
ms: finishedAt - state.startedAt
|
|
1017
|
+
});
|
|
1018
|
+
await this.#nextInstall(true);
|
|
1019
|
+
}
|
|
1020
|
+
async advisories() {
|
|
1021
|
+
const install = (await this.ctx.storage.get(INSTALL_KEY)) ?? {
|
|
1022
|
+
state: "idle"
|
|
1023
|
+
};
|
|
1024
|
+
const armed = await this.ctx.storage.get(INSTALL_ARMED_KEY);
|
|
1025
|
+
const treePresent = armed !== undefined &&
|
|
1026
|
+
this.#container.running &&
|
|
1027
|
+
(await this.#rootExec(["test", "-d", `${armed.dir}/node_modules`]))
|
|
1028
|
+
.exitCode === 0;
|
|
1029
|
+
return deriveAdvisories({ install, dependencyTreePresent: treePresent });
|
|
1030
|
+
}
|
|
1031
|
+
#probe() {
|
|
1032
|
+
return {
|
|
1033
|
+
exists: async (path) => (await this.#rootExec(["test", "-e", path])).exitCode === 0,
|
|
1034
|
+
// As the agent: a checkout's files are its own, and may be symlinks.
|
|
1035
|
+
readFile: async (path) => {
|
|
1036
|
+
const read = await this.#agentExec(["cat", path]);
|
|
1037
|
+
if (read.exitCode !== 0)
|
|
1038
|
+
throw Object.assign(new Error(`ENOENT: ${path}`), { code: "ENOENT" });
|
|
1039
|
+
return read.stdout;
|
|
1040
|
+
}
|
|
1041
|
+
};
|
|
1042
|
+
}
|
|
1043
|
+
// --- reclaim and debug ---------------------------------------------------------
|
|
1044
|
+
/** Empty a sandbox nobody has used in `maxIdleMs`: backups, storage and all. */
|
|
1045
|
+
async reclaimIfIdle(maxIdleMs) {
|
|
1046
|
+
return this.#exclusive(() => this.#reclaim(maxIdleMs));
|
|
1047
|
+
}
|
|
1048
|
+
async #reclaim(maxIdleMs) {
|
|
1049
|
+
const lastUsed = (await this.ctx.storage.get(LAST_USED_KEY)) ?? 0;
|
|
1050
|
+
const idleMs = Date.now() - lastUsed;
|
|
1051
|
+
if (lastUsed === 0 || idleMs < maxIdleMs)
|
|
1052
|
+
return { reclaimed: false, idleMs };
|
|
1053
|
+
// Last use is when work began, so what is still working is asked directly.
|
|
1054
|
+
if (await this.#busy())
|
|
1055
|
+
return { reclaimed: false, idleMs };
|
|
1056
|
+
if (this.#container.running)
|
|
1057
|
+
await this.#container.destroy("reclaimed");
|
|
1058
|
+
const backups = this.#backups();
|
|
1059
|
+
for (const row of this.#checkouts()) {
|
|
1060
|
+
if (backups && row.backup) {
|
|
1061
|
+
await backups
|
|
1062
|
+
.delete(JSON.parse(row.backup))
|
|
1063
|
+
.catch(() => undefined);
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
this.#gone = true;
|
|
1067
|
+
await this.ctx.storage.deleteAlarm();
|
|
1068
|
+
await this.ctx.storage.deleteAll();
|
|
1069
|
+
// After the reply: this instance still holds the state just deleted.
|
|
1070
|
+
setTimeout(() => this.ctx.abort("sandbox reclaimed"), 0);
|
|
1071
|
+
return { reclaimed: true, idleMs };
|
|
1072
|
+
}
|
|
1073
|
+
/** For acceptance runs only, behind a secret: reset this object, destroy its container, or run as root. */
|
|
1074
|
+
async debug(secret, op) {
|
|
1075
|
+
const binding = this.#cfg.debugSecretBinding;
|
|
1076
|
+
const expected = binding
|
|
1077
|
+
? this.env[binding]
|
|
1078
|
+
: undefined;
|
|
1079
|
+
if (typeof expected !== "string" ||
|
|
1080
|
+
expected === "" ||
|
|
1081
|
+
secret !== expected) {
|
|
1082
|
+
throw new Error("debug is not enabled for this sandbox");
|
|
1083
|
+
}
|
|
1084
|
+
switch (op.kind) {
|
|
1085
|
+
case "reset":
|
|
1086
|
+
setTimeout(() => this.ctx.abort("debug reset"), 0);
|
|
1087
|
+
return { ok: true };
|
|
1088
|
+
case "destroy":
|
|
1089
|
+
await this.#container.destroy("debug destroy");
|
|
1090
|
+
return { ok: true };
|
|
1091
|
+
case "root-exec":
|
|
1092
|
+
return this.#rootExec(op.argv);
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
function installFiles(job) {
|
|
1097
|
+
return {
|
|
1098
|
+
exit: `${RUN_DIR}/install-${job}.exit`,
|
|
1099
|
+
log: `${RUN_DIR}/install-${job}.log`
|
|
1100
|
+
};
|
|
1101
|
+
}
|
|
1102
|
+
function markOf(dir) {
|
|
1103
|
+
return `${CHECKOUT_MARKS}/${encodeURIComponent(dir)}`;
|
|
1104
|
+
}
|
|
1105
|
+
function installMarkOf(dir) {
|
|
1106
|
+
return `${INSTALL_MARKS}/${encodeURIComponent(dir)}`;
|
|
1107
|
+
}
|
|
1108
|
+
/** `env`, in key order, as a digest a fingerprint can carry. */
|
|
1109
|
+
async function digestEnv(env) {
|
|
1110
|
+
const sorted = Object.entries(env ?? {}).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
1111
|
+
const bytes = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify(sorted)));
|
|
1112
|
+
return [...new Uint8Array(bytes)]
|
|
1113
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
1114
|
+
.join("")
|
|
1115
|
+
.slice(0, 16);
|
|
1116
|
+
}
|
|
1117
|
+
function isNotFound(err) {
|
|
1118
|
+
return (typeof err === "object" &&
|
|
1119
|
+
err !== null &&
|
|
1120
|
+
"code" in err &&
|
|
1121
|
+
err.code === "ENOENT");
|
|
1122
|
+
}
|
|
1123
|
+
function isAbort(err) {
|
|
1124
|
+
return (err instanceof Error &&
|
|
1125
|
+
(err.name === "AbortError" ||
|
|
1126
|
+
err.name === "TimeoutError" ||
|
|
1127
|
+
/abort/i.test(err.message)));
|
|
1128
|
+
}
|
|
1129
|
+
function sleep(ms) {
|
|
1130
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1131
|
+
}
|
|
1132
|
+
async function digest(value) {
|
|
1133
|
+
const bytes = new TextEncoder().encode(JSON.stringify(value));
|
|
1134
|
+
const hash = await crypto.subtle.digest("SHA-256", bytes);
|
|
1135
|
+
return [...new Uint8Array(hash)]
|
|
1136
|
+
.slice(0, 12)
|
|
1137
|
+
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
1138
|
+
.join("");
|
|
1139
|
+
}
|