@sigrun/sigrun 0.1.5 → 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.
Files changed (128) hide show
  1. package/CHANGELOG.md +136 -0
  2. package/README.md +352 -36
  3. package/THIRD_PARTY_NOTICES.md +23 -0
  4. package/dist/blocks/schema.js +9 -2
  5. package/dist/blocks/validate.js +57 -8
  6. package/dist/cli.js +26 -2
  7. package/dist/config.js +67 -1
  8. package/dist/extension.d.ts +189 -15
  9. package/dist/harness/agent-env.js +62 -0
  10. package/dist/harness/ambient.js +39 -185
  11. package/dist/harness/blocks/entries.js +47 -0
  12. package/dist/harness/blocks/state.js +58 -0
  13. package/dist/harness/blocks/tools.js +274 -0
  14. package/dist/harness/boot.js +253 -20
  15. package/dist/harness/builtins.js +15 -11
  16. package/dist/harness/event-bus.js +105 -10
  17. package/dist/harness/identity/accounts.js +186 -0
  18. package/dist/harness/identity/groups.js +14 -0
  19. package/dist/harness/identity/index.js +13 -6
  20. package/dist/harness/identity/json-store.js +15 -4
  21. package/dist/harness/identity/setup-code-check.js +9 -0
  22. package/dist/harness/identity/tokens.js +28 -5
  23. package/dist/harness/identity/users.js +111 -12
  24. package/dist/harness/interactions.js +292 -136
  25. package/dist/harness/loader.js +271 -17
  26. package/dist/harness/packages.js +1 -1
  27. package/dist/harness/reserved-sections.js +21 -0
  28. package/dist/harness/seam-host.js +80 -6
  29. package/dist/harness/settings.js +63 -2
  30. package/dist/harness/stores.js +241 -4
  31. package/dist/harness/user-services.js +391 -0
  32. package/dist/harness/user-tools.js +187 -0
  33. package/dist/harness/workspace.js +61 -55
  34. package/dist/jobs/fire.js +10 -16
  35. package/dist/sandbox/availability.js +162 -0
  36. package/dist/sandbox/command-observer.js +59 -0
  37. package/dist/sandbox/grants.js +159 -0
  38. package/dist/sandbox/helper.js +29 -0
  39. package/dist/sandbox/isolation.js +29 -0
  40. package/dist/sandbox/launch.js +20 -0
  41. package/dist/sandbox/os-account.js +49 -0
  42. package/dist/sandbox/policy.js +253 -0
  43. package/dist/sandbox/pool.js +548 -0
  44. package/dist/sandbox/protocol.js +167 -0
  45. package/dist/sandbox/sandboxed-env.js +245 -0
  46. package/dist/sandbox/unavailable-env.js +49 -0
  47. package/dist/sandbox/worker.js +281 -0
  48. package/dist/serve.js +82 -7
  49. package/dist/server/auth/access-principal.js +16 -0
  50. package/dist/server/auth/pipeline.js +47 -33
  51. package/dist/server/blocks/answers.js +94 -0
  52. package/dist/server/blocks/chat-surfaces.js +393 -0
  53. package/dist/server/blocks/notes.js +216 -0
  54. package/dist/server/blocks/summaries.js +328 -0
  55. package/dist/server/commands.js +5 -0
  56. package/dist/server/content-parts-lazy.js +13 -0
  57. package/dist/server/conversation-handler.js +2 -5
  58. package/dist/server/conversation-ops.js +138 -37
  59. package/dist/server/helpers.js +18 -6
  60. package/dist/server/host-bridge.js +40 -20
  61. package/dist/server/images/cache.js +205 -0
  62. package/dist/server/images/convert.js +13 -0
  63. package/dist/server/images/fetch.js +302 -0
  64. package/dist/server/images/ranges.js +42 -0
  65. package/dist/server/images/rewrite.js +94 -0
  66. package/dist/server/images/sign.js +84 -0
  67. package/dist/server/images/sniff.js +62 -0
  68. package/dist/server/index.js +144 -4
  69. package/dist/server/login/flows.js +126 -0
  70. package/dist/server/login/http.js +31 -0
  71. package/dist/server/login/resolve.js +133 -0
  72. package/dist/server/login/setup-claim.js +55 -0
  73. package/dist/server/login/usernames.js +15 -0
  74. package/dist/server/ownership.js +13 -0
  75. package/dist/server/routes/admin.js +58 -0
  76. package/dist/server/routes/auth.js +88 -2
  77. package/dist/server/routes/conversations.js +3 -6
  78. package/dist/server/routes/events.js +28 -3
  79. package/dist/server/routes/extensions.js +37 -5
  80. package/dist/server/routes/images.js +267 -0
  81. package/dist/server/routes/interactions.js +57 -68
  82. package/dist/server/routes/jobs.js +2 -8
  83. package/dist/server/routes/login.js +223 -0
  84. package/dist/server/routes/me.js +90 -0
  85. package/dist/server/routes/meta.js +15 -0
  86. package/dist/server/routes/onboarding.js +94 -84
  87. package/dist/server/routes/settings-sections.js +190 -0
  88. package/dist/server/routes/settings.js +16 -4
  89. package/dist/server/routes/submissions.js +1 -1
  90. package/dist/server/routes/surfaces.js +175 -23
  91. package/dist/server/routes/users.js +116 -20
  92. package/dist/server/schema-fixture.js +213 -0
  93. package/dist/server/session-meta.js +3 -0
  94. package/dist/server/shutdown.js +11 -4
  95. package/dist/server/static.js +18 -0
  96. package/dist/server/surfaces/dedup.js +9 -2
  97. package/dist/server/surfaces/service.js +181 -13
  98. package/dist/server/surfaces/settings-surfaces.js +597 -0
  99. package/dist/server/turns/submit-turn.js +26 -0
  100. package/dist/server/views.js +16 -0
  101. package/dist/settings/core-sections.js +532 -0
  102. package/dist/settings/pending.js +181 -0
  103. package/dist/settings/registry.js +453 -0
  104. package/dist/skills/index.js +6 -0
  105. package/node_modules/@sigrun/shared/CHANGELOG.md +15 -0
  106. package/node_modules/@sigrun/shared/dist/access.d.ts +4 -2
  107. package/node_modules/@sigrun/shared/dist/access.js +11 -4
  108. package/node_modules/@sigrun/shared/dist/ambient.d.ts +17 -74
  109. package/node_modules/@sigrun/shared/dist/ambient.js +8 -6
  110. package/node_modules/@sigrun/shared/dist/blocks/text.js +16 -2
  111. package/node_modules/@sigrun/shared/dist/blocks/transport.d.ts +4 -0
  112. package/node_modules/@sigrun/shared/dist/blocks/types.d.ts +21 -4
  113. package/node_modules/@sigrun/shared/dist/interactions.d.ts +22 -48
  114. package/node_modules/@sigrun/shared/dist/interactions.js +9 -7
  115. package/node_modules/@sigrun/shared/dist/settings.d.ts +27 -2
  116. package/node_modules/@sigrun/shared/dist/themes.d.ts +2 -3
  117. package/node_modules/@sigrun/shared/dist/themes.js +2 -3
  118. package/node_modules/@sigrun/shared/dist/turns.d.ts +14 -0
  119. package/node_modules/@sigrun/shared/dist/turns.js +53 -3
  120. package/node_modules/@sigrun/shared/dist/views.d.ts +175 -12
  121. package/node_modules/@sigrun/shared/dist/views.js +16 -0
  122. package/node_modules/@sigrun/shared/package.json +1 -1
  123. package/package.json +13 -8
  124. package/webui/dist/assets/index-CxB969qp.js +55 -0
  125. package/webui/dist/assets/index-yqkL6BSS.css +1 -0
  126. package/webui/dist/index.html +2 -2
  127. package/webui/dist/assets/index-B4-GYqEb.js +0 -50
  128. package/webui/dist/assets/index-BiSMljer.css +0 -1
