@sigrun/sigrun 0.1.6 → 0.2.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/CHANGELOG.md +104 -0
- package/README.md +352 -36
- package/dist/blocks/schema.js +9 -2
- package/dist/blocks/validate.js +57 -8
- package/dist/cli.js +26 -2
- package/dist/config.js +67 -1
- package/dist/extension.d.ts +189 -15
- package/dist/harness/ambient.js +39 -185
- package/dist/harness/blocks/entries.js +47 -0
- package/dist/harness/blocks/state.js +58 -0
- package/dist/harness/blocks/tools.js +274 -0
- package/dist/harness/boot.js +253 -20
- package/dist/harness/builtins.js +15 -11
- package/dist/harness/event-bus.js +105 -10
- package/dist/harness/identity/accounts.js +186 -0
- package/dist/harness/identity/groups.js +14 -0
- package/dist/harness/identity/index.js +13 -6
- package/dist/harness/identity/json-store.js +15 -4
- package/dist/harness/identity/setup-code-check.js +9 -0
- package/dist/harness/identity/tokens.js +28 -5
- package/dist/harness/identity/users.js +111 -12
- package/dist/harness/interactions.js +292 -136
- package/dist/harness/loader.js +271 -17
- package/dist/harness/packages.js +1 -1
- package/dist/harness/reserved-sections.js +21 -0
- package/dist/harness/seam-host.js +80 -6
- package/dist/harness/settings.js +63 -2
- package/dist/harness/stores.js +241 -4
- package/dist/harness/user-services.js +391 -0
- package/dist/harness/user-tools.js +187 -0
- package/dist/harness/workspace.js +61 -57
- package/dist/jobs/fire.js +10 -16
- package/dist/sandbox/availability.js +162 -0
- package/dist/sandbox/command-observer.js +59 -0
- package/dist/sandbox/grants.js +159 -0
- package/dist/sandbox/helper.js +29 -0
- package/dist/sandbox/isolation.js +29 -0
- package/dist/sandbox/launch.js +20 -0
- package/dist/sandbox/os-account.js +49 -0
- package/dist/sandbox/policy.js +253 -0
- package/dist/sandbox/pool.js +548 -0
- package/dist/sandbox/protocol.js +167 -0
- package/dist/sandbox/sandboxed-env.js +245 -0
- package/dist/sandbox/unavailable-env.js +49 -0
- package/dist/sandbox/worker.js +281 -0
- package/dist/serve.js +82 -7
- package/dist/server/auth/access-principal.js +16 -0
- package/dist/server/auth/pipeline.js +47 -33
- package/dist/server/blocks/answers.js +94 -0
- package/dist/server/blocks/chat-surfaces.js +393 -0
- package/dist/server/blocks/notes.js +216 -0
- package/dist/server/blocks/summaries.js +328 -0
- package/dist/server/commands.js +5 -0
- package/dist/server/content-parts-lazy.js +13 -0
- package/dist/server/conversation-handler.js +2 -5
- package/dist/server/conversation-ops.js +138 -37
- package/dist/server/helpers.js +18 -6
- package/dist/server/host-bridge.js +40 -20
- package/dist/server/images/cache.js +205 -0
- package/dist/server/images/convert.js +13 -0
- package/dist/server/images/fetch.js +302 -0
- package/dist/server/images/ranges.js +42 -0
- package/dist/server/images/rewrite.js +94 -0
- package/dist/server/images/sign.js +84 -0
- package/dist/server/images/sniff.js +62 -0
- package/dist/server/index.js +144 -4
- package/dist/server/login/flows.js +126 -0
- package/dist/server/login/http.js +31 -0
- package/dist/server/login/resolve.js +133 -0
- package/dist/server/login/setup-claim.js +55 -0
- package/dist/server/login/usernames.js +15 -0
- package/dist/server/ownership.js +13 -0
- package/dist/server/routes/admin.js +58 -0
- package/dist/server/routes/auth.js +88 -2
- package/dist/server/routes/conversations.js +3 -6
- package/dist/server/routes/events.js +28 -3
- package/dist/server/routes/extensions.js +37 -5
- package/dist/server/routes/images.js +267 -0
- package/dist/server/routes/interactions.js +57 -68
- package/dist/server/routes/jobs.js +2 -8
- package/dist/server/routes/login.js +223 -0
- package/dist/server/routes/me.js +90 -0
- package/dist/server/routes/meta.js +15 -0
- package/dist/server/routes/onboarding.js +94 -84
- package/dist/server/routes/settings-sections.js +190 -0
- package/dist/server/routes/settings.js +16 -4
- package/dist/server/routes/submissions.js +1 -1
- package/dist/server/routes/surfaces.js +175 -23
- package/dist/server/routes/users.js +116 -20
- package/dist/server/schema-fixture.js +213 -0
- package/dist/server/session-meta.js +3 -0
- package/dist/server/shutdown.js +11 -4
- package/dist/server/static.js +18 -0
- package/dist/server/surfaces/dedup.js +9 -2
- package/dist/server/surfaces/service.js +181 -13
- package/dist/server/surfaces/settings-surfaces.js +597 -0
- package/dist/server/turns/submit-turn.js +26 -0
- package/dist/server/views.js +16 -0
- package/dist/settings/core-sections.js +532 -0
- package/dist/settings/pending.js +181 -0
- package/dist/settings/registry.js +453 -0
- package/dist/skills/index.js +6 -0
- package/node_modules/@sigrun/shared/CHANGELOG.md +6 -0
- package/node_modules/@sigrun/shared/dist/access.d.ts +4 -2
- package/node_modules/@sigrun/shared/dist/access.js +11 -4
- package/node_modules/@sigrun/shared/dist/ambient.d.ts +17 -74
- package/node_modules/@sigrun/shared/dist/ambient.js +8 -6
- package/node_modules/@sigrun/shared/dist/blocks/text.js +16 -2
- package/node_modules/@sigrun/shared/dist/blocks/transport.d.ts +4 -0
- package/node_modules/@sigrun/shared/dist/blocks/types.d.ts +21 -4
- package/node_modules/@sigrun/shared/dist/interactions.d.ts +22 -48
- package/node_modules/@sigrun/shared/dist/interactions.js +9 -7
- package/node_modules/@sigrun/shared/dist/settings.d.ts +27 -2
- package/node_modules/@sigrun/shared/dist/themes.d.ts +2 -3
- package/node_modules/@sigrun/shared/dist/themes.js +2 -3
- package/node_modules/@sigrun/shared/dist/turns.d.ts +14 -0
- package/node_modules/@sigrun/shared/dist/turns.js +53 -3
- package/node_modules/@sigrun/shared/dist/views.d.ts +175 -12
- package/node_modules/@sigrun/shared/dist/views.js +16 -0
- package/node_modules/@sigrun/shared/package.json +1 -1
- package/package.json +11 -6
- package/webui/dist/assets/index-CxB969qp.js +55 -0
- package/webui/dist/assets/index-yqkL6BSS.css +1 -0
- package/webui/dist/index.html +2 -2
- package/webui/dist/assets/index-CiYInlLx.css +0 -1
- package/webui/dist/assets/index-JDfOQV9z.js +0 -52
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
/** Per-user worker lifecycle and the client side of the stdio protocol. */
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { ExecutionError, FileError } from "@earendil-works/pi-durable/env";
|
|
6
|
+
import { decodeError, decodeMessage, decodeValue, encodeMessage, encodeValue, } from "./protocol.js";
|
|
7
|
+
import { launchInJail } from "./launch.js";
|
|
8
|
+
function crashError(op, user, detail) {
|
|
9
|
+
const message = `sandbox worker for ${user} exited (${detail})`;
|
|
10
|
+
// The M1 protocol names these transport failures "crashed" and "io".
|
|
11
|
+
// pi-durable 1.3.0 has not added those codes to its public unions yet;
|
|
12
|
+
// keep the protocol codes visible to callers rather than hiding the crash
|
|
13
|
+
// behind a generic success or an unrelated spawn error.
|
|
14
|
+
return op === "exec"
|
|
15
|
+
? new ExecutionError("crashed", message)
|
|
16
|
+
: new FileError("io", message);
|
|
17
|
+
}
|
|
18
|
+
class WorkerProcess {
|
|
19
|
+
client;
|
|
20
|
+
child;
|
|
21
|
+
exit;
|
|
22
|
+
capabilities;
|
|
23
|
+
buffer = "";
|
|
24
|
+
nextRequest = 1;
|
|
25
|
+
pending = new Map();
|
|
26
|
+
watchEvents = new Map();
|
|
27
|
+
watchHandles = new Map();
|
|
28
|
+
commandGroups = new Set();
|
|
29
|
+
drainedResolve;
|
|
30
|
+
drained;
|
|
31
|
+
stopPromise;
|
|
32
|
+
exited = false;
|
|
33
|
+
retiring = false;
|
|
34
|
+
constructor(client, launch) {
|
|
35
|
+
this.client = client;
|
|
36
|
+
this.child = spawn(launch.command, launch.args, {
|
|
37
|
+
env: launch.env,
|
|
38
|
+
// Own a process group: pi-durable's commands are detached too, so
|
|
39
|
+
// stopping only the worker can orphan them. Group cleanup reaches
|
|
40
|
+
// the command groups below this worker, including after SIGKILL.
|
|
41
|
+
detached: process.platform !== "win32",
|
|
42
|
+
// Exactly three descriptors: pi-durable gives every command its own
|
|
43
|
+
// pipes, so neither side of this IPC channel reaches a command.
|
|
44
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
45
|
+
windowsHide: true,
|
|
46
|
+
});
|
|
47
|
+
this.child.stdout.setEncoding("utf8");
|
|
48
|
+
this.child.stdout.on("data", (chunk) => this.receive(chunk));
|
|
49
|
+
this.child.stderr.setEncoding("utf8");
|
|
50
|
+
let stderr = "";
|
|
51
|
+
this.child.stderr.on("data", (chunk) => {
|
|
52
|
+
stderr += chunk;
|
|
53
|
+
for (;;) {
|
|
54
|
+
const newline = stderr.indexOf("\n");
|
|
55
|
+
if (newline < 0)
|
|
56
|
+
break;
|
|
57
|
+
const line = stderr.slice(0, newline).trimEnd();
|
|
58
|
+
stderr = stderr.slice(newline + 1);
|
|
59
|
+
if (line !== "")
|
|
60
|
+
this.client.log(`sandbox[${this.client.user}] ${line}`);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
this.exit = new Promise((resolve) => {
|
|
64
|
+
this.child.once("error", () => { this.exited = true; });
|
|
65
|
+
this.child.once("exit", (code, signal) => {
|
|
66
|
+
this.exited = true;
|
|
67
|
+
// A direct crash cannot run the worker's stdin-close cleanup.
|
|
68
|
+
// The negative pid addresses the worker's whole process group;
|
|
69
|
+
// ESRCH simply means every member is already gone.
|
|
70
|
+
this.killGroup("SIGKILL");
|
|
71
|
+
this.failAll(code, signal);
|
|
72
|
+
this.client.processExited(this);
|
|
73
|
+
resolve();
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
this.capabilities = this.request("hello", []).then((value) => {
|
|
77
|
+
// The canonical hello shape is {capabilities: {listFiles}}.
|
|
78
|
+
return { listFiles: value.capabilities.listFiles === true };
|
|
79
|
+
});
|
|
80
|
+
// A caller will observe this through its first operation; attaching a
|
|
81
|
+
// rejection sink here prevents a failed boot becoming unhandled.
|
|
82
|
+
void this.capabilities.catch(() => { });
|
|
83
|
+
}
|
|
84
|
+
get running() {
|
|
85
|
+
return !this.exited && this.child.exitCode === null && this.child.signalCode === null;
|
|
86
|
+
}
|
|
87
|
+
get inFlight() {
|
|
88
|
+
return this.pending.size;
|
|
89
|
+
}
|
|
90
|
+
commandGroupsSnapshot() {
|
|
91
|
+
return [...this.commandGroups];
|
|
92
|
+
}
|
|
93
|
+
request(op, args, onEvent, context, failureOp = op) {
|
|
94
|
+
if (!this.running)
|
|
95
|
+
return Promise.reject(crashError(op, this.client.user, "not running"));
|
|
96
|
+
if (context?.abortSignal?.aborted) {
|
|
97
|
+
return Promise.reject(op === "exec"
|
|
98
|
+
? new ExecutionError("aborted", "aborted")
|
|
99
|
+
: new FileError("aborted", "aborted"));
|
|
100
|
+
}
|
|
101
|
+
const id = this.nextRequest++;
|
|
102
|
+
return new Promise((resolve, reject) => {
|
|
103
|
+
if (op === "handle.close" && typeof args[0] === "number") {
|
|
104
|
+
const watchRequest = this.watchHandles.get(args[0]);
|
|
105
|
+
if (watchRequest !== undefined) {
|
|
106
|
+
this.watchEvents.delete(watchRequest);
|
|
107
|
+
this.watchHandles.delete(args[0]);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const pending = {
|
|
111
|
+
op: failureOp,
|
|
112
|
+
watch: op === "result" && args[0] === "watch",
|
|
113
|
+
resolve: (value) => resolve(value),
|
|
114
|
+
reject,
|
|
115
|
+
onEvent,
|
|
116
|
+
context,
|
|
117
|
+
};
|
|
118
|
+
this.pending.set(id, pending);
|
|
119
|
+
if (context?.abortSignal !== undefined) {
|
|
120
|
+
const signal = context.abortSignal;
|
|
121
|
+
const abort = () => {
|
|
122
|
+
if (!this.pending.has(id) || !this.running)
|
|
123
|
+
return;
|
|
124
|
+
this.child.stdin.write(encodeMessage({ id: 0, op: "abort", args: [id] }));
|
|
125
|
+
};
|
|
126
|
+
pending.onAbort = abort;
|
|
127
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
128
|
+
}
|
|
129
|
+
this.child.stdin.write(encodeMessage({ id, op, args }), (error) => {
|
|
130
|
+
if (error !== null && error !== undefined)
|
|
131
|
+
this.settleError(id, error);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
markRetiring() {
|
|
136
|
+
this.retiring = true;
|
|
137
|
+
}
|
|
138
|
+
async drainAndStop() {
|
|
139
|
+
if (!this.running)
|
|
140
|
+
return;
|
|
141
|
+
// A stop during a policy change drains calls already handed to the
|
|
142
|
+
// worker; it never races their still-running commands.
|
|
143
|
+
if (this.inFlight > 0) {
|
|
144
|
+
this.drained ??= new Promise((resolve) => { this.drainedResolve = resolve; });
|
|
145
|
+
await this.drained;
|
|
146
|
+
}
|
|
147
|
+
await this.stop();
|
|
148
|
+
}
|
|
149
|
+
stop() {
|
|
150
|
+
if (this.stopPromise !== undefined)
|
|
151
|
+
return this.stopPromise;
|
|
152
|
+
this.stopPromise = (async () => {
|
|
153
|
+
if (!this.running) {
|
|
154
|
+
this.killGroup("SIGKILL");
|
|
155
|
+
await this.exit;
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const killTimer = setTimeout(() => {
|
|
159
|
+
// The worker may be wedged and unable to run its own cleanup;
|
|
160
|
+
// kill its group, never just the Node process.
|
|
161
|
+
this.killGroup("SIGKILL");
|
|
162
|
+
}, 2_000);
|
|
163
|
+
killTimer.unref();
|
|
164
|
+
try {
|
|
165
|
+
// Let the worker's stdin-close hook run pi-durable cleanup for
|
|
166
|
+
// tracked command groups before the forced group-kill fallback.
|
|
167
|
+
this.child.stdin.end();
|
|
168
|
+
await this.exit;
|
|
169
|
+
}
|
|
170
|
+
finally {
|
|
171
|
+
clearTimeout(killTimer);
|
|
172
|
+
this.killGroup("SIGKILL");
|
|
173
|
+
}
|
|
174
|
+
})();
|
|
175
|
+
return this.stopPromise;
|
|
176
|
+
}
|
|
177
|
+
killGroup(signal) {
|
|
178
|
+
const pid = this.child.pid;
|
|
179
|
+
if (pid === undefined || process.platform === "win32") {
|
|
180
|
+
this.child.kill(signal);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
// pi-durable puts each command in a NEW process group, so killing only
|
|
184
|
+
// -workerPid cannot reach it. The worker reports actual spawned pids;
|
|
185
|
+
// kill each command's group as well as the worker's group. ESRCH means
|
|
186
|
+
// a group already exited.
|
|
187
|
+
const groups = new Set();
|
|
188
|
+
for (const commandPid of this.commandGroups)
|
|
189
|
+
groups.add(commandPid);
|
|
190
|
+
this.commandGroups.clear();
|
|
191
|
+
groups.add(pid);
|
|
192
|
+
for (const group of groups) {
|
|
193
|
+
try {
|
|
194
|
+
process.kill(-group, signal);
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
if (error.code !== "ESRCH")
|
|
198
|
+
throw error;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
receive(chunk) {
|
|
203
|
+
this.buffer += chunk;
|
|
204
|
+
for (;;) {
|
|
205
|
+
const newline = this.buffer.indexOf("\n");
|
|
206
|
+
if (newline < 0)
|
|
207
|
+
break;
|
|
208
|
+
const line = this.buffer.slice(0, newline);
|
|
209
|
+
this.buffer = this.buffer.slice(newline + 1);
|
|
210
|
+
if (line === "")
|
|
211
|
+
continue;
|
|
212
|
+
let message;
|
|
213
|
+
try {
|
|
214
|
+
message = decodeMessage(line);
|
|
215
|
+
}
|
|
216
|
+
catch (error) {
|
|
217
|
+
this.client.log(`sandbox[${this.client.user}] invalid worker message: ${error instanceof Error ? error.message : String(error)}`);
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
const pending = this.pending.get(message.id);
|
|
221
|
+
if ("event" in message) {
|
|
222
|
+
if (message.event === "command") {
|
|
223
|
+
if (message.phase === "start")
|
|
224
|
+
this.commandGroups.add(message.pid);
|
|
225
|
+
else
|
|
226
|
+
this.commandGroups.delete(message.pid);
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
const listener = pending?.onEvent ?? this.watchEvents.get(message.id);
|
|
230
|
+
if (listener === undefined)
|
|
231
|
+
continue;
|
|
232
|
+
try {
|
|
233
|
+
listener(message);
|
|
234
|
+
}
|
|
235
|
+
catch (error) {
|
|
236
|
+
if (pending !== undefined)
|
|
237
|
+
this.settleError(message.id, error);
|
|
238
|
+
else
|
|
239
|
+
this.client.log(`sandbox[${this.client.user}] watcher callback failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
240
|
+
}
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
if (pending === undefined)
|
|
244
|
+
continue;
|
|
245
|
+
this.pending.delete(message.id);
|
|
246
|
+
this.removeAbort(pending);
|
|
247
|
+
if (message.ok) {
|
|
248
|
+
try {
|
|
249
|
+
const value = decodeValue(message.value);
|
|
250
|
+
this.client.valueDecoded(value);
|
|
251
|
+
if (pending.watch && pending.onEvent !== undefined) {
|
|
252
|
+
this.watchEvents.set(message.id, pending.onEvent);
|
|
253
|
+
const handle = this.findHandle(value);
|
|
254
|
+
if (handle !== undefined)
|
|
255
|
+
this.watchHandles.set(handle, message.id);
|
|
256
|
+
}
|
|
257
|
+
pending.resolve(value);
|
|
258
|
+
}
|
|
259
|
+
catch (error) {
|
|
260
|
+
pending.reject(error instanceof Error ? error : new Error(String(error)));
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
pending.reject(decodeError(message.error));
|
|
265
|
+
}
|
|
266
|
+
this.maybeDrained();
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
findHandle(value) {
|
|
270
|
+
if (Array.isArray(value)) {
|
|
271
|
+
for (const item of value) {
|
|
272
|
+
const found = this.findHandle(item);
|
|
273
|
+
if (found !== undefined)
|
|
274
|
+
return found;
|
|
275
|
+
}
|
|
276
|
+
return undefined;
|
|
277
|
+
}
|
|
278
|
+
if (typeof value !== "object" || value === null || value instanceof Uint8Array)
|
|
279
|
+
return undefined;
|
|
280
|
+
if ("$handle" in value)
|
|
281
|
+
return typeof value.$handle === "number" ? value.$handle : undefined;
|
|
282
|
+
for (const item of Object.values(value)) {
|
|
283
|
+
const found = this.findHandle(item);
|
|
284
|
+
if (found !== undefined)
|
|
285
|
+
return found;
|
|
286
|
+
}
|
|
287
|
+
return undefined;
|
|
288
|
+
}
|
|
289
|
+
settleError(id, error) {
|
|
290
|
+
const pending = this.pending.get(id);
|
|
291
|
+
if (pending === undefined)
|
|
292
|
+
return;
|
|
293
|
+
this.pending.delete(id);
|
|
294
|
+
this.removeAbort(pending);
|
|
295
|
+
pending.reject(error instanceof Error ? error : new Error(String(error)));
|
|
296
|
+
this.maybeDrained();
|
|
297
|
+
}
|
|
298
|
+
removeAbort(pending) {
|
|
299
|
+
if (pending.onAbort !== undefined) {
|
|
300
|
+
pending.context?.abortSignal?.removeEventListener("abort", pending.onAbort);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
failAll(code, signal) {
|
|
304
|
+
const detail = signal ?? `code ${code ?? "unknown"}`;
|
|
305
|
+
for (const [id, pending] of this.pending) {
|
|
306
|
+
this.removeAbort(pending);
|
|
307
|
+
pending.reject(crashError(pending.op, this.client.user, detail));
|
|
308
|
+
this.pending.delete(id);
|
|
309
|
+
}
|
|
310
|
+
this.drainedResolve?.();
|
|
311
|
+
this.drainedResolve = undefined;
|
|
312
|
+
this.watchEvents.clear();
|
|
313
|
+
this.watchHandles.clear();
|
|
314
|
+
this.client.clearHandles();
|
|
315
|
+
}
|
|
316
|
+
maybeDrained() {
|
|
317
|
+
if (this.inFlight === 0) {
|
|
318
|
+
this.drainedResolve?.();
|
|
319
|
+
this.drainedResolve = undefined;
|
|
320
|
+
this.client.callSettled();
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
export class WorkerClient {
|
|
325
|
+
pool;
|
|
326
|
+
user;
|
|
327
|
+
policy;
|
|
328
|
+
policyJson;
|
|
329
|
+
process;
|
|
330
|
+
replacement;
|
|
331
|
+
idleTimer;
|
|
332
|
+
handles = new Set();
|
|
333
|
+
closed = false;
|
|
334
|
+
constructor(policy, pool) {
|
|
335
|
+
this.pool = pool;
|
|
336
|
+
this.user = policy.user;
|
|
337
|
+
this.policy = policy;
|
|
338
|
+
this.policyJson = JSON.stringify(policy);
|
|
339
|
+
}
|
|
340
|
+
/** @internal Pool-only policy change notification. */
|
|
341
|
+
updatePolicy(policy) {
|
|
342
|
+
const json = JSON.stringify(policy);
|
|
343
|
+
if (json === this.policyJson)
|
|
344
|
+
return;
|
|
345
|
+
this.policy = policy;
|
|
346
|
+
this.policyJson = json;
|
|
347
|
+
this.process?.markRetiring();
|
|
348
|
+
// Start the handoff now, but do not spawn until the old process has
|
|
349
|
+
// drained. The next call joins this same promise.
|
|
350
|
+
if (this.process !== undefined)
|
|
351
|
+
void this.ensureProcess().catch(() => { });
|
|
352
|
+
// Retiring workers are owned by the drain path, never the idle clock.
|
|
353
|
+
this.clearIdle();
|
|
354
|
+
}
|
|
355
|
+
log(line) {
|
|
356
|
+
this.pool.log(line);
|
|
357
|
+
}
|
|
358
|
+
async call(op, args, onEvent, context) {
|
|
359
|
+
const crashOp = op === "result" && args[0] === "exec" ? "exec" : op;
|
|
360
|
+
if (this.closed)
|
|
361
|
+
throw crashError(crashOp, this.user, "client stopped");
|
|
362
|
+
this.clearIdle();
|
|
363
|
+
const process = await this.ensureProcess();
|
|
364
|
+
try {
|
|
365
|
+
const result = await process.request(op, args, onEvent, context, crashOp);
|
|
366
|
+
return encodeValue(result);
|
|
367
|
+
}
|
|
368
|
+
finally {
|
|
369
|
+
this.callSettled();
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
/** Decode a wire failure into pi-durable's typed error (test/tool seam). */
|
|
373
|
+
async decodeError(reply) {
|
|
374
|
+
if (reply.ok)
|
|
375
|
+
throw new Error("not an error reply");
|
|
376
|
+
throw decodeError(reply.error);
|
|
377
|
+
}
|
|
378
|
+
capabilities() {
|
|
379
|
+
return this.ensureProcess().then((process) => process.capabilities);
|
|
380
|
+
}
|
|
381
|
+
openHandles() {
|
|
382
|
+
return this.handles.size;
|
|
383
|
+
}
|
|
384
|
+
/** Read-only snapshot of command groups currently tracked for crash cleanup. */
|
|
385
|
+
trackedCommandGroups() {
|
|
386
|
+
return [...(this.process?.commandGroupsSnapshot() ?? [])];
|
|
387
|
+
}
|
|
388
|
+
/** Current process id without starting a replacement (lifecycle probe). */
|
|
389
|
+
get pid() {
|
|
390
|
+
return this.process?.running ? this.process.child.pid : undefined;
|
|
391
|
+
}
|
|
392
|
+
valueDecoded(value) {
|
|
393
|
+
this.collectHandles(value);
|
|
394
|
+
this.armIdle();
|
|
395
|
+
}
|
|
396
|
+
collectHandles(value) {
|
|
397
|
+
if (Array.isArray(value)) {
|
|
398
|
+
for (const item of value)
|
|
399
|
+
this.collectHandles(item);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
if (typeof value !== "object" ||
|
|
403
|
+
value === null ||
|
|
404
|
+
value instanceof Uint8Array)
|
|
405
|
+
return;
|
|
406
|
+
if ("$handle" in value) {
|
|
407
|
+
const id = value.$handle;
|
|
408
|
+
if (typeof id === "number")
|
|
409
|
+
this.handles.add(id);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
for (const item of Object.values(value))
|
|
413
|
+
this.collectHandles(item);
|
|
414
|
+
}
|
|
415
|
+
hasHandle(id) {
|
|
416
|
+
return this.handles.has(id);
|
|
417
|
+
}
|
|
418
|
+
releaseHandle(id) {
|
|
419
|
+
this.handles.delete(id);
|
|
420
|
+
this.armIdle();
|
|
421
|
+
}
|
|
422
|
+
clearHandles() {
|
|
423
|
+
this.handles.clear();
|
|
424
|
+
}
|
|
425
|
+
processExited(process) {
|
|
426
|
+
if (this.process === process)
|
|
427
|
+
this.armIdle();
|
|
428
|
+
}
|
|
429
|
+
callSettled() {
|
|
430
|
+
this.armIdle();
|
|
431
|
+
}
|
|
432
|
+
close() {
|
|
433
|
+
if (this.closed)
|
|
434
|
+
return;
|
|
435
|
+
this.closed = true;
|
|
436
|
+
this.clearIdle();
|
|
437
|
+
this.clearHandles();
|
|
438
|
+
const process = this.process;
|
|
439
|
+
if (process !== undefined)
|
|
440
|
+
void process.stop();
|
|
441
|
+
}
|
|
442
|
+
async stop() {
|
|
443
|
+
this.close();
|
|
444
|
+
await this.replacement?.catch(() => { });
|
|
445
|
+
await this.process?.stop();
|
|
446
|
+
}
|
|
447
|
+
async ensureProcess() {
|
|
448
|
+
if (this.closed)
|
|
449
|
+
throw new Error(`sandbox client for ${this.user} is stopped`);
|
|
450
|
+
if (this.process?.running && !this.process.retiring)
|
|
451
|
+
return this.process;
|
|
452
|
+
if (this.replacement !== undefined)
|
|
453
|
+
return this.replacement;
|
|
454
|
+
this.replacement = (async () => {
|
|
455
|
+
const previous = this.process;
|
|
456
|
+
if (previous !== undefined)
|
|
457
|
+
await previous.drainAndStop();
|
|
458
|
+
if (this.closed)
|
|
459
|
+
throw new Error(`sandbox client for ${this.user} is stopped`);
|
|
460
|
+
const launch = this.pool.launcher(this.policy);
|
|
461
|
+
const process = new WorkerProcess(this, launch);
|
|
462
|
+
this.process = process;
|
|
463
|
+
await process.capabilities;
|
|
464
|
+
return process;
|
|
465
|
+
})().finally(() => {
|
|
466
|
+
this.replacement = undefined;
|
|
467
|
+
});
|
|
468
|
+
return this.replacement;
|
|
469
|
+
}
|
|
470
|
+
clearIdle() {
|
|
471
|
+
if (this.idleTimer !== undefined)
|
|
472
|
+
clearTimeout(this.idleTimer);
|
|
473
|
+
this.idleTimer = undefined;
|
|
474
|
+
}
|
|
475
|
+
armIdle() {
|
|
476
|
+
this.clearIdle();
|
|
477
|
+
if (this.closed ||
|
|
478
|
+
this.handles.size > 0 ||
|
|
479
|
+
this.process === undefined ||
|
|
480
|
+
!this.process.running ||
|
|
481
|
+
this.process.retiring ||
|
|
482
|
+
this.process.inFlight > 0)
|
|
483
|
+
return;
|
|
484
|
+
const minutes = this.pool.idleMinutes();
|
|
485
|
+
if (!Number.isFinite(minutes) || minutes <= 0)
|
|
486
|
+
return;
|
|
487
|
+
this.idleTimer = setTimeout(() => {
|
|
488
|
+
const process = this.process;
|
|
489
|
+
if (process !== undefined && process.inFlight === 0 && this.handles.size === 0) {
|
|
490
|
+
process.markRetiring();
|
|
491
|
+
void process.stop();
|
|
492
|
+
}
|
|
493
|
+
}, minutes * 60_000);
|
|
494
|
+
this.idleTimer.unref();
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
export class WorkerPool {
|
|
498
|
+
opts;
|
|
499
|
+
clients = new Map();
|
|
500
|
+
constructor(opts) {
|
|
501
|
+
this.opts = opts;
|
|
502
|
+
}
|
|
503
|
+
client(policy) {
|
|
504
|
+
let client = this.clients.get(policy.user);
|
|
505
|
+
if (client === undefined) {
|
|
506
|
+
client = new WorkerClient(policy, this.opts);
|
|
507
|
+
this.clients.set(policy.user, client);
|
|
508
|
+
}
|
|
509
|
+
else {
|
|
510
|
+
client.updatePolicy(policy);
|
|
511
|
+
}
|
|
512
|
+
return client;
|
|
513
|
+
}
|
|
514
|
+
async stopUser(user) {
|
|
515
|
+
const client = this.clients.get(user);
|
|
516
|
+
if (client === undefined)
|
|
517
|
+
return;
|
|
518
|
+
this.clients.delete(user);
|
|
519
|
+
await client.stop();
|
|
520
|
+
}
|
|
521
|
+
async stopAll() {
|
|
522
|
+
const clients = [...this.clients.values()];
|
|
523
|
+
this.clients.clear();
|
|
524
|
+
await Promise.all(clients.map((client) => client.stop()));
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
/** Prefer the built sibling; source checkouts run the TypeScript entry via tsx. */
|
|
528
|
+
export function resolveWorkerEntry(coreRoot) {
|
|
529
|
+
const built = join(import.meta.dirname, "worker.js");
|
|
530
|
+
if (existsSync(built))
|
|
531
|
+
return { command: process.execPath, args: [built] };
|
|
532
|
+
return {
|
|
533
|
+
command: process.execPath,
|
|
534
|
+
args: ["--import", "tsx", join(coreRoot, "src", "sandbox", "worker.ts")],
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
/** The OS-independent test launcher: no helper, only the worker entry. */
|
|
538
|
+
export function testLauncher(workerEntry) {
|
|
539
|
+
return (policy) => ({
|
|
540
|
+
command: workerEntry.command,
|
|
541
|
+
args: [...workerEntry.args],
|
|
542
|
+
env: { ...policy.env },
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
/** The production launcher applies Landlock before exec'ing the Node worker. */
|
|
546
|
+
export function sandboxLauncher(helper, workerEntry) {
|
|
547
|
+
return (policy) => launchInJail(helper, policy, workerEntry.command, workerEntry.args, policy.env);
|
|
548
|
+
}
|