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