@@ -0,0 +1,281 @@
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: throw new TypeError(`unknown sandbox operation ${request.op}`);
150
+ }
151
+ }
152
+ /** Both exec paths share this attribution; its finally emits no pid event. */
153
+ async function runWithSpawnAttribution(env, requestId, run) {
154
+ let pending = pendingSpawns.get(env);
155
+ if (pending === undefined) {
156
+ pending = new Set();
157
+ pendingSpawns.set(env, pending);
158
+ }
159
+ pending.add(requestId);
160
+ try {
161
+ return await run();
162
+ }
163
+ finally {
164
+ // processRequest's settlement sweep owns end events, not this finally.
165
+ pending.delete(requestId);
166
+ }
167
+ }
168
+ async function execute(request) {
169
+ const args = argsOf(request);
170
+ const command = args[0];
171
+ const supplied = (args[1] ?? {});
172
+ const options = {
173
+ ...supplied,
174
+ // The worker's process.env is the already-scrubbed per-user allowlist,
175
+ // so inheriting it is the point. The caller's env remains the override.
176
+ onOutput: (text, _context, info) => {
177
+ write({ id: request.id, event: "output", stream: info.stream, text, ...(info.skipped === undefined ? {} : { skipped: encodeValue(info.skipped) }) });
178
+ },
179
+ };
180
+ const cwd = args[2];
181
+ if (typeof cwd !== "string")
182
+ throw new TypeError("exec requires cwd");
183
+ const env = environment(cwd);
184
+ const result = await runWithSpawnAttribution(env, request.id, () => env.exec(command, options, active.get(request.id) === undefined ? BACKGROUND_CONTEXT : withAbortSignal(active.get(request.id).signal, BACKGROUND_CONTEXT)));
185
+ write(result.ok ? { id: request.id, ok: true, value: encodeValue(result.value) } : errorReply(request.id, result.error, "exec"));
186
+ }
187
+ async function dispatchResult(request, controller) {
188
+ const [op, rawArgs, cwd] = request.args;
189
+ if (typeof op !== "string" || !Array.isArray(rawArgs) || typeof cwd !== "string") {
190
+ throw new TypeError("result requires op, args and cwd");
191
+ }
192
+ if (op === "exec") {
193
+ const args = rawArgs.map((arg) => decodeValue(arg));
194
+ const supplied = (args[1] ?? {});
195
+ const options = {
196
+ ...supplied,
197
+ onOutput: (text, _context, info) => {
198
+ write({ id: request.id, event: "output", stream: info.stream, text, ...(info.skipped === undefined ? {} : { skipped: encodeValue(info.skipped) }) });
199
+ },
200
+ };
201
+ const env = environment(cwd);
202
+ return runWithSpawnAttribution(env, request.id, () => env.exec(args[0], options, withAbortSignal(controller.signal, BACKGROUND_CONTEXT)));
203
+ }
204
+ return dispatch({ id: request.id, op, args: [...rawArgs, cwd] });
205
+ }
206
+ function operationResultValue(result, fallbackKind) {
207
+ if (typeof result === "object" && result !== null && "ok" in result && typeof result.ok === "boolean") {
208
+ if (result.ok)
209
+ return result;
210
+ if ("error" in result)
211
+ return { ok: false, error: wireError(result.error, fallbackKind) };
212
+ }
213
+ if (result === undefined)
214
+ return null;
215
+ if (typeof result === "string" || typeof result === "number" || typeof result === "boolean" || typeof result === "object")
216
+ return result;
217
+ throw new TypeError("unsupported operation result");
218
+ }
219
+ async function processRequest(request) {
220
+ if (request.id === 0 && request.op === "abort") {
221
+ const target = request.args[0];
222
+ if (typeof target === "number")
223
+ active.get(target)?.abort();
224
+ return;
225
+ }
226
+ const controller = new AbortController();
227
+ active.set(request.id, controller);
228
+ try {
229
+ if (request.op === "result") {
230
+ const forwarded = { id: request.id, op: "result.dispatch", args: request.args };
231
+ const result = await dispatchResult(forwarded, controller);
232
+ write({ id: request.id, ok: true, value: encodeValue(operationResultValue(result, forwarded.args[0] === "exec" ? "exec" : "file")) });
233
+ return;
234
+ }
235
+ if (request.op === "exec") {
236
+ await execute(request);
237
+ return;
238
+ }
239
+ const result = await dispatch(request);
240
+ write({ id: request.id, ok: true, value: encodeValue(operationResultValue(result, request.op === "exec" ? "exec" : "file")) });
241
+ }
242
+ catch (error) {
243
+ write(errorReply(request.id, error, request.op === "exec" ? "exec" : "file"));
244
+ }
245
+ finally {
246
+ active.delete(request.id);
247
+ // pi-durable removes a pid BEFORE the exec promise resolves. The
248
+ // registry hook therefore sees the request still active; this is the
249
+ // settlement boundary that forgets ordinary pids and emits their end.
250
+ for (const observer of commandObservers.values())
251
+ observer.settleRequest(request.id);
252
+ }
253
+ }
254
+ process.stdin.setEncoding("utf8");
255
+ process.stdin.on("data", (chunk) => {
256
+ buffer += chunk;
257
+ for (;;) {
258
+ const newline = buffer.indexOf("\n");
259
+ if (newline < 0)
260
+ break;
261
+ const line = buffer.slice(0, newline);
262
+ buffer = buffer.slice(newline + 1);
263
+ if (line.trim() === "")
264
+ continue;
265
+ try {
266
+ void processRequest(JSON.parse(line));
267
+ }
268
+ catch (error) {
269
+ process.stderr.write(`sandbox worker request error: ${error instanceof Error ? error.message : String(error)}\n`);
270
+ }
271
+ }
272
+ });
273
+ process.stdin.on("end", () => {
274
+ for (const controller of active.values())
275
+ controller.abort();
276
+ for (const env of environments.values())
277
+ void env.cleanup(BACKGROUND_CONTEXT);
278
+ for (const handle of handles.values())
279
+ void handle.value.close(BACKGROUND_CONTEXT);
280
+ process.exit(0);
281
+ });
package/dist/serve.js CHANGED
@@ -32,10 +32,27 @@ import { startGateway } from "./server/gateway.js";
32
32
  import { SIGRUN_VERSION } from "./server/schema-fixture.js";
