@sigrun/sigrun 0.1.6 → 0.2.1
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 +192 -0
- package/README.md +445 -42
- package/dist/blocks/build-stamp.json +13 -0
- package/dist/blocks/builders.d.ts +57 -0
- package/dist/blocks/images.d.ts +26 -0
- package/dist/blocks/index.d.ts +20 -0
- package/dist/blocks/limits.d.ts +25 -0
- package/dist/blocks/placements.d.ts +26 -0
- package/dist/blocks/schema.d.ts +38 -0
- package/dist/blocks/schema.js +9 -2
- package/dist/blocks/validate.d.ts +73 -0
- package/dist/blocks/validate.js +57 -8
- package/dist/cli.js +26 -2
- package/dist/config.js +67 -1
- package/dist/extension.d.ts +230 -22
- 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 +259 -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 +92 -6
- package/dist/harness/sections/context-files.js +1 -1
- 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 +211 -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 +31 -0
- package/dist/sandbox/os-account.js +49 -0
- package/dist/sandbox/policy.js +274 -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 +261 -0
- package/dist/serve.js +213 -25
- package/dist/server/auth/access-principal.js +16 -0
- package/dist/server/auth/pipeline.js +99 -36
- 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 +213 -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 +152 -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/notifications.js +21 -4
- 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 +274 -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 +19 -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 +11 -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 +202 -12
- package/node_modules/@sigrun/shared/dist/views.js +16 -0
- package/node_modules/@sigrun/shared/package.json +1 -1
- package/package.json +13 -9
- package/webui/dist/assets/index-SO2W4jjk.js +55 -0
- package/webui/dist/assets/index-yqkL6BSS.css +1 -0
- package/webui/dist/index.html +2 -2
- package/CONTEXT.md +0 -163
- package/webui/dist/assets/index-CiYInlLx.css +0 -1
- package/webui/dist/assets/index-JDfOQV9z.js +0 -52
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/** Child entry: serve pi-durable's own environment over stdin/stdout. */
|
|
2
|
+
import { BACKGROUND_CONTEXT, withAbortSignal } from "@earendil-works/chord/context";
|
|
3
|
+
import { NodeExecutionEnv } from "@earendil-works/pi-durable/env/node";
|
|
4
|
+
import { createCommandObserver } from "./command-observer.js";
|
|
5
|
+
import { decodeValue, encodeMessage, encodeValue, errorReply, wireError, } from "./protocol.js";
|
|
6
|
+
const environments = new Map();
|
|
7
|
+
/** Exec requests that have not reached pi-durable's synchronous spawn yet. */
|
|
8
|
+
const pendingSpawns = new Map();
|
|
9
|
+
/** One ownership observer per environment, shared by all its execs. */
|
|
10
|
+
const commandObservers = new Map();
|
|
11
|
+
const handles = new Map();
|
|
12
|
+
const active = new Map();
|
|
13
|
+
let nextHandle = 1;
|
|
14
|
+
let buffer = "";
|
|
15
|
+
function environment(cwd) {
|
|
16
|
+
let env = environments.get(cwd);
|
|
17
|
+
if (env === undefined) {
|
|
18
|
+
env = new NodeExecutionEnv({ cwd });
|
|
19
|
+
observeCommands(env);
|
|
20
|
+
environments.set(cwd, env);
|
|
21
|
+
}
|
|
22
|
+
return env;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Install ONE permanent observer per environment. This deliberately depends
|
|
26
|
+
* on pi-durable 1.3.0's PRIVATE, UNDOCUMENTED activeChildPids field because
|
|
27
|
+
* ExecutionEnv exposes no spawn notification. An upgrade that removes or
|
|
28
|
+
* changes that field must update this observer; fail loudly rather than
|
|
29
|
+
* silently losing crash cleanup.
|
|
30
|
+
*/
|
|
31
|
+
function observeCommands(env) {
|
|
32
|
+
// SAFETY: this is the intentional private-field dependency described above.
|
|
33
|
+
const registry = env.activeChildPids;
|
|
34
|
+
if (!(registry instanceof Set)) {
|
|
35
|
+
throw new Error("pi-durable activeChildPids is unavailable; sandbox command cleanup cannot be tracked");
|
|
36
|
+
}
|
|
37
|
+
commandObservers.set(env, createCommandObserver({
|
|
38
|
+
registry,
|
|
39
|
+
pending: () => pendingSpawns.get(env) ?? new Set(),
|
|
40
|
+
active: () => new Set(active.keys()),
|
|
41
|
+
emit: ({ id, pid, phase }) => write({ id, event: "command", pid, phase }),
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
44
|
+
function write(message) {
|
|
45
|
+
// SAFETY: skipped is the exact ShellOutputSkip shape emitted by pi-durable;
|
|
46
|
+
// JsonValue is only the wider transport representation accepted by the encoder.
|
|
47
|
+
process.stdout.write(encodeMessage(message));
|
|
48
|
+
}
|
|
49
|
+
function argsOf(request) {
|
|
50
|
+
return request.args.map((arg) => decodeValue(arg));
|
|
51
|
+
}
|
|
52
|
+
function handleId(value) {
|
|
53
|
+
const id = typeof value === "object" && value !== null && "$handle" in value
|
|
54
|
+
? value.$handle
|
|
55
|
+
: value;
|
|
56
|
+
if (typeof id !== "number" || !handles.has(id))
|
|
57
|
+
throw new Error("worker handle is closed");
|
|
58
|
+
return id;
|
|
59
|
+
}
|
|
60
|
+
function openHandle(handle) {
|
|
61
|
+
const id = nextHandle++;
|
|
62
|
+
handles.set(id, handle);
|
|
63
|
+
return { $handle: id };
|
|
64
|
+
}
|
|
65
|
+
async function handleCall(request, args) {
|
|
66
|
+
const id = handleId(args[0]);
|
|
67
|
+
const handle = handles.get(id);
|
|
68
|
+
const method = request.op.slice("handle.".length);
|
|
69
|
+
if (method === "close") {
|
|
70
|
+
handles.delete(id);
|
|
71
|
+
await handle.value.close(BACKGROUND_CONTEXT);
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
if (handle.kind === "text" && method === "readLine") {
|
|
75
|
+
return handle.value.readLine(BACKGROUND_CONTEXT);
|
|
76
|
+
}
|
|
77
|
+
if (handle.kind === "binary" && method === "info") {
|
|
78
|
+
return handle.value.info(BACKGROUND_CONTEXT);
|
|
79
|
+
}
|
|
80
|
+
if (handle.kind === "binary" && method === "read") {
|
|
81
|
+
return handle.value.read(args[1], args[2], BACKGROUND_CONTEXT);
|
|
82
|
+
}
|
|
83
|
+
if (handle.kind === "binary" && method === "scanLines") {
|
|
84
|
+
return handle.value.scanLines(args[1], BACKGROUND_CONTEXT);
|
|
85
|
+
}
|
|
86
|
+
if (handle.kind === "dir" && method === "next") {
|
|
87
|
+
return handle.value.next(args[1], BACKGROUND_CONTEXT);
|
|
88
|
+
}
|
|
89
|
+
throw new TypeError(`unsupported handle operation ${request.op}`);
|
|
90
|
+
}
|
|
91
|
+
async function dispatch(request) {
|
|
92
|
+
const args = argsOf(request);
|
|
93
|
+
if (request.op.startsWith("handle."))
|
|
94
|
+
return handleCall(request, args);
|
|
95
|
+
if (request.op === "hello") {
|
|
96
|
+
return { capabilities: { listFiles: "listFiles" in NodeExecutionEnv.prototype } };
|
|
97
|
+
}
|
|
98
|
+
if (request.op === "pid")
|
|
99
|
+
return process.pid;
|
|
100
|
+
const cwd = args.at(-1);
|
|
101
|
+
if (typeof cwd !== "string")
|
|
102
|
+
throw new TypeError(`${request.op} requires cwd`);
|
|
103
|
+
const env = environment(cwd);
|
|
104
|
+
switch (request.op) {
|
|
105
|
+
case "absolutePath": return env.absolutePath(args[0], BACKGROUND_CONTEXT);
|
|
106
|
+
case "joinPath": return env.joinPath(args[0], BACKGROUND_CONTEXT);
|
|
107
|
+
case "readTextFile": return env.readTextFile(args[0], BACKGROUND_CONTEXT);
|
|
108
|
+
case "openTextLineReader": {
|
|
109
|
+
const result = await env.openTextLineReader(args[0], BACKGROUND_CONTEXT);
|
|
110
|
+
return result.ok ? { ok: true, value: openHandle({ kind: "text", value: result.value }) } : result;
|
|
111
|
+
}
|
|
112
|
+
case "readTextLines": return env.readTextLines(args[0], args[1], BACKGROUND_CONTEXT);
|
|
113
|
+
case "readBinaryFile": return env.readBinaryFile(args[0], BACKGROUND_CONTEXT);
|
|
114
|
+
case "openBinaryReader": {
|
|
115
|
+
const result = await env.openBinaryReader(args[0], args[1], BACKGROUND_CONTEXT);
|
|
116
|
+
return result.ok ? { ok: true, value: openHandle({ kind: "binary", value: result.value }) } : result;
|
|
117
|
+
}
|
|
118
|
+
case "writeFile": return env.writeFile(args[0], args[1], BACKGROUND_CONTEXT);
|
|
119
|
+
case "appendFile": return env.appendFile(args[0], args[1], BACKGROUND_CONTEXT);
|
|
120
|
+
case "truncateFile": return env.truncateFile(args[0], args[1], BACKGROUND_CONTEXT);
|
|
121
|
+
case "flushFile": return env.flushFile(args[0], BACKGROUND_CONTEXT);
|
|
122
|
+
case "renameFile": return env.renameFile(args[0], args[1], BACKGROUND_CONTEXT);
|
|
123
|
+
case "fileInfo": return env.fileInfo(args[0], BACKGROUND_CONTEXT);
|
|
124
|
+
case "listDir": return env.listDir(args[0], BACKGROUND_CONTEXT);
|
|
125
|
+
case "listFiles": {
|
|
126
|
+
// NodeExecutionEnv gained this optional member after 1.3.0; the
|
|
127
|
+
// structural view keeps this worker compatible with both shapes.
|
|
128
|
+
const listing = env;
|
|
129
|
+
if (listing.listFiles === undefined)
|
|
130
|
+
throw new Error("listFiles is not supported");
|
|
131
|
+
return listing.listFiles(args[0], args[1], BACKGROUND_CONTEXT);
|
|
132
|
+
}
|
|
133
|
+
case "openDirReader": {
|
|
134
|
+
const result = await env.openDirReader(args[0], BACKGROUND_CONTEXT);
|
|
135
|
+
return result.ok ? { ok: true, value: openHandle({ kind: "dir", value: result.value }) } : result;
|
|
136
|
+
}
|
|
137
|
+
case "watch": {
|
|
138
|
+
const result = await env.watch(args[0], (change) => write({ id: request.id, event: "watch", change: encodeValue("error" in change ? { error: wireError(change.error) } : change) }), BACKGROUND_CONTEXT);
|
|
139
|
+
return result.ok
|
|
140
|
+
? { ok: true, value: { handle: openHandle({ kind: "watch", value: result.value }), mode: result.value.mode } }
|
|
141
|
+
: result;
|
|
142
|
+
}
|
|
143
|
+
case "canonicalPath": return env.canonicalPath(args[0], BACKGROUND_CONTEXT);
|
|
144
|
+
case "exists": return env.exists(args[0], BACKGROUND_CONTEXT);
|
|
145
|
+
case "createDir": return env.createDir(args[0], args[1], BACKGROUND_CONTEXT);
|
|
146
|
+
case "remove": return env.remove(args[0], args[1], BACKGROUND_CONTEXT);
|
|
147
|
+
case "createTempDir": return env.createTempDir(args[0], BACKGROUND_CONTEXT);
|
|
148
|
+
case "createTempFile": return env.createTempFile(args[0], BACKGROUND_CONTEXT);
|
|
149
|
+
default:
|
|
150
|
+
throw new TypeError(request.op === "exec"
|
|
151
|
+
? 'top-level op "exec" is not supported; the worker dispatches only op "result"'
|
|
152
|
+
: `unknown sandbox operation ${request.op}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/** Both exec paths share this attribution; its finally emits no pid event. */
|
|
156
|
+
async function runWithSpawnAttribution(env, requestId, run) {
|
|
157
|
+
let pending = pendingSpawns.get(env);
|
|
158
|
+
if (pending === undefined) {
|
|
159
|
+
pending = new Set();
|
|
160
|
+
pendingSpawns.set(env, pending);
|
|
161
|
+
}
|
|
162
|
+
pending.add(requestId);
|
|
163
|
+
try {
|
|
164
|
+
return await run();
|
|
165
|
+
}
|
|
166
|
+
finally {
|
|
167
|
+
// processRequest's settlement sweep owns end events, not this finally.
|
|
168
|
+
pending.delete(requestId);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
async function dispatchResult(request, controller) {
|
|
172
|
+
const [op, rawArgs, cwd] = request.args;
|
|
173
|
+
if (typeof op !== "string" || !Array.isArray(rawArgs) || typeof cwd !== "string") {
|
|
174
|
+
throw new TypeError("result requires op, args and cwd");
|
|
175
|
+
}
|
|
176
|
+
if (op === "exec") {
|
|
177
|
+
const args = rawArgs.map((arg) => decodeValue(arg));
|
|
178
|
+
const supplied = (args[1] ?? {});
|
|
179
|
+
const options = {
|
|
180
|
+
...supplied,
|
|
181
|
+
onOutput: (text, _context, info) => {
|
|
182
|
+
write({ id: request.id, event: "output", stream: info.stream, text, ...(info.skipped === undefined ? {} : { skipped: encodeValue(info.skipped) }) });
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
const env = environment(cwd);
|
|
186
|
+
return runWithSpawnAttribution(env, request.id, () => env.exec(args[0], options, withAbortSignal(controller.signal, BACKGROUND_CONTEXT)));
|
|
187
|
+
}
|
|
188
|
+
return dispatch({ id: request.id, op, args: [...rawArgs, cwd] });
|
|
189
|
+
}
|
|
190
|
+
function operationResultValue(result, fallbackKind) {
|
|
191
|
+
if (typeof result === "object" && result !== null && "ok" in result && typeof result.ok === "boolean") {
|
|
192
|
+
if (result.ok)
|
|
193
|
+
return result;
|
|
194
|
+
if ("error" in result)
|
|
195
|
+
return { ok: false, error: wireError(result.error, fallbackKind) };
|
|
196
|
+
}
|
|
197
|
+
if (result === undefined)
|
|
198
|
+
return null;
|
|
199
|
+
if (typeof result === "string" || typeof result === "number" || typeof result === "boolean" || typeof result === "object")
|
|
200
|
+
return result;
|
|
201
|
+
throw new TypeError("unsupported operation result");
|
|
202
|
+
}
|
|
203
|
+
async function processRequest(request) {
|
|
204
|
+
if (request.id === 0 && request.op === "abort") {
|
|
205
|
+
const target = request.args[0];
|
|
206
|
+
if (typeof target === "number")
|
|
207
|
+
active.get(target)?.abort();
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const controller = new AbortController();
|
|
211
|
+
active.set(request.id, controller);
|
|
212
|
+
try {
|
|
213
|
+
if (request.op === "result") {
|
|
214
|
+
const forwarded = { id: request.id, op: "result.dispatch", args: request.args };
|
|
215
|
+
const result = await dispatchResult(forwarded, controller);
|
|
216
|
+
write({ id: request.id, ok: true, value: encodeValue(operationResultValue(result, forwarded.args[0] === "exec" ? "exec" : "file")) });
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const result = await dispatch(request);
|
|
220
|
+
write({ id: request.id, ok: true, value: encodeValue(operationResultValue(result, request.op === "exec" ? "exec" : "file")) });
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
write(errorReply(request.id, error, request.op === "exec" ? "exec" : "file"));
|
|
224
|
+
}
|
|
225
|
+
finally {
|
|
226
|
+
active.delete(request.id);
|
|
227
|
+
// pi-durable removes a pid BEFORE the exec promise resolves. The
|
|
228
|
+
// registry hook therefore sees the request still active; this is the
|
|
229
|
+
// settlement boundary that forgets ordinary pids and emits their end.
|
|
230
|
+
for (const observer of commandObservers.values())
|
|
231
|
+
observer.settleRequest(request.id);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
process.stdin.setEncoding("utf8");
|
|
235
|
+
process.stdin.on("data", (chunk) => {
|
|
236
|
+
buffer += chunk;
|
|
237
|
+
for (;;) {
|
|
238
|
+
const newline = buffer.indexOf("\n");
|
|
239
|
+
if (newline < 0)
|
|
240
|
+
break;
|
|
241
|
+
const line = buffer.slice(0, newline);
|
|
242
|
+
buffer = buffer.slice(newline + 1);
|
|
243
|
+
if (line.trim() === "")
|
|
244
|
+
continue;
|
|
245
|
+
try {
|
|
246
|
+
void processRequest(JSON.parse(line));
|
|
247
|
+
}
|
|
248
|
+
catch (error) {
|
|
249
|
+
process.stderr.write(`sandbox worker request error: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
process.stdin.on("end", () => {
|
|
254
|
+
for (const controller of active.values())
|
|
255
|
+
controller.abort();
|
|
256
|
+
for (const env of environments.values())
|
|
257
|
+
void env.cleanup(BACKGROUND_CONTEXT);
|
|
258
|
+
for (const handle of handles.values())
|
|
259
|
+
void handle.value.close(BACKGROUND_CONTEXT);
|
|
260
|
+
process.exit(0);
|
|
261
|
+
});
|
package/dist/serve.js
CHANGED
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
* never serve a broken process.
|
|
25
25
|
*/
|
|
26
26
|
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
27
|
-
import
|
|
27
|
+
import net from "node:net";
|
|
28
|
+
import { envServerSection, loadConfig, parseExtensionList } from "./config.js";
|
|
28
29
|
import { openHarness } from "./harness/boot.js";
|
|
29
30
|
import { packagesDirOf, syncPackages } from "./harness/packages.js";
|
|
30
31
|
import { defaultSettingsDir, SettingsStore } from "./harness/settings.js";
|
|
@@ -32,10 +33,106 @@ import { startGateway } from "./server/gateway.js";
|
|
|
32
33
|
import { SIGRUN_VERSION } from "./server/schema-fixture.js";
|
|
33
34
|
import { createServer } from "./server/index.js";
|
|
34
35
|
import { createShutdown } from "./server/shutdown.js";
|
|
36
|
+
/**
|
|
37
|
+
* The deploy entry's default listen host: the IPv6 wildcard, which is a
|
|
38
|
+
* DUAL-STACK listener on Linux (`net.ipv6.bindv6only` is 0) — it accepts
|
|
39
|
+
* the pod's IPv4 connections AND its IPv6 ones.
|
|
40
|
+
*
|
|
41
|
+
* `0.0.0.0` is the WRONG default for a dual-stack cluster: it is a
|
|
42
|
+
* v4-only socket, so it refuses every IPv6 connection, while a Service
|
|
43
|
+
* whose `ipFamilies` is `["IPv6"]` advertises only the pod's IPv6
|
|
44
|
+
* address in its EndpointSlice. The gateway dials that address, gets
|
|
45
|
+
* connection-refused, and answers the browser with "upstream connect
|
|
46
|
+
* error or disconnect/reset before headers. reset reason: remote
|
|
47
|
+
* connection failure". (This cluster's ClusterIP range is
|
|
48
|
+
* `fd00:beef:cafe::/…`, so a chart-default Service is IPv6-only — the
|
|
49
|
+
* mismatch is the DEFAULT shape, not an edge case.)
|
|
50
|
+
*/
|
|
51
|
+
export const DEFAULT_LISTEN_HOST = "::";
|
|
52
|
+
export const LISTENER_PROBE_TIMEOUT_MS = 5_000;
|
|
53
|
+
/** Test-only seam for the post-listen failure boundary; production uses the real probe. */
|
|
54
|
+
export const listenerProbe = { connect: probeLoopback };
|
|
55
|
+
/**
|
|
56
|
+
* One loopback probe: whether a TCP connection to `host:port` succeeds.
|
|
57
|
+
* Bounded by {@link LISTENER_PROBE_TIMEOUT_MS}: a silently dropped
|
|
58
|
+
* loopback connect resolves `false` (never hangs boot — an inconclusive
|
|
59
|
+
* measurement must fail loudly, spec §2).
|
|
60
|
+
*/
|
|
61
|
+
function probeLoopback(host, port) {
|
|
62
|
+
return new Promise((resolve) => {
|
|
63
|
+
const socket = net.connect({ host, port });
|
|
64
|
+
const settle = (ok) => {
|
|
65
|
+
clearTimeout(timer);
|
|
66
|
+
socket.destroy();
|
|
67
|
+
resolve(ok);
|
|
68
|
+
};
|
|
69
|
+
const timer = setTimeout(() => settle(false), LISTENER_PROBE_TIMEOUT_MS);
|
|
70
|
+
timer.unref();
|
|
71
|
+
socket.once("connect", () => settle(true));
|
|
72
|
+
socket.once("error", () => settle(false));
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Measure the live listener's address families ONCE (release-readiness
|
|
77
|
+
* Task 1, serving spec §2) and return the retained {@link ListenerInfo}.
|
|
78
|
+
*
|
|
79
|
+
* - A literal IPv4/IPv6 host reports its family without probing.
|
|
80
|
+
* - A hostname bind reports the bound socket's family (`address()` —
|
|
81
|
+
* Node binds ONE resolved address for a hostname listen). A hostname
|
|
82
|
+
* or wildcard is never reported as a concrete peer address: the wire
|
|
83
|
+
* host stays the configured string.
|
|
84
|
+
* - The `::` wildcard does NOT assume dual-stack from the spelling: one
|
|
85
|
+
* startup loopback probe against `127.0.0.1` and `[::1]` on the bound
|
|
86
|
+
* port, retaining exactly the families that connect. Absence of
|
|
87
|
+
* exactly one family is a legitimate result (an IPv6-only host).
|
|
88
|
+
* INCONCLUSIVE — neither loopback connects while the listener is bound
|
|
89
|
+
* — throws: serving fails loudly rather than advertising unmeasured
|
|
90
|
+
* reachability.
|
|
91
|
+
*
|
|
92
|
+
* @param host the effective host string supplied to `listen`.
|
|
93
|
+
* @param address `app.server.address()` after listen (null → loud).
|
|
94
|
+
* @param connect the probe (tests inject; production is
|
|
95
|
+
* {@link probeLoopback}).
|
|
96
|
+
*/
|
|
97
|
+
export async function measureListener(host, address, connect = probeLoopback) {
|
|
98
|
+
if (address === null || typeof address === "string") {
|
|
99
|
+
throw new Error("listener measurement failed: the server reports no bound address");
|
|
100
|
+
}
|
|
101
|
+
const port = address.port;
|
|
102
|
+
const literal = net.isIP(host);
|
|
103
|
+
if (literal === 4)
|
|
104
|
+
return { host, port, families: ["ipv4"] };
|
|
105
|
+
if (literal === 6 && address.address !== "::") {
|
|
106
|
+
return { host, port, families: ["ipv6"] };
|
|
107
|
+
}
|
|
108
|
+
if (literal === 0) {
|
|
109
|
+
// A hostname bind: the bound socket's family, verbatim.
|
|
110
|
+
const family = address.family === "IPv4" || address.family === 4 ? "ipv4" : "ipv6";
|
|
111
|
+
return { host, port, families: [family] };
|
|
112
|
+
}
|
|
113
|
+
// The BOUND socket identifies the wildcard: Node canonicalizes alternate
|
|
114
|
+
// spellings such as ::0 to ::. Measure, never infer dual-stack from the
|
|
115
|
+
// configured spelling.
|
|
116
|
+
const [v4, v6] = await Promise.all([
|
|
117
|
+
connect("127.0.0.1", port),
|
|
118
|
+
connect("::1", port),
|
|
119
|
+
]);
|
|
120
|
+
const families = [];
|
|
121
|
+
if (v4)
|
|
122
|
+
families.push("ipv4");
|
|
123
|
+
if (v6)
|
|
124
|
+
families.push("ipv6");
|
|
125
|
+
if (families.length === 0) {
|
|
126
|
+
throw new Error(`listener reachability check inconclusive on ${host}:${port} — neither 127.0.0.1 nor [::1] connected while the listener is bound`);
|
|
127
|
+
}
|
|
128
|
+
return { host, port, families };
|
|
129
|
+
}
|
|
35
130
|
/**
|
|
36
131
|
* Boot the harness + server composition and listen on
|
|
37
|
-
* `options.host ??
|
|
38
|
-
*
|
|
132
|
+
* `options.host ?? env.SIGRUN_LISTEN_HOST ?? "::"` + `<port>` (the
|
|
133
|
+
* default is the container's
|
|
134
|
+
* dual-stack wildcard — loopback-only would be unreachable behind the
|
|
135
|
+
* Service, and a v4-only bind is unreachable on an IPv6 endpoint;
|
|
39
136
|
* `loadConfig` refuses port 0, so a real port is always bound).
|
|
40
137
|
* The CLI's `run` additionally launches the TUI against the loopback URL
|
|
41
138
|
* on {@link BootedServer.port}.
|
|
@@ -80,52 +177,108 @@ export async function serve(env, options) {
|
|
|
80
177
|
// undeclared BEFORE any extension loads. Fail-loud: an uninstallable
|
|
81
178
|
// declaration fails the boot here, naming the specifier. Steady state
|
|
82
179
|
// (the dir already matches) costs one manifest read, no subprocess.
|
|
83
|
-
const settingsFile = settingsStore.read();
|
|
84
180
|
const packagesDir = packagesDirOf(defaultSettingsDir());
|
|
85
|
-
|
|
181
|
+
// The MERGED extension spec list (env SIGRUN_EXTENSIONS over the file —
|
|
182
|
+
// the same precedence loadConfig applied above), not the file's list
|
|
183
|
+
// alone: an env-declared extension must install and load even when the
|
|
184
|
+
// file declares none (e.g. a pre-existing settings file blocks the
|
|
185
|
+
// bootstrap mirror). Merge at SPEC level: config.extensions is the
|
|
186
|
+
// loader's flattened string[] view (entry/ref/enabled already
|
|
187
|
+
// discarded); syncPackages needs the full ExtensionSpec objects, so the
|
|
188
|
+
// env list or the file's verbatim entries feed it directly.
|
|
189
|
+
const settingsFile = settingsStore.read();
|
|
190
|
+
const extensionSpecs = parseExtensionList(env.SIGRUN_EXTENSIONS) ?? settingsFile.extensions ?? [];
|
|
191
|
+
const extensionResolutions = await syncPackages(extensionSpecs, packagesDir);
|
|
86
192
|
// The workspace directory must exist before any files endpoint or tool
|
|
87
193
|
// call touches it. The deploy manifest mounts a volume at /data; the
|
|
88
194
|
// workspace is /data/workspace. Without this, the first files request
|
|
89
195
|
// 404s forever (the directory was never created).
|
|
90
196
|
const { mkdir } = await import("node:fs/promises");
|
|
91
197
|
await mkdir(config.workspace.path, { recursive: true });
|
|
92
|
-
|
|
198
|
+
// The serve env threads into boot so boot-time env reads (store
|
|
199
|
+
// roots, the provider collector, the U4 scheduler cadence resolution
|
|
200
|
+
// in `resolveRefreshMinutes`) see the SAME env `loadConfig` just
|
|
201
|
+
// resolved — a serve-harness env override must win over process.env
|
|
202
|
+
// here exactly like it does in the config.
|
|
203
|
+
// The ONE listener decision (release-readiness Task 1, serving spec §1):
|
|
204
|
+
// `options.host` → `SIGRUN_LISTEN_HOST` (from the env `serve` received)
|
|
205
|
+
// → `DEFAULT_LISTEN_HOST`, resolved ONCE here and handed verbatim to
|
|
206
|
+
// `openHarness` (the boot-side auth derivation), `createServer` (the
|
|
207
|
+
// pipeline's Host check + method list), and `app.listen` (the actual
|
|
208
|
+
// bind) — the three consumers can never disagree.
|
|
209
|
+
const host = options?.host ?? env.SIGRUN_LISTEN_HOST ?? DEFAULT_LISTEN_HOST;
|
|
210
|
+
const opened = await openHarness(config, {
|
|
211
|
+
extensionEntries: extensionResolutions,
|
|
212
|
+
env,
|
|
213
|
+
listenHost: host,
|
|
214
|
+
});
|
|
93
215
|
// The gateway handle (gateway C2) exists BEFORE the server: the meta
|
|
94
216
|
// route reaches it through ServerContext, so the engine's health is
|
|
95
217
|
// reportable from the first request. The ENGINE STARTS after listen
|
|
96
218
|
// (below) — an engine supervising chat transports starts once the
|
|
97
219
|
// server it delivers through is up.
|
|
98
220
|
const gateway = startGateway(opened);
|
|
221
|
+
// The booted handle the restart callback defers to: `createServer`
|
|
222
|
+
// needs the callback BEFORE `serve` can build the BootedServer (the
|
|
223
|
+
// shutdown composition lives at the bottom of this function), so the
|
|
224
|
+
// handle is promised here and RESOLVED at return time; `onRestart`
|
|
225
|
+
// awaits the resolution. Between `app.listen` + `gateway.started` and
|
|
226
|
+
// the assignment below a supervised admin POST can already land (202
|
|
227
|
+
// answered, callback deferred) — awaiting the promise holds that
|
|
228
|
+
// window open instead of dropping the restart on the floor (a `?.`
|
|
229
|
+
// on an unassigned ref made 202-with-no-restart reachable).
|
|
230
|
+
let resolveBooted;
|
|
231
|
+
const bootedReady = new Promise((resolve) => {
|
|
232
|
+
resolveBooted = resolve;
|
|
233
|
+
});
|
|
99
234
|
// The contributed-bundle registry (build-and-webui Task 6): boot folded
|
|
100
235
|
// the loader's extension contributions into the name-keyed registry;
|
|
101
236
|
// the server's serve-time NAME-selector resolution reads it here.
|
|
102
237
|
const app = await createServer(opened, config, opened.webuiBundles, {
|
|
103
|
-
listenHost:
|
|
238
|
+
listenHost: host,
|
|
104
239
|
env,
|
|
105
240
|
gateway,
|
|
241
|
+
// U4 Task 2 (spec §13.3): the admin restart route's callback runs
|
|
242
|
+
// EXACTLY the SIGTERM path (the ordered shutdown), then exits 0 —
|
|
243
|
+
// the supervisor (chart/compose, `server.supervised`) brings the
|
|
244
|
+
// process back. `shutdownOnce` is the same re-entry guard the
|
|
245
|
+
// signal path uses; `stopping` is assigned by closure below, so
|
|
246
|
+
// the callback defers to it (the route fires post-listen).
|
|
247
|
+
onRestart: () => bootedReady
|
|
248
|
+
.then(async (handle) => {
|
|
249
|
+
// Force-exit lever (the review's finding 4): the ordered
|
|
250
|
+
// shutdown's drain is unbounded — a wedged close would be
|
|
251
|
+
// an unrecoverable outage (the supervisor never sees the
|
|
252
|
+
// process die, so it never restarts it). The watchdog is
|
|
253
|
+
// unref'd: the normal path (shutdown settles, exit below)
|
|
254
|
+
// is unaffected.
|
|
255
|
+
const watchdog = setTimeout(() => {
|
|
256
|
+
console.error("sigrun: restart shutdown exceeded 30s; forcing exit");
|
|
257
|
+
process.exit(1);
|
|
258
|
+
}, 30_000);
|
|
259
|
+
watchdog.unref();
|
|
260
|
+
await handle.shutdownOnce();
|
|
261
|
+
clearTimeout(watchdog);
|
|
262
|
+
process.exit(0);
|
|
263
|
+
})
|
|
264
|
+
.catch((error) => {
|
|
265
|
+
console.error("sigrun restart error:", error);
|
|
266
|
+
process.exit(1);
|
|
267
|
+
}),
|
|
106
268
|
});
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
// handle, never wedges boot). AWAITED: serve() resolves only once the
|
|
112
|
-
// engine start completed (or failed-and-latched) — the constraint's
|
|
113
|
-
// intent; the lazy `started` never rejects, so this await cannot throw.
|
|
114
|
-
await gateway.started;
|
|
115
|
-
// Machine-greppable: the deploy-entry test waits for this line, and an
|
|
116
|
-
// operator reading pod logs sees the bound port without guessing. The
|
|
117
|
-
// version rides the same line (what an operator quotes in a bug report).
|
|
118
|
-
console.log(`sigrun v${SIGRUN_VERSION} listening on http://${host}:${config.port}`);
|
|
119
|
-
// The ordered shutdown (gateway C2's extraction of the inline original
|
|
120
|
-
// into src/server/shutdown.ts): SSE streams FIRST, the app drain, the
|
|
121
|
-
// engine's intake close, the event-bus sequence (drain + flush), the
|
|
122
|
-
// engine stop, the refresh stop + bounded boot-refresh join, the
|
|
123
|
-
// harness close — each failure collected, all steps always run.
|
|
269
|
+
// The ordered shutdown exists before listen so a failed measurement can
|
|
270
|
+
// run the SAME cleanup as an ordinary stop. In particular, a rejected
|
|
271
|
+
// serve() must not leave the bound listener or harness keeping the
|
|
272
|
+
// caller's process alive.
|
|
124
273
|
const shutdown = createShutdown({
|
|
125
274
|
endStreams: () => app.sse.endAll(),
|
|
126
275
|
closeApp: () => app.close(),
|
|
127
276
|
gateway,
|
|
128
277
|
eventBusShutdown: () => opened.eventBus.shutdownSequence(BACKGROUND_CONTEXT),
|
|
278
|
+
stopSandboxWorkers: async () => {
|
|
279
|
+
await opened.sandboxPool.stopAll();
|
|
280
|
+
await opened.userServices.stopAll();
|
|
281
|
+
},
|
|
129
282
|
stopProviderRefresh: async () => {
|
|
130
283
|
// The interval refresh scheduler (provider plugins Task 5) stops
|
|
131
284
|
// BEFORE the harness closes. The T4 boot refresh promise is raced
|
|
@@ -154,8 +307,41 @@ export async function serve(env, options) {
|
|
|
154
307
|
},
|
|
155
308
|
closeHarness: () => opened.harness.close(BACKGROUND_CONTEXT),
|
|
156
309
|
});
|
|
310
|
+
await app.listen({ port: config.port, host });
|
|
311
|
+
// Measure the live listener ONCE and retain the facts (spec §2): the
|
|
312
|
+
// meta route projects this record; no probe ever runs per request. A
|
|
313
|
+
// failed or inconclusive measurement closes the listener and harness,
|
|
314
|
+
// then rethrows — serving fails loudly without stranding live resources.
|
|
315
|
+
let listener;
|
|
316
|
+
try {
|
|
317
|
+
listener = await measureListener(host, app.server.address(), listenerProbe.connect);
|
|
318
|
+
}
|
|
319
|
+
catch (error) {
|
|
320
|
+
try {
|
|
321
|
+
await shutdown();
|
|
322
|
+
}
|
|
323
|
+
catch (cleanupError) {
|
|
324
|
+
throw new AggregateError([error, cleanupError], "sigrun boot and boot-failure cleanup failed");
|
|
325
|
+
}
|
|
326
|
+
throw error;
|
|
327
|
+
}
|
|
328
|
+
app.sigrunContext.listener
|
|
329
|
+
.families = listener.families;
|
|
330
|
+
// The engine starts with the listener live (gateway C2's lifecycle:
|
|
331
|
+
// start after listen — a failed start latches degraded inside the
|
|
332
|
+
// handle, never wedges boot). AWAITED: serve() resolves only once the
|
|
333
|
+
// engine start completed (or failed-and-latched) — the constraint's
|
|
334
|
+
// intent; the lazy `started` never rejects, so this await cannot throw.
|
|
335
|
+
await gateway.started;
|
|
336
|
+
// Machine-greppable: the deploy-entry test waits for this line, and an
|
|
337
|
+
// operator reading pod logs sees the bound port without guessing. The
|
|
338
|
+
// version rides the same line (what an operator quotes in a bug report).
|
|
339
|
+
// An IPv6 host is bracketed — the URL form (`http://[::]:3000`), not the
|
|
340
|
+
// ambiguous `http://:::3000`.
|
|
341
|
+
const shownHost = host.includes(":") ? `[${host}]` : host;
|
|
342
|
+
console.log(`sigrun v${SIGRUN_VERSION} listening on http://${shownHost}:${config.port}`);
|
|
157
343
|
let stopping;
|
|
158
|
-
|
|
344
|
+
const booted = {
|
|
159
345
|
port: config.port,
|
|
160
346
|
shutdown,
|
|
161
347
|
shutdownOnce: () => {
|
|
@@ -163,4 +349,6 @@ export async function serve(env, options) {
|
|
|
163
349
|
return stopping;
|
|
164
350
|
},
|
|
165
351
|
};
|
|
352
|
+
resolveBooted(booted);
|
|
353
|
+
return booted;
|
|
166
354
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Project the authenticated caller into the shared access rule. */
|
|
2
|
+
import { storedGroups } from "../../harness/identity/groups.js";
|
|
3
|
+
import { isSuperuser } from "./principal.js";
|
|
4
|
+
/**
|
|
5
|
+
* Read the CURRENT directory record on every access decision: stored group
|
|
6
|
+
* changes therefore apply immediately. Absent records (including anonymous
|
|
7
|
+
* callers) contribute no groups.
|
|
8
|
+
*/
|
|
9
|
+
export function accessPrincipalOf(principal, users) {
|
|
10
|
+
const record = principal.user === undefined ? undefined : users.get(principal.user);
|
|
11
|
+
return {
|
|
12
|
+
user: principal.user,
|
|
13
|
+
superuser: isSuperuser(principal),
|
|
14
|
+
...(record === undefined ? {} : { groups: storedGroups(record) }),
|
|
15
|
+
};
|
|
16
|
+
}
|