@sigrun/sigrun 0.2.0 → 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.
@@ -169,6 +169,12 @@ export async function openHarness(config, options = {}) {
169
169
  // I/O here (each file materializes on its first write), so this is
170
170
  // pure construction over paths boot already owns.
171
171
  const identity = options.identity ?? openIdentityStores(roots);
172
+ // The caller-supplied host wins (serving spec §1: `serve()` resolves
173
+ // the ONE effective host — options.host → SIGRUN_LISTEN_HOST →
174
+ // DEFAULT_LISTEN_HOST — and passes it here). The env read below is
175
+ // NOT a second source on the serve path (the option is always set
176
+ // there); it remains for direct `openHarness` callers that never
177
+ // bind a listener (tests) and must derive the same auth posture.
172
178
  const listenHost = options.listenHost ?? env.SIGRUN_LISTEN_HOST ?? "0.0.0.0";
173
179
  const methods = effectiveMethods(config.auth, listenHost);
174
180
  const isolationState = new IsolationState({
@@ -38,7 +38,7 @@ import { basename, dirname, isAbsolute, resolve } from "node:path";
38
38
  import { fileURLToPath, pathToFileURL } from "node:url";
39
39
  import { parsePackageSpecifier, readPackageName } from "./packages.js";
40
40
  /**
41
- * The four built-ins, in load order (codemode before tool-search: both
41
+ * The five built-ins, in load order (codemode before tool-search: both
42
42
  * wrap pi-durable's own extensions and neither depends on the other, but
43
43
  * the order pins the tool-collision story should names ever overlap).
44
44
  * Specifiers are the published package names (Task 3) — npm keys a file:
@@ -171,6 +171,18 @@ export function createSeamHost(identity, kinds, bind, sharedStores, perUserTools
171
171
  return true;
172
172
  }, context);
173
173
  },
174
+ async peekInteraction(conversationId, callId, context) {
175
+ // The non-consuming inspection read (release-readiness-2 §1):
176
+ // the SAME stored projection as snapshotInteraction, with NO
177
+ // path to takeSecretDelivery — an inspector (the gateway
178
+ // notifier, outcome edits, answer correlation) can never eat
179
+ // the owning extension's one-shot raw-secret delivery.
180
+ const h = await harness(context);
181
+ const stored = await h.snapshot(InteractionDoc, conversationId, callId, context);
182
+ if (stored === undefined)
183
+ return undefined;
184
+ return asInteractionRecord(stored);
185
+ },
174
186
  async snapshotInteraction(conversationId, callId, context) {
175
187
  const h = await harness(context);
176
188
  const stored = await h.snapshot(InteractionDoc, conversationId, callId, context);
@@ -53,7 +53,7 @@ export const IDENTITY_TEXT = "You are sigrun, an AI assistant running as a persi
53
53
  * The persona sigrun ships when no `SOUL.md` exists. A present `SOUL.md`
54
54
  * replaces it wholesale; the default is never merged in.
55
55
  */
56
- export const DEFAULT_PERSONA = `You are Sigrun. You choose, and you say why — a valkyrie doesn't dither over the field. You have opinions and you give them straight: if a plan is bad, say it's bad and say what would make it good. Hedging is a way of lying politely.
56
+ export const DEFAULT_PERSONA = `You are Sigrun. You choose, and you say why — choosing is the job, and dithering over the field isn't. You have opinions and you give them straight: if a plan is bad, say it's bad and say what would make it good. Hedging is a way of lying politely.
57
57
 
58
58
  Match your reply to the weight of the ask. No filler openers, no restating the request, no narrating what the user can already see. When you don't know, say so and name what would settle it.
59
59
 
@@ -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,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";
@@ -48,9 +49,88 @@ import { createShutdown } from "./server/shutdown.js";
48
49
  * mismatch is the DEFAULT shape, not an edge case.)
49
50
  */
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
+ }
51
130
  /**
52
131
  * Boot the harness + server composition and listen on
53
- * `options.host ?? "::"` + `<port>` (the default is the container's
132
+ * `options.host ?? env.SIGRUN_LISTEN_HOST ?? "::"` + `<port>` (the
133
+ * default is the container's
54
134
  * dual-stack wildcard — loopback-only would be unreachable behind the
55
135
  * Service, and a v4-only bind is unreachable on an IPv6 endpoint;
56
136
  * `loadConfig` refuses port 0, so a real port is always bound).
@@ -97,9 +177,18 @@ export async function serve(env, options) {
97
177
  // undeclared BEFORE any extension loads. Fail-loud: an uninstallable
98
178
  // declaration fails the boot here, naming the specifier. Steady state
99
179
  // (the dir already matches) costs one manifest read, no subprocess.
100
- const settingsFile = settingsStore.read();
101
180
  const packagesDir = packagesDirOf(defaultSettingsDir());
102
- 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);
103
192
  // The workspace directory must exist before any files endpoint or tool
104
193
  // call touches it. The deploy manifest mounts a volume at /data; the
105
194
  // workspace is /data/workspace. Without this, the first files request
@@ -111,10 +200,17 @@ export async function serve(env, options) {
111
200
  // in `resolveRefreshMinutes`) see the SAME env `loadConfig` just
112
201
  // resolved — a serve-harness env override must win over process.env
113
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;
114
210
  const opened = await openHarness(config, {
115
211
  extensionEntries: extensionResolutions,
116
212
  env,
117
- listenHost: options?.host ?? "0.0.0.0",
213
+ listenHost: host,
118
214
  });
119
215
  // The gateway handle (gateway C2) exists BEFORE the server: the meta
120
216
  // route reaches it through ServerContext, so the engine's health is
@@ -139,7 +235,7 @@ export async function serve(env, options) {
139
235
  // the loader's extension contributions into the name-keyed registry;
140
236
  // the server's serve-time NAME-selector resolution reads it here.
141
237
  const app = await createServer(opened, config, opened.webuiBundles, {
142
- listenHost: options?.host ?? DEFAULT_LISTEN_HOST,
238
+ listenHost: host,
143
239
  env,
144
240
  gateway,
145
241
  // U4 Task 2 (spec §13.3): the admin restart route's callback runs
@@ -170,26 +266,10 @@ export async function serve(env, options) {
170
266
  process.exit(1);
171
267
  }),
172
268
  });
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.
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.
193
273
  const shutdown = createShutdown({
194
274
  endStreams: () => app.sse.endAll(),
195
275
  closeApp: () => app.close(),
@@ -227,6 +307,39 @@ export async function serve(env, options) {
227
307
  },
228
308
  closeHarness: () => opened.harness.close(BACKGROUND_CONTEXT),
229
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}`);
230
343
  let stopping;
231
344
  const booted = {
232
345
  port: config.port,