33
33
  import { createServer } from "./server/index.js";
34
34
  import { createShutdown } from "./server/shutdown.js";
35
+ /**
36
+ * The deploy entry's default listen host: the IPv6 wildcard, which is a
37
+ * DUAL-STACK listener on Linux (`net.ipv6.bindv6only` is 0) — it accepts
38
+ * the pod's IPv4 connections AND its IPv6 ones.
39
+ *
40
+ * `0.0.0.0` is the WRONG default for a dual-stack cluster: it is a
41
+ * v4-only socket, so it refuses every IPv6 connection, while a Service
42
+ * whose `ipFamilies` is `["IPv6"]` advertises only the pod's IPv6
43
+ * address in its EndpointSlice. The gateway dials that address, gets
44
+ * connection-refused, and answers the browser with "upstream connect
45
+ * error or disconnect/reset before headers. reset reason: remote
46
+ * connection failure". (This cluster's ClusterIP range is
47
+ * `fd00:beef:cafe::/…`, so a chart-default Service is IPv6-only — the
48
+ * mismatch is the DEFAULT shape, not an edge case.)
49
+ */
50
+ export const DEFAULT_LISTEN_HOST = "::";
35
51
  /**
36
52
  * 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;
53
+ * `options.host ?? "::"` + `<port>` (the default is the container's
54
+ * dual-stack wildcard — loopback-only would be unreachable behind the
55
+ * Service, and a v4-only bind is unreachable on an IPv6 endpoint;
39
56
  * `loadConfig` refuses port 0, so a real port is always bound).
40
57
  * The CLI's `run` additionally launches the TUI against the loopback URL
41
58
  * on {@link BootedServer.port}.
@@ -89,22 +106,71 @@ export async function serve(env, options) {
89
106
  // 404s forever (the directory was never created).
90
107
  const { mkdir } = await import("node:fs/promises");
91
108
  await mkdir(config.workspace.path, { recursive: true });
92
- const opened = await openHarness(config, { extensionEntries: extensionResolutions });
109
+ // The serve env threads into boot so boot-time env reads (store
110
+ // roots, the provider collector, the U4 scheduler cadence resolution
111
+ // in `resolveRefreshMinutes`) see the SAME env `loadConfig` just
112
+ // resolved — a serve-harness env override must win over process.env
113
+ // here exactly like it does in the config.
114
+ const opened = await openHarness(config, {
115
+ extensionEntries: extensionResolutions,
116
+ env,
117
+ listenHost: options?.host ?? "0.0.0.0",
118
+ });
93
119
  // The gateway handle (gateway C2) exists BEFORE the server: the meta
94
120
  // route reaches it through ServerContext, so the engine's health is
95
121
  // reportable from the first request. The ENGINE STARTS after listen
96
122
  // (below) — an engine supervising chat transports starts once the
97
123
  // server it delivers through is up.
98
124
  const gateway = startGateway(opened);
125
+ // The booted handle the restart callback defers to: `createServer`
126
+ // needs the callback BEFORE `serve` can build the BootedServer (the
127
+ // shutdown composition lives at the bottom of this function), so the
128
+ // handle is promised here and RESOLVED at return time; `onRestart`
129
+ // awaits the resolution. Between `app.listen` + `gateway.started` and
130
+ // the assignment below a supervised admin POST can already land (202
131
+ // answered, callback deferred) — awaiting the promise holds that
132
+ // window open instead of dropping the restart on the floor (a `?.`
133
+ // on an unassigned ref made 202-with-no-restart reachable).
134
+ let resolveBooted;
135
+ const bootedReady = new Promise((resolve) => {
136
+ resolveBooted = resolve;
137
+ });
99
138
  // The contributed-bundle registry (build-and-webui Task 6): boot folded
100
139
  // the loader's extension contributions into the name-keyed registry;
101
140
  // the server's serve-time NAME-selector resolution reads it here.
102
141
  const app = await createServer(opened, config, opened.webuiBundles, {
103
- listenHost: options?.host ?? "0.0.0.0",
142
+ listenHost: options?.host ?? DEFAULT_LISTEN_HOST,
104
143
  env,
105
144
  gateway,
145
+ // U4 Task 2 (spec §13.3): the admin restart route's callback runs
146
+ // EXACTLY the SIGTERM path (the ordered shutdown), then exits 0 —
147
+ // the supervisor (chart/compose, `server.supervised`) brings the
148
+ // process back. `shutdownOnce` is the same re-entry guard the
149
+ // signal path uses; `stopping` is assigned by closure below, so
150
+ // the callback defers to it (the route fires post-listen).
151
+ onRestart: () => bootedReady
152
+ .then(async (handle) => {
153
+ // Force-exit lever (the review's finding 4): the ordered
154
+ // shutdown's drain is unbounded — a wedged close would be
155
+ // an unrecoverable outage (the supervisor never sees the
156
+ // process die, so it never restarts it). The watchdog is
157
+ // unref'd: the normal path (shutdown settles, exit below)
158
+ // is unaffected.
159
+ const watchdog = setTimeout(() => {
160
+ console.error("sigrun: restart shutdown exceeded 30s; forcing exit");
161
+ process.exit(1);
162
+ }, 30_000);
163
+ watchdog.unref();
164
+ await handle.shutdownOnce();
165
+ clearTimeout(watchdog);
166
+ process.exit(0);
167
+ })
168
+ .catch((error) => {
169
+ console.error("sigrun restart error:", error);
170
+ process.exit(1);
171
+ }),
106
172
  });
107
- const host = options?.host ?? "0.0.0.0";
173
+ const host = options?.host ?? DEFAULT_LISTEN_HOST;
108
174
  await app.listen({ port: config.port, host });
109
175
  // The engine starts with the listener live (gateway C2's lifecycle:
110
176
  // start after listen — a failed start latches degraded inside the
@@ -115,7 +181,10 @@ export async function serve(env, options) {
115
181
  // Machine-greppable: the deploy-entry test waits for this line, and an
116
182
  // operator reading pod logs sees the bound port without guessing. The
117
183
  // 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}`);
184
+ // An IPv6 host is bracketed — the URL form (`http://[::]:3000`), not the
185
+ // ambiguous `http://:::3000`.
186
+ const shownHost = host.includes(":") ? `[${host}]` : host;
187
+ console.log(`sigrun v${SIGRUN_VERSION} listening on http://${shownHost}:${config.port}`);
119
188
  // The ordered shutdown (gateway C2's extraction of the inline original
120
189
  // into src/server/shutdown.ts): SSE streams FIRST, the app drain, the
121
190
  // engine's intake close, the event-bus sequence (drain + flush), the
@@ -126,6 +195,10 @@ export async function serve(env, options) {
126
195
  closeApp: () => app.close(),
127
196
  gateway,
128
197
  eventBusShutdown: () => opened.eventBus.shutdownSequence(BACKGROUND_CONTEXT),
198
+ stopSandboxWorkers: async () => {
199
+ await opened.sandboxPool.stopAll();
200
+ await opened.userServices.stopAll();
201
+ },
129
202
  stopProviderRefresh: async () => {
130
203
  // The interval refresh scheduler (provider plugins Task 5) stops
131
204
  // BEFORE the harness closes. The T4 boot refresh promise is raced
@@ -155,7 +228,7 @@ export async function serve(env, options) {
155
228
  closeHarness: () => opened.harness.close(BACKGROUND_CONTEXT),
156
229
  });
157
230
  let stopping;
158
- return {
231
+ const booted = {
159
232
  port: config.port,
160
233
  shutdown,
161
234
  shutdownOnce: () => {
@@ -163,4 +236,6 @@ export async function serve(env, options) {
163
236
  return stopping;
164
237
  },
165
238
  };
239
+ resolveBooted(booted);
240
+ return booted;
166
241
  }
@@ -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
+ }
@@ -42,6 +42,8 @@
42
42
  import { timingSafeEqual } from "node:crypto";
43
43
  import { ipAllowed } from "./ip.js";
44
44
  import { isLoopbackHost } from "./loopback.js";
45
+ import { AdmissionRefusedError } from "../../harness/identity/users.js";
46
+ import { effectiveAdmin } from "../../harness/identity/groups.js";
45
47
  /** One header read: Fastify headers may be string | string[]; first wins. */
