@sigrun/sigrun 0.2.0 → 0.3.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 (72) hide show
  1. package/CHANGELOG.md +145 -7
  2. package/README.md +333 -13
  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/validate.d.ts +73 -0
  11. package/dist/cli.js +117 -19
  12. package/dist/config.js +21 -2
  13. package/dist/extension.d.ts +48 -8
  14. package/dist/harness/ambient.js +2 -0
  15. package/dist/harness/blocks/state.js +1 -0
  16. package/dist/harness/boot.js +92 -6
  17. package/dist/harness/builtins.js +1 -1
  18. package/dist/harness/context.js +25 -0
  19. package/dist/harness/event-bus.js +25 -14
  20. package/dist/harness/identity/accounts.js +3 -2
  21. package/dist/harness/identity/index.js +2 -1
  22. package/dist/harness/identity/invites.js +26 -9
  23. package/dist/harness/identity/link-sweep.js +47 -0
  24. package/dist/harness/identity/typed-username.js +15 -0
  25. package/dist/harness/identity/users.js +10 -7
  26. package/dist/harness/interactions.js +2 -0
  27. package/dist/harness/loader.js +57 -1
  28. package/dist/harness/migration-snapshot.js +97 -0
  29. package/dist/harness/reports.js +100 -0
  30. package/dist/harness/seam-host.js +23 -1
  31. package/dist/harness/sections/context-files.js +1 -1
  32. package/dist/harness/settings.js +52 -4
  33. package/dist/jobs/docs.js +3 -0
  34. package/dist/jobs/fire.js +2 -0
  35. package/dist/jobs/parked.js +38 -0
  36. package/dist/jobs/task.js +37 -8
  37. package/dist/sandbox/availability.js +69 -20
  38. package/dist/sandbox/launch.js +11 -0
  39. package/dist/sandbox/policy.js +24 -3
  40. package/dist/sandbox/worker.js +4 -24
  41. package/dist/serve.js +162 -40
  42. package/dist/server/auth/ip.js +9 -4
  43. package/dist/server/auth/own-host.js +115 -0
  44. package/dist/server/auth/pipeline.js +93 -28
  45. package/dist/server/auth/proxy-diagnostic.js +60 -0
  46. package/dist/server/auth/proxy-identity.js +134 -0
  47. package/dist/server/auth/proxy-only.js +28 -0
  48. package/dist/server/images/cache.js +13 -5
  49. package/dist/server/index.js +10 -2
  50. package/dist/server/login/resolve.js +88 -26
  51. package/dist/server/notifications.js +21 -4
  52. package/dist/server/routes/admin.js +97 -0
  53. package/dist/server/routes/images.js +16 -9
  54. package/dist/server/routes/login.js +8 -0
  55. package/dist/server/routes/me.js +1 -1
  56. package/dist/server/routes/meta.js +4 -0
  57. package/dist/server/routes/metrics.js +18 -2
  58. package/dist/server/routes/onboarding.js +97 -37
  59. package/dist/server/session-meta.js +1 -0
  60. package/dist/server/shutdown.js +93 -16
  61. package/dist/settings/registry.js +6 -0
  62. package/node_modules/@sigrun/shared/CHANGELOG.md +12 -0
  63. package/node_modules/@sigrun/shared/dist/settings.d.ts +14 -0
  64. package/node_modules/@sigrun/shared/dist/usernames.d.ts +8 -0
  65. package/node_modules/@sigrun/shared/dist/usernames.js +39 -0
  66. package/node_modules/@sigrun/shared/dist/views.d.ts +33 -4
  67. package/node_modules/@sigrun/shared/package.json +5 -1
  68. package/package.json +16 -15
  69. package/webui/dist/assets/{index-CxB969qp.js → index-CEOzj1C4.js} +11 -11
  70. package/webui/dist/index.html +1 -1
  71. package/CONTEXT.md +0 -163
  72. package/dist/server/login/usernames.js +0 -15
@@ -5,6 +5,7 @@ import { tmpdir } from "node:os";
5
5
  import { dirname, join } from "node:path";
6
6
  import { promisify } from "node:util";
7
7
  import { lookupOsAccount } from "./os-account.js";
