@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.
- package/CHANGELOG.md +95 -7
- package/README.md +99 -12
- package/dist/blocks/build-stamp.json +13 -0
- package/dist/blocks/builders.d.ts +57 -0
- package/dist/blocks/images.d.ts +26 -0
- package/dist/blocks/index.d.ts +20 -0
- package/dist/blocks/limits.d.ts +25 -0
- package/dist/blocks/placements.d.ts +26 -0
- package/dist/blocks/schema.d.ts +38 -0
- package/dist/blocks/validate.d.ts +73 -0
- package/dist/extension.d.ts +41 -7
- package/dist/harness/boot.js +6 -0
- package/dist/harness/builtins.js +1 -1
- package/dist/harness/seam-host.js +12 -0
- package/dist/harness/sections/context-files.js +1 -1
- package/dist/sandbox/availability.js +69 -20
- package/dist/sandbox/launch.js +11 -0
- package/dist/sandbox/policy.js +24 -3
- package/dist/sandbox/worker.js +4 -24
- package/dist/serve.js +139 -26
- package/dist/server/auth/pipeline.js +52 -3
- package/dist/server/images/cache.js +13 -5
- package/dist/server/index.js +10 -2
- package/dist/server/notifications.js +21 -4
- package/dist/server/routes/images.js +16 -9
- package/dist/server/routes/me.js +1 -1
- package/dist/server/routes/meta.js +4 -0
- package/node_modules/@sigrun/shared/CHANGELOG.md +5 -0
- package/node_modules/@sigrun/shared/dist/views.d.ts +27 -0
- package/node_modules/@sigrun/shared/package.json +1 -1
- package/package.json +8 -9
- package/webui/dist/assets/{index-CxB969qp.js → index-SO2W4jjk.js} +1 -1
- package/webui/dist/index.html +1 -1
- package/CONTEXT.md +0 -163
package/dist/harness/boot.js
CHANGED
|
@@ -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({
|
package/dist/harness/builtins.js
CHANGED
|
@@ -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
|
|
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 —
|
|
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: [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
159
|
-
|
|
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
|
}
|
package/dist/sandbox/launch.js
CHANGED
|
@@ -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,
|
package/dist/sandbox/policy.js
CHANGED
|
@@ -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
|
-
|
|
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();
|
package/dist/sandbox/worker.js
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
//
|
|
176
|
-
//
|
|
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,
|