46
48
  function headerOf(request, name) {
47
49
  const value = request.headers[name.toLowerCase()];
@@ -57,7 +59,7 @@ function timingSafeStringEqual(a, b) {
57
59
  return timingSafeEqual(left, right);
58
60
  }
59
61
  /** The request's Bearer credential, if the Authorization header carries one. */
60
- function bearerOf(request) {
62
+ export function bearerOf(request) {
61
63
  const value = headerOf(request, "authorization");
62
64
  if (value === undefined)
63
65
  return undefined;
@@ -65,7 +67,7 @@ function bearerOf(request) {
65
67
  return match?.[1]?.trim() || undefined;
66
68
  }
67
69
  /** The `sigrun_session` cookie value (naive parse; the cookie is ours). */
68
- function sessionCookieOf(request) {
70
+ export function sessionCookieOf(request) {
69
71
  const raw = headerOf(request, "cookie");
70
72
  if (raw === undefined)
71
73
  return undefined;
@@ -160,6 +162,15 @@ function isPublicPath(request, ctx) {
160
162
  return true;
161
163
  if (pattern === "/v1/auth/logout" && request.method === "POST")
162
164
  return true;
165
+ if (pattern === "/v1/auth/providers" && request.method === "GET")
166
+ return true;
167
+ if (pattern === "/v1/auth/login/:provider" && request.method === "GET")
168
+ return true;
169
+ if (pattern === "/v1/auth/login/code" && request.method === "POST")
170
+ return true;
171
+ if (pattern === "/v1/auth/callback/:provider" &&
172
+ (request.method === "GET" || request.method === "POST"))
173
+ return true;
163
174
  if (pattern === "/v1/onboarding" || pattern?.startsWith("/v1/onboarding/")) {
164
175
  return true;
165
176
  }
@@ -209,45 +220,43 @@ async function resolveProxy(request, ctx) {
209
220
  if (existing === undefined && !proxy.autoProvision) {
210
221
  return { status: 403, error: "user_not_provisioned" };
211
222
  }
212
- const groupAdmin = groups.some((group) => adminGroups.includes(group));
213
- const record = existing ??
214
- (await users.add({
215
- user,
216
- ...(headerOf(request, proxy.nameHeader) === undefined
217
- ? {}
218
- : { name: headerOf(request, proxy.nameHeader) }),
219
- ...(headerOf(request, proxy.emailHeader) === undefined
220
- ? {}
221
- : { email: headerOf(request, proxy.emailHeader) }),
222
- admin: false,
223
- // Gateway C8: the grant is stamped at provision — host-side
224
- // checks (chat commands, no headers) read it from the record.
225
- ...(adminGroups.length > 0 ? { groupAdmin } : {}),
226
- source: "proxy",
227
- }));
228
- // Gateway C8 write-through: host-side code has no request headers, so
229
- // the computed group grant must live on the directory record. Only a
230
- // CHANGE writes (the steady state never rewrites the file), and only
231
- // while `adminGroups` is configured — an empty list leaves the field
232
- // untouched (a group-exit clears it at this user's next request under
233
- // a non-empty list, never under an empty one). Absent counts as false;
234
- // the auto-provisioned record was added with the flag already set, so
235
- // the comparison is against the RESOLVED record, never `existing`.
236
- if (adminGroups.length > 0 &&
237
- (record.groupAdmin ?? false) !== groupAdmin) {
238
- await users.update(user, { groupAdmin });
223
+ let record;
224
+ try {
225
+ record = existing ??
226
+ (await users.add({
227
+ user,
228
+ ...(headerOf(request, proxy.nameHeader) === undefined
229
+ ? {}
230
+ : { name: headerOf(request, proxy.nameHeader) }),
231
+ ...(headerOf(request, proxy.emailHeader) === undefined
232
+ ? {}
233
+ : { email: headerOf(request, proxy.emailHeader) }),
234
+ admin: false,
235
+ source: "proxy",
236
+ }));
237
+ }
238
+ catch (error) {
239
+ if (error instanceof AdmissionRefusedError) {
240
+ return { status: 403, error: error.code };
241
+ }
242
+ throw error;
239
243
  }
244
+ // Persist the authenticated source's groups on every proxy request;
245
+ // setGroups avoids the write when the values are unchanged. Effective
246
+ // admin is computed over the UPDATED record, so a group exit revokes
247
+ // the request's grant immediately and host-side checks agree.
248
+ record = await users.setGroups(user, "proxy", groups);
240
249
  return {
241
250
  user: record.user,
242
251
  ...(record.name === undefined ? {} : { name: record.name }),
243
252
  ...(record.email === undefined ? {} : { email: record.email }),
244
- admin: record.admin || groupAdmin,
253
+ admin: effectiveAdmin(record, adminGroups),
245
254
  service: false,
246
255
  method: "proxy",
247
256
  };
248
257
  }
249
258
  /** Stage 5b (token): verify a presented credential against the token store. */
250
- function resolveToken(request, ctx) {
259
+ export function resolveToken(request, ctx) {
251
260
  const presented = bearerOf(request) ?? sessionCookieOf(request);
252
261
  if (presented === undefined)
253
262
  return { status: 401 };
@@ -262,9 +271,14 @@ function resolveToken(request, ctx) {
262
271
  user: user.user,
263
272
  ...(user.name === undefined ? {} : { name: user.name }),
264
273
  ...(user.email === undefined ? {} : { email: user.email }),
265
- admin: user.admin,
274
+ // Token/session credentials are not group sources: derive from the
275
+ // stored record without writing groups on this request path.
276
+ admin: effectiveAdmin(user, ctx.config.auth.adminGroups),
266
277
  service: false,
267
- method: "token",
278
+ method: record.kind === "session" ? "session" : "token",
279
+ ...(record.kind === "session" && record.provider !== undefined
280
+ ? { provider: record.provider }
281
+ : {}),
268
282
  };
269
283
  }
270
284
  /** The ONE request hook. Stages 1→5 in brief order; a stage replies or falls through. */
@@ -0,0 +1,94 @@
1
+ /**
2
+ * The block answer (UI-blocks U5 Task 3, spec §8.4): a turn action
3
+ * (`submit`, default `turn: true`; any action with `turn: true`) makes
4
+ * core submit a user message for the acting user through the normal
5
+ * submission path (`submitOp`):
6
+ *
7
+ * { type: "input",
8
+ * content: textProjection, // "Submitted 'Ranking': 1. …"
9
+ * entry: { kind: "sigrun.blocks.answer",
10
+ * data: { entryId, action, payload, state } },
11
+ * requestId: "blocks:" + actionId }
12
+ *
13
+ * The surface's unnoted change records fold into `content` first and
14
+ * `notedThrough` advances BEFORE the §8.3 note step runs — so the turn
15
+ * sends no separate note for this surface, while changes on OTHER
16
+ * surfaces still note. `requestId` makes a double click one turn.
17
+ */
18
+ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
19
+ import { toText } from "@sigrun/shared/blocks";
20
+ import { submitOp } from "../conversation-ops.js";
21
+ import { readChatBlock } from "./chat-surfaces.js";
22
+ import { NOTE_HEADER } from "./notes.js";
23
+ import { PROJECTION_LOCALE, PROJECTION_WIDTH, } from "../../harness/blocks/tools.js";
24
+ import { BlocksAnswerEntry } from "../../harness/blocks/entries.js";
25
+ import { BlocksStateDoc } from "../../harness/blocks/state.js";
26
+ /**
27
+ * Submit one block answer: fold the surface's unnoted changes and its
28
+ * current projection into the user-visible content, advance the
29
+ * surface's `notedThrough`, then admit the input through `submitOp` with
30
+ * `skipNoteFor` (this surface notes no further this turn) and the
31
+ * answer entry. The route owns access gating and actionId dedup; this
32
+ * caller runs only for a located, write-accessible block whose handler
33
+ * starts a turn.
34
+ *
35
+ * The fold and the `notedThrough` advance run in ONE commit BEFORE the
36
+ * note step (§8.4: "The surface's unnoted change records are folded into
37
+ * `content` first, and `notedThrough` advances, before the §8.3 note
38
+ * step runs"), so the answer turn never double-reports its surface.
39
+ */
40
+ export async function answerBlock(ctx, conversationId, entryId, action, payload, actionId) {
41
+ const { opened } = ctx;
42
+ const block = await readChatBlock(opened.harness, conversationId, entryId);
43
+ if (block === undefined) {
44
+ throw new Error(`answerBlock: block ${entryId} not found`);
45
+ }
46
+ const conversation = await opened.harness.conversation(conversationId, BACKGROUND_CONTEXT);
47
+ if (conversation === undefined) {
48
+ throw new Error(`answerBlock: conversation ${conversationId} not found`);
49
+ }
50
+ // The fold (one commit): read the CURRENT state, build the content
51
+ // (changes + projection), advance notedThrough past every record the
52
+ // fold covers.
53
+ const folded = await conversation.commit(async (tx) => {
54
+ const draft = await tx.doc(BlocksStateDoc, conversationId, entryId, entryId);
55
+ const pending = draft.changes.filter((record) => record.seq > draft.notedThrough);
56
+ const lines = [];
57
+ if (pending.length > 0) {
58
+ const byAuthor = new Map();
59
+ for (const record of pending) {
60
+ const list = byAuthor.get(record.by) ?? [];
61
+ list.push(record.summary);
62
+ byAuthor.set(record.by, list);
63
+ }
64
+ lines.push(NOTE_HEADER);
65
+ for (const [author, summaries] of byAuthor) {
66
+ lines.push(`• ${block.title} (${author}): ${summaries.join("; ")}`);
67
+ }
68
+ }
69
+ lines.push(`Submitted '${block.title}': ${toText(block.tree, draft.data, {
70
+ width: PROJECTION_WIDTH,
71
+ locale: PROJECTION_LOCALE,
72
+ })}`);
73
+ if (pending.length > 0) {
74
+ draft.notedThrough = pending[pending.length - 1].seq;
75
+ }
76
+ return {
77
+ content: lines.join("\n"),
78
+ state: JSON.parse(JSON.stringify(draft.data)),
79
+ };
80
+ }, BACKGROUND_CONTEXT);
81
+ await submitOp(ctx, conversation, folded.content, {
82
+ ...(actionId === undefined ? {} : { requestId: `blocks:${actionId}` }),
83
+ skipNoteFor: entryId,
84
+ entry: {
85
+ kind: BlocksAnswerEntry.kind,
86
+ data: {
87
+ entryId,
88
+ action,
89
+ payload,
90
+ state: folded.state,
91
+ },
92
+ },
93
+ });
94
+ }