8
+ import { nodeRuntimePaths } from "./policy.js";
8
9
  const execFileAsync = promisify(execFile);
9
10
  /**
10
11
  * Prove a real identity transition, not merely the presence of a helper.
@@ -25,7 +26,19 @@ export async function probeOsUsers(helper, options) {
25
26
  env: {
26
27
  LC_ALL: "C",
27
28
  SIGRUN_SANDBOX_POLICY: JSON.stringify({
28
- ro: ["/usr", "/usr/bin", "/bin", "/sbin", "/lib", "/lib32", "/lib64", "/etc", "/opt", "/proc", "/sys"],
29
+ ro: [
30
+ "/usr",
31
+ "/usr/bin",
32
+ "/bin",
33
+ "/sbin",
34
+ "/lib",
35
+ "/lib32",
36
+ "/lib64",
37
+ "/etc",
38
+ "/opt",
39
+ "/proc",
40
+ "/sys",
41
+ ],
29
42
  rw: [],
30
43
  minAbi: 6,
31
44
  uid: account.uid,
@@ -56,27 +69,35 @@ export async function runHelperProbe(helper) {
56
69
  return { abi: result.abi };
57
70
  }
58
71
  /** One reader invocation, changing only whether the probe dir is granted. */
59
- async function runSelfTestLeg(helper, reader, probe, probeDir, grantProbe) {
72
+ async function runSelfTestLeg(helper, reader, probe, probeDir, grantProbe, opensslConf) {
73
+ // The runtime grants are exactly what policy.ts derives for the worker:
74
+ // the node binary directory plus every ldd-reported shared-library
75
+ // directory. A package-managed Node (Homebrew, nvm, nix) resolves its
76
+ // libraries OUTSIDE the executable's directory; granting only system
77
+ // prefixes cannot start it, and broadening prefixes is the trap. When
78
+ // ldd is unreadable the derivation degrades to the binary directory
79
+ // alone, and the control leg then fails closed — reported by
80
+ // probeIsolation as a runtime-derivation failure, never as a pass.
60
81
  const ro = [
61
- "/bin",
62
- "/usr/bin",
63
- "/lib",
64
- "/lib64",
65
- "/usr/lib",
66
- "/usr/lib64",
82
+ ...nodeRuntimePaths(process.execPath),
67
83
  dirname(reader),
68
- dirname(process.execPath),
69
84
  ...(grantProbe ? [probeDir] : []),
70
85
  ];
71
86
  return execFileAsync(helper, ["run", "--", process.execPath, reader, probe], {
72
87
  env: {
73
88
  ...process.env,
74
89
  LC_ALL: "C",
90
+ // Node auto-loads OpenSSL config relative to its prefix
91
+ // (--openssl-shared-config). In the jail that file is not granted,
92
+ // and node exits before reaching the reader. Point it at the
93
+ // self-test's own (granted) empty config instead.
94
+ OPENSSL_CONF: opensslConf,
75
95
  SIGRUN_SANDBOX_POLICY: JSON.stringify({ ro, rw: [], minAbi: 6 }),
76
96
  },
77
97
  });
78
98
  }
79
- const SELF_TEST_READER = `
99
+ /** Exported for the layout tests, which run it under synthetic grants. */
100
+ export const SELF_TEST_READER = `
80
101
  console.log("BEGIN");
81
102
  try {
82
103
  const content = require("node:fs").readFileSync(process.argv[2], "utf8");
@@ -96,43 +117,58 @@ try {
96
117
  * line. No locale-dependent stderr text classifies the failure: the errno is
97
118
  * the positive evidence, and BEGIN proves the reader actually executed.
98
119
  */
99
- export async function selfTestHelper(helper) {
120
+ export async function selfTestHelper(helper, onFailure) {
100
121
  const root = await mkdtemp(join(tmpdir(), "sigrun-sandbox-self-test-"));
101
122
  const grantedDir = join(root, "granted");
102
123
  const probeDir = join(root, "probe");
103
124
  const reader = join(grantedDir, "reader.cjs");
104
125
  const probe = join(probeDir, "probe");
126
+ const opensslConf = join(grantedDir, "openssl.cnf");
105
127
  const content = "sandbox boundary probe\n";
106
128
  try {
107
129
  await mkdir(grantedDir);
108
130
  await mkdir(probeDir);
109
131
  await writeFile(reader, SELF_TEST_READER, { mode: 0o600 });
110
132
  await writeFile(probe, content, { mode: 0o600 });
133
+ // An empty config satisfies Node's --openssl-shared-config auto-load
134
+ // without granting any system OpenSSL tree.
135
+ await writeFile(opensslConf, "", { mode: 0o600 });
111
136
  let control;
112
137
  try {
113
- control = await runSelfTestLeg(helper, reader, probe, probeDir, true);
138
+ control = await runSelfTestLeg(helper, reader, probe, probeDir, true, opensslConf);
114
139
  }
115
140
  catch {
141
+ onFailure?.("control");
116
142
  return false;
117
143
  }
118
- if (control.stdout !== `BEGIN\nREAD_OK:${content}\n`)
144
+ if (control.stdout !== `BEGIN\nREAD_OK:${content}\n`) {
145
+ onFailure?.("control");
119
146
  return false;
147
+ }
120
148
  try {
121
- await runSelfTestLeg(helper, reader, probe, probeDir, false);
149
+ await runSelfTestLeg(helper, reader, probe, probeDir, false, opensslConf);
150
+ onFailure?.("denial");
122
151
  return false;
123
152
  }
124
153
  catch (error) {
125
- if (typeof error !== "object" || error === null)
154
+ if (typeof error !== "object" || error === null) {
155
+ onFailure?.("denial");
126
156
  return false;
157
+ }
127
158
  const outcome = error;
128
- if (typeof outcome.stderr !== "string")
159
+ if (typeof outcome.stderr !== "string") {
160
+ onFailure?.("denial");
129
161
  return false;
130
- return (typeof outcome.code === "number" &&
162
+ }
163
+ const denied = typeof outcome.code === "number" &&
131
164
  outcome.code !== 0 &&
132
165
  outcome.stdout === "BEGIN\nREAD_DENIED:EACCES\n" &&
133
166
  !outcome.stderr
134
167
  .split(/\r?\n/)
135
- .some((line) => line.startsWith("sigrun-sandbox:")));
168
+ .some((line) => line.startsWith("sigrun-sandbox:"));
169
+ if (!denied)
170
+ onFailure?.("denial");
171
+ return denied;
136
172
  }
137
173
  }
138
174
  finally {
@@ -155,8 +191,21 @@ export async function probeIsolation(opts) {
155
191
  reason: `landlock ABI ${abi} < 6`,
156
192
  };
157
193
  }
158
- if (!(await opts.selfTest(opts.helper))) {
159
- return { available: false, abi, reason: "self-test failed" };
194
+ // The reason names the leg that failed: a control-leg failure is the
195
+ // runtime-derivation class (the jailed node could not run at all); a
196
+ // denial-leg failure means the isolation boundary itself did not hold.
197
+ let failure;
198
+ const selfTest = opts.selfTest === selfTestHelper
199
+ ? (helper) => selfTestHelper(helper, (leg) => { failure = leg; })
200
+ : opts.selfTest;
201
+ if (!(await selfTest(opts.helper))) {
202
+ return {
203
+ available: false,
204
+ abi,
205
+ reason: failure === "denial"
206
+ ? "self-test failed: isolation boundary did not deny the ungranted read"
207
+ : "self-test failed: node runtime libraries unavailable",
208
+ };
160
209
  }
161
210
  return { available: true, abi, helper: opts.helper };
162
211
  }
@@ -6,6 +6,17 @@ export function launchInJail(helper, policy, command, args, env) {
6
6
  args: ["run", "--", command, ...args],
7
7
  env: {
8
8
  ...env,
9
+ // Node compiled with --openssl-shared-config auto-loads
10
+ // $OPENSSLDIR/openssl.cnf at startup. On a package-managed
11
+ // Node (Homebrew) that directory lives OUTSIDE every policy
12
+ // prefix, and an unreadable config is a FATAL startup error —
13
+ // the jailed worker cannot start node at all. The worker
14
+ // policy grants tmp rw, so point the loader there; node does
15
+ // not require the file to exist. This deliberately overrides
16
+ // any operator-supplied OPENSSL_CONF: an operator config path
17
+ // is not granted inside the jail, so honoring it would be the
18
+ // same fatal startup error.
19
+ OPENSSL_CONF: `${policy.tmp}/openssl.cnf`,
9
20
  SIGRUN_SANDBOX_POLICY: JSON.stringify({
10
21
  ro: policy.ro,
11
22
  rw: policy.rw,
@@ -89,6 +89,14 @@ export function defaultModuleTrees(coreRoot) {
89
89
  : join(coreRoot, "src");
90
90
  return [code, join(coreRoot, "shared"), join(coreRoot, "node_modules")];
91
91
  }
92
+ /**
93
+ * The worker's ESM scope resolution reads coreRoot/package.json even when
94
+ * the entry lives in a granted module tree; an unreadable package config
95
+ * is a fatal startup error, so the worker policy grants this one file.
96
+ */
97
+ export function defaultPackageConfig(coreRoot) {
98
+ return join(coreRoot, "package.json");
99
+ }
92
100
  function validateUserPath(path, record, managed, inheritedFrom) {
93
101
  const candidate = canonical(path) ?? resolve(path);
94
102
  if (inside(candidate, "/dev/shm") || inside("/dev/shm", candidate)) {
@@ -185,7 +193,15 @@ export function buildUserPolicy(record, ctx) {
185
193
  return invalidReason(tmp, "path cannot be resolved");
186
194
  const realWorkspace = workspaceValidation.realpath;
187
195
  const realHome = homeValidation.realpath;
188
- const moduleTrees = canonicalModuleTrees(ctx.moduleTrees, ctx.managed);
196
+ // A source checkout that has ever been built has BOTH src/ and dist/;
197
+ // defaultModuleTrees picks one while the worker entry may use the other
198
+ // (dist worker.js when present, src worker.ts via tsx otherwise).
199
+ // Grant whichever sibling code tree exists so both shapes stay readable.
200
+ const siblingTree = join(ctx.managed.coreRoot, "src");
201
+ const extraTrees = existsSync(siblingTree) && !ctx.moduleTrees.includes(siblingTree)
202
+ ? [...ctx.moduleTrees, siblingTree]
203
+ : ctx.moduleTrees;
204
+ const moduleTrees = canonicalModuleTrees(extraTrees, ctx.managed);
189
205
  if (!moduleTrees.ok)
190
206
  return moduleTrees;
191
207
  const grants = [];
@@ -200,6 +216,12 @@ export function buildUserPolicy(record, ctx) {
200
216
  ...grants.flatMap((grant) => (grant.access === "rw" ? [grant.path] : [])),
201
217
  realHome,
202
218
  realTmp,
219
+ // Device creation belongs to rw, but the selected devices' ordinary
220
+ // read/write I/O survives a read-only Landlock rule. rw stays empty
221
+ // except for workspace/home/tmp/grants so /dev/shm remains denied.
222
+ ]);
223
+ const ro = sortedUnique([
224
+ "/dev",
203
225
  "/dev/null",
204
226
  "/dev/zero",
205
227
  "/dev/full",
@@ -207,8 +229,6 @@ export function buildUserPolicy(record, ctx) {
207
229
  "/dev/urandom",
208
230
  "/dev/tty",
209
231
  "/dev/pts",
210
- ]);
211
- const ro = sortedUnique([
212
232
  "/usr",
213
233
  "/bin",
214
234
  "/sbin",
@@ -221,6 +241,7 @@ export function buildUserPolicy(record, ctx) {
221
241
  "/sys",
222
242
  ...nodeRuntimePaths(process.execPath, ctx.managed),
223
243
  ...moduleTrees.paths,
244
+ defaultPackageConfig(ctx.managed.coreRoot),
224
245
  ...grants.flatMap((grant) => (grant.access === "ro" ? [grant.path] : [])),
225
246
  ]);
226
247
  const env = agentEnv();
@@ -146,7 +146,10 @@ async function dispatch(request) {
146
146
  case "remove": return env.remove(args[0], args[1], BACKGROUND_CONTEXT);
147
147
  case "createTempDir": return env.createTempDir(args[0], BACKGROUND_CONTEXT);
148
148
  case "createTempFile": return env.createTempFile(args[0], BACKGROUND_CONTEXT);
149
- default: throw new TypeError(`unknown sandbox operation ${request.op}`);
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}`);
150
153
  }
151
154
  }
152
155
  /** Both exec paths share this attribution; its finally emits no pid event. */
@@ -165,25 +168,6 @@ async function runWithSpawnAttribution(env, requestId, run) {
165
168
  pending.delete(requestId);
166
169
  }
167
170
  }
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
171
  async function dispatchResult(request, controller) {
188
172
  const [op, rawArgs, cwd] = request.args;
189
173
  if (typeof op !== "string" || !Array.isArray(rawArgs) || typeof cwd !== "string") {
@@ -232,10 +216,6 @@ async function processRequest(request) {
232
216
  write({ id: request.id, ok: true, value: encodeValue(operationResultValue(result, forwarded.args[0] === "exec" ? "exec" : "file")) });
233
217
  return;
234
218
  }
235
- if (request.op === "exec") {
236
- await execute(request);
237
- return;
238
- }
239
219
  const result = await dispatch(request);
240
220
  write({ id: request.id, ok: true, value: encodeValue(operationResultValue(result, request.op === "exec" ? "exec" : "file")) });
241
221
  }
package/dist/serve.js CHANGED
@@ -24,14 +24,30 @@
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";
31
32
  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
- import { createShutdown } from "./server/shutdown.js";
35
+ import { createShutdown, PROVIDER_REFRESH_SHUTDOWN_CAP_MS, shutdownBudgetMs, } from "./server/shutdown.js";
36
+ /**
37
+ * Arm the admin-restart force-exit watchdog. The injected timer/exit seams
38
+ * make the contract testable without exiting the suite's process. Returns
39
+ * the disarm function used after a clean ordered shutdown.
40
+ */
41
+ export function armRestartWatchdog(budgetMs, opts) {
42
+ const setTimer = opts?.setTimer ?? setTimeout;
43
+ const exit = opts?.exit ?? process.exit;
44
+ const watchdog = setTimer(() => {
45
+ console.error(`sigrun: restart shutdown exceeded ${budgetMs}ms; forcing exit`);
46
+ exit(1);
47
+ }, budgetMs);
48
+ watchdog.unref?.();
49
+ return () => clearTimeout(watchdog);
50
+ }
35
51
  /**
36
52
  * The deploy entry's default listen host: the IPv6 wildcard, which is a
37
53
  * DUAL-STACK listener on Linux (`net.ipv6.bindv6only` is 0) — it accepts
@@ -48,9 +64,88 @@ import { createShutdown } from "./server/shutdown.js";
48
64
  * mismatch is the DEFAULT shape, not an edge case.)
49
65
  */
50
66
  export const DEFAULT_LISTEN_HOST = "::";
67
+ export const LISTENER_PROBE_TIMEOUT_MS = 5_000;
68
+ /** Test-only seam for the post-listen failure boundary; production uses the real probe. */
69
+ export const listenerProbe = { connect: probeLoopback };
70
+ /**
71
+ * One loopback probe: whether a TCP connection to `host:port` succeeds.
72
+ * Bounded by {@link LISTENER_PROBE_TIMEOUT_MS}: a silently dropped
73
+ * loopback connect resolves `false` (never hangs boot — an inconclusive
74
+ * measurement must fail loudly, spec §2).
75
+ */
76
+ function probeLoopback(host, port) {
77
+ return new Promise((resolve) => {
78
+ const socket = net.connect({ host, port });
79
+ const settle = (ok) => {
80
+ clearTimeout(timer);
81
+ socket.destroy();
82
+ resolve(ok);
83
+ };
84
+ const timer = setTimeout(() => settle(false), LISTENER_PROBE_TIMEOUT_MS);
85
+ timer.unref();
86
+ socket.once("connect", () => settle(true));
87
+ socket.once("error", () => settle(false));
88
+ });
89
+ }
90
+ /**
91
+ * Measure the live listener's address families ONCE (release-readiness
92
+ * Task 1, serving spec §2) and return the retained {@link ListenerInfo}.
93
+ *
94
+ * - A literal IPv4/IPv6 host reports its family without probing.
95
+ * - A hostname bind reports the bound socket's family (`address()` —
96
+ * Node binds ONE resolved address for a hostname listen). A hostname
97
+ * or wildcard is never reported as a concrete peer address: the wire
98
+ * host stays the configured string.
99
+ * - The `::` wildcard does NOT assume dual-stack from the spelling: one
100
+ * startup loopback probe against `127.0.0.1` and `[::1]` on the bound
101
+ * port, retaining exactly the families that connect. Absence of
102
+ * exactly one family is a legitimate result (an IPv6-only host).
103
+ * INCONCLUSIVE — neither loopback connects while the listener is bound
104
+ * — throws: serving fails loudly rather than advertising unmeasured
105
+ * reachability.
106
+ *
107
+ * @param host the effective host string supplied to `listen`.
108
+ * @param address `app.server.address()` after listen (null → loud).
109
+ * @param connect the probe (tests inject; production is
110
+ * {@link probeLoopback}).
111
+ */
112
+ export async function measureListener(host, address, connect = probeLoopback) {
113
+ if (address === null || typeof address === "string") {
114
+ throw new Error("listener measurement failed: the server reports no bound address");
115
+ }
116
+ const port = address.port;
117
+ const literal = net.isIP(host);
118
+ if (literal === 4)
119
+ return { host, port, families: ["ipv4"] };
120
+ if (literal === 6 && address.address !== "::") {
121
+ return { host, port, families: ["ipv6"] };
122
+ }
123
+ if (literal === 0) {
124
+ // A hostname bind: the bound socket's family, verbatim.
125
+ const family = address.family === "IPv4" || address.family === 4 ? "ipv4" : "ipv6";
126
+ return { host, port, families: [family] };
127
+ }
128
+ // The BOUND socket identifies the wildcard: Node canonicalizes alternate
129
+ // spellings such as ::0 to ::. Measure, never infer dual-stack from the
130
+ // configured spelling.
131
+ const [v4, v6] = await Promise.all([
132
+ connect("127.0.0.1", port),
133
+ connect("::1", port),
134
+ ]);
135
+ const families = [];
136
+ if (v4)
137
+ families.push("ipv4");
138
+ if (v6)
139
+ families.push("ipv6");
140
+ if (families.length === 0) {
141
+ throw new Error(`listener reachability check inconclusive on ${host}:${port} — neither 127.0.0.1 nor [::1] connected while the listener is bound`);
142
+ }
143
+ return { host, port, families };
144
+ }
51
145
  /**
52
146
  * Boot the harness + server composition and listen on
53
- * `options.host ?? "::"` + `<port>` (the default is the container's
147
+ * `options.host ?? env.SIGRUN_LISTEN_HOST ?? "::"` + `<port>` (the
148
+ * default is the container's
54
149
  * dual-stack wildcard — loopback-only would be unreachable behind the
55
150
  * Service, and a v4-only bind is unreachable on an IPv6 endpoint;
56
151
  * `loadConfig` refuses port 0, so a real port is always bound).
@@ -97,9 +192,18 @@ export async function serve(env, options) {
97
192
  // undeclared BEFORE any extension loads. Fail-loud: an uninstallable
98
193
  // declaration fails the boot here, naming the specifier. Steady state
99
194
  // (the dir already matches) costs one manifest read, no subprocess.
100
- const settingsFile = settingsStore.read();
101
195
  const packagesDir = packagesDirOf(defaultSettingsDir());
102
- const extensionResolutions = await syncPackages(settingsFile.extensions ?? [], packagesDir);
196
+ // The MERGED extension spec list (env SIGRUN_EXTENSIONS over the file —
197
+ // the same precedence loadConfig applied above), not the file's list
198
+ // alone: an env-declared extension must install and load even when the
199
+ // file declares none (e.g. a pre-existing settings file blocks the
200
+ // bootstrap mirror). Merge at SPEC level: config.extensions is the
201
+ // loader's flattened string[] view (entry/ref/enabled already
202
+ // discarded); syncPackages needs the full ExtensionSpec objects, so the
203
+ // env list or the file's verbatim entries feed it directly.
204
+ const settingsFile = settingsStore.read();
205
+ const extensionSpecs = parseExtensionList(env.SIGRUN_EXTENSIONS) ?? settingsFile.extensions ?? [];
206
+ const extensionResolutions = await syncPackages(extensionSpecs, packagesDir);
103
207
  // The workspace directory must exist before any files endpoint or tool
104
208
  // call touches it. The deploy manifest mounts a volume at /data; the
105
209
  // workspace is /data/workspace. Without this, the first files request
@@ -111,10 +215,17 @@ export async function serve(env, options) {
111
215
  // in `resolveRefreshMinutes`) see the SAME env `loadConfig` just
112
216
  // resolved — a serve-harness env override must win over process.env
113
217
  // here exactly like it does in the config.
218
+ // The ONE listener decision (release-readiness Task 1, serving spec §1):
219
+ // `options.host` → `SIGRUN_LISTEN_HOST` (from the env `serve` received)
220
+ // → `DEFAULT_LISTEN_HOST`, resolved ONCE here and handed verbatim to
221
+ // `openHarness` (the boot-side auth derivation), `createServer` (the
222
+ // pipeline's Host check + method list), and `app.listen` (the actual
223
+ // bind) — the three consumers can never disagree.
224
+ const host = options?.host ?? env.SIGRUN_LISTEN_HOST ?? DEFAULT_LISTEN_HOST;
114
225
  const opened = await openHarness(config, {
115
226
  extensionEntries: extensionResolutions,
116
227
  env,
117
- listenHost: options?.host ?? "0.0.0.0",
228
+ listenHost: host,
118
229
  });
119
230
  // The gateway handle (gateway C2) exists BEFORE the server: the meta
120
231
  // route reaches it through ServerContext, so the engine's health is
@@ -139,7 +250,7 @@ export async function serve(env, options) {
139
250
  // the loader's extension contributions into the name-keyed registry;
140
251
  // the server's serve-time NAME-selector resolution reads it here.
141
252
  const app = await createServer(opened, config, opened.webuiBundles, {
142
- listenHost: options?.host ?? DEFAULT_LISTEN_HOST,
253
+ listenHost: host,
143
254
  env,
144
255
  gateway,
145
256
  // U4 Task 2 (spec §13.3): the admin restart route's callback runs
@@ -150,19 +261,13 @@ export async function serve(env, options) {
150
261
  // the callback defers to it (the route fires post-listen).
151
262
  onRestart: () => bootedReady
152
263
  .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();
264
+ // A wedged stage must never leave the supervisor waiting for
265
+ // a process that cannot die. The watchdog shares the ordered
266
+ // shutdown's computed fixed budget; it is unref'd, so the
267
+ // normal path (shutdown settles, exit below) is unaffected.
268
+ const disarmWatchdog = armRestartWatchdog(shutdownBudgetMs(config.memorySeam));
164
269
  await handle.shutdownOnce();
165
- clearTimeout(watchdog);
270
+ disarmWatchdog();
166
271
  process.exit(0);
167
272
  })
168
273
  .catch((error) => {
@@ -170,26 +275,10 @@ export async function serve(env, options) {
170
275
  process.exit(1);
171
276
  }),
172
277
  });
173
- const host = options?.host ?? DEFAULT_LISTEN_HOST;
174
- await app.listen({ port: config.port, host });
175
- // The engine starts with the listener live (gateway C2's lifecycle:
176
- // start after listen — a failed start latches degraded inside the
177
- // handle, never wedges boot). AWAITED: serve() resolves only once the
178
- // engine start completed (or failed-and-latched) — the constraint's
179
- // intent; the lazy `started` never rejects, so this await cannot throw.
180
- await gateway.started;
181
- // Machine-greppable: the deploy-entry test waits for this line, and an
182
- // operator reading pod logs sees the bound port without guessing. The
183
- // version rides the same line (what an operator quotes in a bug report).
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}`);
188
- // The ordered shutdown (gateway C2's extraction of the inline original
189
- // into src/server/shutdown.ts): SSE streams FIRST, the app drain, the
190
- // engine's intake close, the event-bus sequence (drain + flush), the
191
- // engine stop, the refresh stop + bounded boot-refresh join, the
192
- // harness close — each failure collected, all steps always run.
278
+ // The ordered shutdown exists before listen so a failed measurement can
279
+ // run the SAME cleanup as an ordinary stop. In particular, a rejected
280
+ // serve() must not leave the bound listener or harness keeping the
281
+ // caller's process alive.
193
282
  const shutdown = createShutdown({
194
283
  endStreams: () => app.sse.endAll(),
195
284
  closeApp: () => app.close(),
@@ -213,7 +302,7 @@ export async function serve(env, options) {
213
302
  joined = true;
214
303
  })
215
304
  .catch(() => { });
216
- const BOOT_REFRESH_JOIN_MS = 2_000;
305
+ const BOOT_REFRESH_JOIN_MS = PROVIDER_REFRESH_SHUTDOWN_CAP_MS;
217
306
  await Promise.race([
218
307
  join,
219
308
  new Promise((resolve) => {
@@ -227,6 +316,39 @@ export async function serve(env, options) {
227
316
  },
228
317
  closeHarness: () => opened.harness.close(BACKGROUND_CONTEXT),
229
318
  });
319
+ await app.listen({ port: config.port, host });
320
+ // Measure the live listener ONCE and retain the facts (spec §2): the
321
+ // meta route projects this record; no probe ever runs per request. A
322
+ // failed or inconclusive measurement closes the listener and harness,
323
+ // then rethrows — serving fails loudly without stranding live resources.
324
+ let listener;
325
+ try {
326
+ listener = await measureListener(host, app.server.address(), listenerProbe.connect);
327
+ }
328
+ catch (error) {
329
+ try {
330
+ await shutdown();
331
+ }
332
+ catch (cleanupError) {
333
+ throw new AggregateError([error, cleanupError], "sigrun boot and boot-failure cleanup failed");
334
+ }
335
+ throw error;
336
+ }
337
+ app.sigrunContext.listener
338
+ .families = listener.families;
339
+ // The engine starts with the listener live (gateway C2's lifecycle:
340
+ // start after listen — a failed start latches degraded inside the
341
+ // handle, never wedges boot). AWAITED: serve() resolves only once the
342
+ // engine start completed (or failed-and-latched) — the constraint's
343
+ // intent; the lazy `started` never rejects, so this await cannot throw.
344
+ await gateway.started;
345
+ // Machine-greppable: the deploy-entry test waits for this line, and an
346
+ // operator reading pod logs sees the bound port without guessing. The
347
+ // version rides the same line (what an operator quotes in a bug report).
348
+ // An IPv6 host is bracketed — the URL form (`http://[::]:3000`), not the
349
+ // ambiguous `http://:::3000`.
350
+ const shownHost = host.includes(":") ? `[${host}]` : host;
351
+ console.log(`sigrun v${SIGRUN_VERSION} listening on http://${shownHost}:${config.port}`);
230
352
  let stopping;
231
353
  const booted = {
232
354
  port: config.port,
@@ -6,6 +6,14 @@
6
6
  * allowlist spells v4 ranges.
7
7
  */
8
8
  import { BlockList, isIP } from "node:net";
9
+ /** Fold a v4-mapped v6 spelling (`::ffff:10.42.3.4`) to the v4 address. */
10
+ export function normalizePeerAddress(address) {
11
+ const trimmed = address.trim();
12
+ if (trimmed.startsWith("::ffff:") && isIP(trimmed.slice(7)) === 4) {
13
+ return trimmed.slice(7);
14
+ }
15
+ return trimmed;
16
+ }
9
17
  /**
10
18
  * Whether `address` (a socket remote address) falls in any of `cidrs`
11
19
  * (CIDR ranges or bare IPs). Malformed entries never match; an
@@ -14,10 +22,7 @@ import { BlockList, isIP } from "node:net";
14
22
  export function ipAllowed(address, cidrs) {
15
23
  if (address === undefined)
16
24
  return false;
17
- let candidate = address.trim();
18
- if (candidate.startsWith("::ffff:") && isIP(candidate.slice(7)) === 4) {
19
- candidate = candidate.slice(7);
20
- }
25
+ const candidate = normalizePeerAddress(address);
21
26
  const family = isIP(candidate);
22
27
  if (family === 0)
23
28
  return false;