@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.
Files changed (137) hide show
  1. package/CHANGELOG.md +192 -0
  2. package/README.md +445 -42
  3. package/dist/blocks/build-stamp.json +13 -0
  4. package/dist/blocks/builders.d.ts +57 -0
  5. package/dist/blocks/images.d.ts +26 -0
  6. package/dist/blocks/index.d.ts +20 -0
  7. package/dist/blocks/limits.d.ts +25 -0
  8. package/dist/blocks/placements.d.ts +26 -0
  9. package/dist/blocks/schema.d.ts +38 -0
  10. package/dist/blocks/schema.js +9 -2
  11. package/dist/blocks/validate.d.ts +73 -0
  12. package/dist/blocks/validate.js +57 -8
  13. package/dist/cli.js +26 -2
  14. package/dist/config.js +67 -1
  15. package/dist/extension.d.ts +230 -22
  16. package/dist/harness/ambient.js +39 -185
  17. package/dist/harness/blocks/entries.js +47 -0
  18. package/dist/harness/blocks/state.js +58 -0
  19. package/dist/harness/blocks/tools.js +274 -0
  20. package/dist/harness/boot.js +259 -20
  21. package/dist/harness/builtins.js +15 -11
  22. package/dist/harness/event-bus.js +105 -10
  23. package/dist/harness/identity/accounts.js +186 -0
  24. package/dist/harness/identity/groups.js +14 -0
  25. package/dist/harness/identity/index.js +13 -6
  26. package/dist/harness/identity/json-store.js +15 -4
  27. package/dist/harness/identity/setup-code-check.js +9 -0
  28. package/dist/harness/identity/tokens.js +28 -5
  29. package/dist/harness/identity/users.js +111 -12
  30. package/dist/harness/interactions.js +292 -136
  31. package/dist/harness/loader.js +271 -17
  32. package/dist/harness/packages.js +1 -1
  33. package/dist/harness/reserved-sections.js +21 -0
  34. package/dist/harness/seam-host.js +92 -6
  35. package/dist/harness/sections/context-files.js +1 -1
  36. package/dist/harness/settings.js +63 -2
  37. package/dist/harness/stores.js +241 -4
  38. package/dist/harness/user-services.js +391 -0
  39. package/dist/harness/user-tools.js +187 -0
  40. package/dist/harness/workspace.js +61 -57
  41. package/dist/jobs/fire.js +10 -16
  42. package/dist/sandbox/availability.js +211 -0
  43. package/dist/sandbox/command-observer.js +59 -0
  44. package/dist/sandbox/grants.js +159 -0
  45. package/dist/sandbox/helper.js +29 -0
  46. package/dist/sandbox/isolation.js +29 -0
  47. package/dist/sandbox/launch.js +31 -0
  48. package/dist/sandbox/os-account.js +49 -0
  49. package/dist/sandbox/policy.js +274 -0
  50. package/dist/sandbox/pool.js +548 -0
  51. package/dist/sandbox/protocol.js +167 -0
  52. package/dist/sandbox/sandboxed-env.js +245 -0
  53. package/dist/sandbox/unavailable-env.js +49 -0
  54. package/dist/sandbox/worker.js +261 -0
  55. package/dist/serve.js +213 -25
  56. package/dist/server/auth/access-principal.js +16 -0
  57. package/dist/server/auth/pipeline.js +99 -36
  58. package/dist/server/blocks/answers.js +94 -0
  59. package/dist/server/blocks/chat-surfaces.js +393 -0
  60. package/dist/server/blocks/notes.js +216 -0
  61. package/dist/server/blocks/summaries.js +328 -0
  62. package/dist/server/commands.js +5 -0
  63. package/dist/server/content-parts-lazy.js +13 -0
  64. package/dist/server/conversation-handler.js +2 -5
  65. package/dist/server/conversation-ops.js +138 -37
  66. package/dist/server/helpers.js +18 -6
  67. package/dist/server/host-bridge.js +40 -20
  68. package/dist/server/images/cache.js +213 -0
  69. package/dist/server/images/convert.js +13 -0
  70. package/dist/server/images/fetch.js +302 -0
  71. package/dist/server/images/ranges.js +42 -0
  72. package/dist/server/images/rewrite.js +94 -0
  73. package/dist/server/images/sign.js +84 -0
  74. package/dist/server/images/sniff.js +62 -0
  75. package/dist/server/index.js +152 -4
  76. package/dist/server/login/flows.js +126 -0
  77. package/dist/server/login/http.js +31 -0
  78. package/dist/server/login/resolve.js +133 -0
  79. package/dist/server/login/setup-claim.js +55 -0
  80. package/dist/server/login/usernames.js +15 -0
  81. package/dist/server/notifications.js +21 -4
  82. package/dist/server/ownership.js +13 -0
  83. package/dist/server/routes/admin.js +58 -0
  84. package/dist/server/routes/auth.js +88 -2
  85. package/dist/server/routes/conversations.js +3 -6
  86. package/dist/server/routes/events.js +28 -3
  87. package/dist/server/routes/extensions.js +37 -5
  88. package/dist/server/routes/images.js +274 -0
  89. package/dist/server/routes/interactions.js +57 -68
  90. package/dist/server/routes/jobs.js +2 -8
  91. package/dist/server/routes/login.js +223 -0
  92. package/dist/server/routes/me.js +90 -0
  93. package/dist/server/routes/meta.js +19 -0
  94. package/dist/server/routes/onboarding.js +94 -84
  95. package/dist/server/routes/settings-sections.js +190 -0
  96. package/dist/server/routes/settings.js +16 -4
  97. package/dist/server/routes/submissions.js +1 -1
  98. package/dist/server/routes/surfaces.js +175 -23
  99. package/dist/server/routes/users.js +116 -20
  100. package/dist/server/schema-fixture.js +213 -0
  101. package/dist/server/session-meta.js +3 -0
  102. package/dist/server/shutdown.js +11 -4
  103. package/dist/server/static.js +18 -0
  104. package/dist/server/surfaces/dedup.js +9 -2
  105. package/dist/server/surfaces/service.js +181 -13
  106. package/dist/server/surfaces/settings-surfaces.js +597 -0
  107. package/dist/server/turns/submit-turn.js +26 -0
  108. package/dist/server/views.js +16 -0
  109. package/dist/settings/core-sections.js +532 -0
  110. package/dist/settings/pending.js +181 -0
  111. package/dist/settings/registry.js +453 -0
  112. package/dist/skills/index.js +6 -0
  113. package/node_modules/@sigrun/shared/CHANGELOG.md +11 -0
  114. package/node_modules/@sigrun/shared/dist/access.d.ts +4 -2
  115. package/node_modules/@sigrun/shared/dist/access.js +11 -4
  116. package/node_modules/@sigrun/shared/dist/ambient.d.ts +17 -74
  117. package/node_modules/@sigrun/shared/dist/ambient.js +8 -6
  118. package/node_modules/@sigrun/shared/dist/blocks/text.js +16 -2
  119. package/node_modules/@sigrun/shared/dist/blocks/transport.d.ts +4 -0
  120. package/node_modules/@sigrun/shared/dist/blocks/types.d.ts +21 -4
  121. package/node_modules/@sigrun/shared/dist/interactions.d.ts +22 -48
  122. package/node_modules/@sigrun/shared/dist/interactions.js +9 -7
  123. package/node_modules/@sigrun/shared/dist/settings.d.ts +27 -2
  124. package/node_modules/@sigrun/shared/dist/themes.d.ts +2 -3
  125. package/node_modules/@sigrun/shared/dist/themes.js +2 -3
  126. package/node_modules/@sigrun/shared/dist/turns.d.ts +14 -0
  127. package/node_modules/@sigrun/shared/dist/turns.js +53 -3
  128. package/node_modules/@sigrun/shared/dist/views.d.ts +202 -12
  129. package/node_modules/@sigrun/shared/dist/views.js +16 -0
  130. package/node_modules/@sigrun/shared/package.json +1 -1
  131. package/package.json +13 -9
  132. package/webui/dist/assets/index-SO2W4jjk.js +55 -0
  133. package/webui/dist/assets/index-yqkL6BSS.css +1 -0
  134. package/webui/dist/index.html +2 -2
  135. package/CONTEXT.md +0 -163
  136. package/webui/dist/assets/index-CiYInlLx.css +0 -1
  137. 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 { envServerSection, loadConfig } from "./config.js";
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 ?? "0.0.0.0"` + `<port>` (the default is the container's
38
- * address — loopback-only would be unreachable behind the Service;
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
- const extensionResolutions = await syncPackages(settingsFile.extensions ?? [], packagesDir);
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
- const opened = await openHarness(config, { extensionEntries: extensionResolutions });
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: options?.host ?? "0.0.0.0",
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
- const host = options?.host ?? "0.0.0.0";
108
- await app.listen({ port: config.port, host });
109
- // The engine starts with the listener live (gateway C2's lifecycle:
110
- // start after listen — a failed start latches degraded inside the
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
- return {
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
+ }