@sigrun/sigrun 0.1.5 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +136 -0
- package/README.md +352 -36
- package/THIRD_PARTY_NOTICES.md +23 -0
- 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/agent-env.js +62 -0
- 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 -55
- 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 +15 -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 +13 -8
- 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-B4-GYqEb.js +0 -50
- package/webui/dist/assets/index-BiSMljer.css +0 -1
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
/** Core-owned per-user service processes. Workers and services are siblings. */
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { realpathSync } from "node:fs";
|
|
4
|
+
import { resolve, sep } from "node:path";
|
|
5
|
+
import { validateGrantPath } from "../sandbox/grants.js";
|
|
6
|
+
/** Replay is capped so a noisy service cannot grow core memory without bound. */
|
|
7
|
+
export const SERVICE_STDIO_REPLAY_BYTES = 64 * 1024;
|
|
8
|
+
const SERVICE_STDIO_TRUNCATED = "[service output truncated]\n";
|
|
9
|
+
class SharedServiceStream {
|
|
10
|
+
chunks = [];
|
|
11
|
+
bytes = 0;
|
|
12
|
+
truncated = false;
|
|
13
|
+
handles = 0;
|
|
14
|
+
controllers = new Set();
|
|
15
|
+
ended = false;
|
|
16
|
+
get retainedBytes() { return this.bytes; }
|
|
17
|
+
setHandles(count) {
|
|
18
|
+
this.handles = count;
|
|
19
|
+
if (count === 0) {
|
|
20
|
+
this.chunks.length = 0;
|
|
21
|
+
this.bytes = 0;
|
|
22
|
+
this.truncated = false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
stream() {
|
|
26
|
+
return new ReadableStream({
|
|
27
|
+
start: (controller) => {
|
|
28
|
+
if (this.truncated)
|
|
29
|
+
controller.enqueue(new TextEncoder().encode(SERVICE_STDIO_TRUNCATED));
|
|
30
|
+
for (const chunk of this.chunks)
|
|
31
|
+
controller.enqueue(chunk);
|
|
32
|
+
if (this.ended)
|
|
33
|
+
controller.close();
|
|
34
|
+
else
|
|
35
|
+
this.controllers.add(controller);
|
|
36
|
+
},
|
|
37
|
+
cancel: (reason) => {
|
|
38
|
+
for (const controller of this.controllers) {
|
|
39
|
+
try {
|
|
40
|
+
controller.error(reason);
|
|
41
|
+
}
|
|
42
|
+
catch { /* already closed */ }
|
|
43
|
+
}
|
|
44
|
+
this.controllers.clear();
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
write(chunk) {
|
|
49
|
+
if (this.ended || this.handles === 0)
|
|
50
|
+
return;
|
|
51
|
+
const copy = new Uint8Array(chunk);
|
|
52
|
+
this.chunks.push(copy);
|
|
53
|
+
this.bytes += copy.byteLength;
|
|
54
|
+
while (this.bytes > SERVICE_STDIO_REPLAY_BYTES && this.chunks.length > 0) {
|
|
55
|
+
const dropped = this.chunks.shift();
|
|
56
|
+
this.bytes -= dropped.byteLength;
|
|
57
|
+
this.truncated = true;
|
|
58
|
+
}
|
|
59
|
+
if (this.bytes > SERVICE_STDIO_REPLAY_BYTES) {
|
|
60
|
+
const last = this.chunks[this.chunks.length - 1];
|
|
61
|
+
const excess = this.bytes - SERVICE_STDIO_REPLAY_BYTES;
|
|
62
|
+
this.chunks[0] = last.subarray(excess);
|
|
63
|
+
this.bytes = SERVICE_STDIO_REPLAY_BYTES;
|
|
64
|
+
this.truncated = true;
|
|
65
|
+
}
|
|
66
|
+
for (const controller of this.controllers) {
|
|
67
|
+
try {
|
|
68
|
+
controller.enqueue(copy);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
this.controllers.delete(controller);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
end() {
|
|
76
|
+
if (this.ended)
|
|
77
|
+
return;
|
|
78
|
+
this.ended = true;
|
|
79
|
+
for (const controller of this.controllers) {
|
|
80
|
+
try {
|
|
81
|
+
controller.close();
|
|
82
|
+
}
|
|
83
|
+
catch { /* already closed */ }
|
|
84
|
+
}
|
|
85
|
+
this.controllers.clear();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function within(path, root) {
|
|
89
|
+
return path === root || path.startsWith(root.endsWith(sep) ? root : root + sep);
|
|
90
|
+
}
|
|
91
|
+
function packageDirectory(entry, packageDir) {
|
|
92
|
+
if (packageDir !== undefined)
|
|
93
|
+
return realpathSync(packageDir);
|
|
94
|
+
const path = entry.startsWith("file://") ? new URL(entry) : undefined;
|
|
95
|
+
return realpathSync(path === undefined ? resolve(entry, "..") : new URL(".", path));
|
|
96
|
+
}
|
|
97
|
+
function tail(lines) {
|
|
98
|
+
return lines.slice(-20).join("\n");
|
|
99
|
+
}
|
|
100
|
+
function serviceError(entry) {
|
|
101
|
+
return new Error(`service ${entry.slug}/${entry.name} for ${entry.user} failed${entry.lastError === undefined || entry.lastError === "" ? "" : `:\n${entry.lastError}`}`);
|
|
102
|
+
}
|
|
103
|
+
export class UserServices {
|
|
104
|
+
deps;
|
|
105
|
+
entries = new Map();
|
|
106
|
+
declarations = new Map();
|
|
107
|
+
directories = new Map();
|
|
108
|
+
closed = false;
|
|
109
|
+
constructor(deps) {
|
|
110
|
+
this.deps = deps;
|
|
111
|
+
}
|
|
112
|
+
register(slug, declarations, packageDir) {
|
|
113
|
+
this.declarations.set(slug, declarations);
|
|
114
|
+
this.directories.set(slug, packageDir);
|
|
115
|
+
}
|
|
116
|
+
/** A load/re-key failure stops everything the extension had started. */
|
|
117
|
+
quarantine(slug) {
|
|
118
|
+
this.declarations.delete(slug);
|
|
119
|
+
this.directories.delete(slug);
|
|
120
|
+
return this.stopSlug(slug);
|
|
121
|
+
}
|
|
122
|
+
/** Only a committed write to THIS user's settings for THIS slug clears its failure fence. */
|
|
123
|
+
settingsChanged(slug, user) {
|
|
124
|
+
for (const entry of this.entries.values()) {
|
|
125
|
+
if (entry.slug === slug && entry.user === user) {
|
|
126
|
+
entry.failures = [];
|
|
127
|
+
entry.lastError = undefined;
|
|
128
|
+
if (entry.live === undefined)
|
|
129
|
+
this.entries.delete(entry.key);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/** Retained replay bytes, exposed for lifecycle diagnostics and tests. */
|
|
134
|
+
retainedStdioBytes(slug, name, user) {
|
|
135
|
+
const live = this.entries.get(`${slug}\0${name}\0${user}`)?.live;
|
|
136
|
+
return { stdout: live?.stdout.retainedBytes ?? 0, stderr: live?.stderrStream.retainedBytes ?? 0 };
|
|
137
|
+
}
|
|
138
|
+
statuses() {
|
|
139
|
+
return [...this.entries.values()].flatMap((entry) => {
|
|
140
|
+
const live = entry.live;
|
|
141
|
+
const failed = live === undefined && entry.failures.length >= 3;
|
|
142
|
+
// A starting process is not failed, and one/two crashes are
|
|
143
|
+
// retryable rather than a persistent failed service. Do not expose
|
|
144
|
+
// a stale entry in either case; the next connect owns the restart.
|
|
145
|
+
if (live === undefined && !failed)
|
|
146
|
+
return [];
|
|
147
|
+
return [{
|
|
148
|
+
slug: entry.slug,
|
|
149
|
+
name: entry.name,
|
|
150
|
+
user: entry.user,
|
|
151
|
+
state: failed ? "failed" : live.handles === 0 ? "idle" : "running",
|
|
152
|
+
...(failed && entry.lastError !== undefined ? { lastError: entry.lastError } : {}),
|
|
153
|
+
}];
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
async connect(slug, name, user, ctx = { abortSignal: undefined }) {
|
|
157
|
+
if (this.closed)
|
|
158
|
+
throw new Error("user services are stopped");
|
|
159
|
+
const declaration = this.declarations.get(slug)?.find((candidate) => candidate.name === name);
|
|
160
|
+
if (declaration === undefined)
|
|
161
|
+
throw new Error(`unknown service "${slug}/${name}"`);
|
|
162
|
+
const key = `${slug}\0${name}\0${user}`;
|
|
163
|
+
let entry = this.entries.get(key);
|
|
164
|
+
if (entry === undefined) {
|
|
165
|
+
entry = {
|
|
166
|
+
key, slug, name, user, declaration,
|
|
167
|
+
packageDir: this.directories.get(slug),
|
|
168
|
+
failures: [],
|
|
169
|
+
terminal: false,
|
|
170
|
+
};
|
|
171
|
+
// Failed entries stay in the map across explicit stops, so this new
|
|
172
|
+
// entry is used only when there is no crash history to preserve.
|
|
173
|
+
this.entries.set(key, entry);
|
|
174
|
+
}
|
|
175
|
+
if (entry.live === undefined && entry.failures.length >= 3)
|
|
176
|
+
throw serviceError(entry);
|
|
177
|
+
if (entry.live !== undefined)
|
|
178
|
+
return this.attach(entry);
|
|
179
|
+
if (entry.starting === undefined) {
|
|
180
|
+
const starting = this.start(entry, ctx);
|
|
181
|
+
entry.starting = starting;
|
|
182
|
+
void starting.finally(() => {
|
|
183
|
+
if (entry?.starting === starting)
|
|
184
|
+
entry.starting = undefined;
|
|
185
|
+
}).catch(() => { });
|
|
186
|
+
}
|
|
187
|
+
await entry.starting;
|
|
188
|
+
return this.attach(entry);
|
|
189
|
+
}
|
|
190
|
+
async start(entry, ctx) {
|
|
191
|
+
const spec = await entry.declaration.start(entry.user, ctx);
|
|
192
|
+
if (spec === undefined) {
|
|
193
|
+
this.entries.delete(entry.key);
|
|
194
|
+
throw new Error("service not available for this user");
|
|
195
|
+
}
|
|
196
|
+
const base = this.deps.policyFor(entry.user);
|
|
197
|
+
if ("reason" in base)
|
|
198
|
+
throw new Error(base.reason);
|
|
199
|
+
let launchPolicy = base;
|
|
200
|
+
if (this.deps.isolationActive()) {
|
|
201
|
+
const readPaths = [entry.packageDir];
|
|
202
|
+
if (this.deps.managed !== undefined) {
|
|
203
|
+
for (const path of spec.readPaths ?? []) {
|
|
204
|
+
const real = realpathSync(path);
|
|
205
|
+
if (!within(real, entry.packageDir)) {
|
|
206
|
+
const result = validateGrantPath(path, { managed: this.deps.managed, user: entry.user });
|
|
207
|
+
if (!result.ok)
|
|
208
|
+
throw new Error(`${path}: ${result.reason}`);
|
|
209
|
+
readPaths.push(result.realpath);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
readPaths.push(...(spec.readPaths ?? []));
|
|
215
|
+
}
|
|
216
|
+
launchPolicy = { ...base, ro: [...new Set([...base.ro, ...readPaths])].sort() };
|
|
217
|
+
}
|
|
218
|
+
const env = { ...base.env, ...spec.env };
|
|
219
|
+
delete env.SSH_AUTH_SOCK;
|
|
220
|
+
const active = this.deps.isolationActive();
|
|
221
|
+
const launch = active
|
|
222
|
+
? this.deps.launch(launchPolicy, spec.command, spec.args, env)
|
|
223
|
+
: { command: spec.command, args: [...spec.args], env };
|
|
224
|
+
const child = spawn(launch.command, launch.args, {
|
|
225
|
+
env: launch.env,
|
|
226
|
+
cwd: spec.cwd ?? base.home,
|
|
227
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
228
|
+
windowsHide: true,
|
|
229
|
+
});
|
|
230
|
+
entry.live = this.observe(entry, child);
|
|
231
|
+
}
|
|
232
|
+
observe(entry, child) {
|
|
233
|
+
const live = {
|
|
234
|
+
child,
|
|
235
|
+
handles: 0,
|
|
236
|
+
lastTraffic: this.deps.now(),
|
|
237
|
+
stderr: [],
|
|
238
|
+
stdout: new SharedServiceStream(),
|
|
239
|
+
stderrStream: new SharedServiceStream(),
|
|
240
|
+
terminal: false,
|
|
241
|
+
exited: Promise.resolve({ code: null, signal: null }),
|
|
242
|
+
};
|
|
243
|
+
const traffic = () => {
|
|
244
|
+
live.lastTraffic = this.deps.now();
|
|
245
|
+
this.armIdle(entry);
|
|
246
|
+
};
|
|
247
|
+
child.stdout.on("data", (chunk) => {
|
|
248
|
+
live.stdout.write(new Uint8Array(chunk));
|
|
249
|
+
traffic();
|
|
250
|
+
});
|
|
251
|
+
let pendingStderr = "";
|
|
252
|
+
child.stderr.on("data", (chunk) => {
|
|
253
|
+
live.stderrStream.write(new Uint8Array(chunk));
|
|
254
|
+
traffic();
|
|
255
|
+
pendingStderr += chunk.toString();
|
|
256
|
+
const lines = pendingStderr.split("\n");
|
|
257
|
+
pendingStderr = lines.pop() ?? "";
|
|
258
|
+
live.stderr.push(...lines.filter((line) => line !== ""));
|
|
259
|
+
if (live.stderr.length > 20)
|
|
260
|
+
live.stderr.splice(0, live.stderr.length - 20);
|
|
261
|
+
});
|
|
262
|
+
let settle;
|
|
263
|
+
const terminal = (exit, spawnError) => {
|
|
264
|
+
if (live.terminal)
|
|
265
|
+
return;
|
|
266
|
+
live.terminal = true;
|
|
267
|
+
if (spawnError !== undefined)
|
|
268
|
+
live.stderr.push(spawnError.message);
|
|
269
|
+
live.stdout.end();
|
|
270
|
+
live.stderrStream.end();
|
|
271
|
+
if (pendingStderr !== "")
|
|
272
|
+
live.stderr.push(pendingStderr);
|
|
273
|
+
if (live.stderr.length > 20)
|
|
274
|
+
live.stderr.splice(0, live.stderr.length - 20);
|
|
275
|
+
if (entry.live === live) {
|
|
276
|
+
entry.live = undefined;
|
|
277
|
+
clearTimeout(live.timer);
|
|
278
|
+
const stopped = this.closed || !this.declarations.has(entry.slug);
|
|
279
|
+
if (!stopped) {
|
|
280
|
+
const now = this.deps.now();
|
|
281
|
+
entry.failures = entry.failures.filter((at) => now - at <= 300_000);
|
|
282
|
+
entry.failures.push(now);
|
|
283
|
+
entry.lastError = tail(live.stderr);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
settle?.(exit);
|
|
287
|
+
};
|
|
288
|
+
child.once("error", (error) => terminal({ code: null, signal: null }, error));
|
|
289
|
+
child.once("exit", (code, signal) => terminal({ code, signal }));
|
|
290
|
+
live.exited = new Promise((resolveExit) => { settle = resolveExit; });
|
|
291
|
+
return live;
|
|
292
|
+
}
|
|
293
|
+
attach(entry) {
|
|
294
|
+
const live = entry.live;
|
|
295
|
+
if (live === undefined)
|
|
296
|
+
throw serviceError(entry);
|
|
297
|
+
live.handles++;
|
|
298
|
+
live.stdout.setHandles(live.handles);
|
|
299
|
+
live.stderrStream.setHandles(live.handles);
|
|
300
|
+
clearTimeout(live.timer);
|
|
301
|
+
live.lastTraffic = this.deps.now();
|
|
302
|
+
let released = false;
|
|
303
|
+
return {
|
|
304
|
+
stdin: new WritableStream({
|
|
305
|
+
write: (chunk) => new Promise((resolveWrite, rejectWrite) => {
|
|
306
|
+
live.lastTraffic = this.deps.now();
|
|
307
|
+
this.armIdle(entry);
|
|
308
|
+
live.child.stdin.write(chunk, (error) => {
|
|
309
|
+
if (error)
|
|
310
|
+
rejectWrite(error);
|
|
311
|
+
else
|
|
312
|
+
resolveWrite();
|
|
313
|
+
});
|
|
314
|
+
}),
|
|
315
|
+
close: async () => { live.child.stdin.end(); },
|
|
316
|
+
abort: async (reason) => { live.child.stdin.destroy(reason); },
|
|
317
|
+
}),
|
|
318
|
+
stdout: live.stdout.stream(),
|
|
319
|
+
stderr: live.stderrStream.stream(),
|
|
320
|
+
exited: live.exited,
|
|
321
|
+
release: () => {
|
|
322
|
+
if (released)
|
|
323
|
+
return;
|
|
324
|
+
released = true;
|
|
325
|
+
live.handles--;
|
|
326
|
+
live.stdout.setHandles(live.handles);
|
|
327
|
+
live.stderrStream.setHandles(live.handles);
|
|
328
|
+
this.armIdle(entry);
|
|
329
|
+
},
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
armIdle(entry) {
|
|
333
|
+
const live = entry.live;
|
|
334
|
+
if (live === undefined)
|
|
335
|
+
return;
|
|
336
|
+
clearTimeout(live.timer);
|
|
337
|
+
if (live.handles > 0)
|
|
338
|
+
return;
|
|
339
|
+
const minutes = entry.declaration.idleMinutes ?? 10;
|
|
340
|
+
if (!(minutes > 0))
|
|
341
|
+
return;
|
|
342
|
+
const remaining = Math.max(0, minutes * 60_000 - (this.deps.now() - live.lastTraffic));
|
|
343
|
+
live.timer = setTimeout(() => {
|
|
344
|
+
if (entry.live === live && live.handles === 0 && this.deps.now() - live.lastTraffic >= minutes * 60_000) {
|
|
345
|
+
void this.stopEntry(entry);
|
|
346
|
+
}
|
|
347
|
+
else {
|
|
348
|
+
this.armIdle(entry);
|
|
349
|
+
}
|
|
350
|
+
}, remaining);
|
|
351
|
+
live.timer.unref();
|
|
352
|
+
}
|
|
353
|
+
async stopEntry(entry) {
|
|
354
|
+
clearTimeout(entry.live?.timer);
|
|
355
|
+
const starting = entry.starting;
|
|
356
|
+
if (starting !== undefined)
|
|
357
|
+
await starting.catch(() => { });
|
|
358
|
+
const live = entry.live;
|
|
359
|
+
entry.live = undefined;
|
|
360
|
+
if (entry.failures.length < 3)
|
|
361
|
+
this.entries.delete(entry.key);
|
|
362
|
+
if (live === undefined)
|
|
363
|
+
return;
|
|
364
|
+
await new Promise((resolveStop) => {
|
|
365
|
+
if (live.terminal || live.child.exitCode !== null || live.child.signalCode !== null) {
|
|
366
|
+
resolveStop();
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
const timer = setTimeout(() => live.child.kill("SIGKILL"), 2_000);
|
|
370
|
+
timer.unref();
|
|
371
|
+
live.child.once("exit", () => {
|
|
372
|
+
clearTimeout(timer);
|
|
373
|
+
resolveStop();
|
|
374
|
+
});
|
|
375
|
+
live.child.kill("SIGTERM");
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
async stopUser(user) {
|
|
379
|
+
await Promise.all([...this.entries.values()].filter((entry) => entry.user === user).map((entry) => this.stopEntry(entry)));
|
|
380
|
+
}
|
|
381
|
+
async stopSlug(slug) {
|
|
382
|
+
await Promise.all([...this.entries.values()].filter((entry) => entry.slug === slug).map((entry) => this.stopEntry(entry)));
|
|
383
|
+
}
|
|
384
|
+
async stopAll() {
|
|
385
|
+
this.closed = true;
|
|
386
|
+
await Promise.all([...this.entries.values()].map((entry) => this.stopEntry(entry)));
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
export function servicePackageDirectory(entry, packageDir) {
|
|
390
|
+
return packageDirectory(entry, packageDir);
|
|
391
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-user M2 Task 3: per-user extension entries and owner-driven
|
|
3
|
+
* selection.
|
|
4
|
+
*
|
|
5
|
+
* A per-user entry is an ordinary pi-durable Extension named
|
|
6
|
+
* `<slug>@<user>`. It is deliberately NOT part of the host default
|
|
7
|
+
* selection: create/fork/owner-transfer commits add the caller's entries.
|
|
8
|
+
* A late install resolves by name on the next agent read, so no stored
|
|
9
|
+
* selection needs to be edited at publish time.
|
|
10
|
+
*
|
|
11
|
+
* SELECTION IS NOT ENFORCEMENT. Every published tool is wrapped, and the
|
|
12
|
+
* wrapper reads the conversation's COMMITTED SessionMeta owner on every
|
|
13
|
+
* execution. A hand-written array selection can select another user's
|
|
14
|
+
* extension (including its hooks); it still cannot execute its tools.
|
|
15
|
+
*/
|
|
16
|
+
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
17
|
+
import { AgentDoc, } from "@earendil-works/pi-durable";
|
|
18
|
+
import { SessionMetaDoc } from "../server/session-meta.js";
|
|
19
|
+
/** The registry name for one extension's publication to one user. */
|
|
20
|
+
export function userEntryName(slug, user) {
|
|
21
|
+
return `${slug}@${user}`;
|
|
22
|
+
}
|
|
23
|
+
/** Per-user names are core-computed and never operator-authored. */
|
|
24
|
+
export function isUserEntryName(name) {
|
|
25
|
+
return name.includes("@");
|
|
26
|
+
}
|
|
27
|
+
/** Parse a per-user name into its extension slug and owning user. */
|
|
28
|
+
export function parseUserEntryName(name) {
|
|
29
|
+
const at = name.indexOf("@");
|
|
30
|
+
if (at <= 0 || at === name.length - 1)
|
|
31
|
+
return undefined;
|
|
32
|
+
return { slug: name.slice(0, at), user: name.slice(at + 1) };
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Publish per-user tools over pi-durable's application-owned Registry.
|
|
36
|
+
* `install` replaces the same name in place; an empty tool list removes
|
|
37
|
+
* the entry altogether. Core owns the per-user slug set (the loader's
|
|
38
|
+
* declarations), not the publishing extension.
|
|
39
|
+
*/
|
|
40
|
+
export class UserToolRegistry {
|
|
41
|
+
registry;
|
|
42
|
+
perUserSlugs;
|
|
43
|
+
constructor(registry, perUserSlugs) {
|
|
44
|
+
this.registry = registry;
|
|
45
|
+
this.perUserSlugs = perUserSlugs;
|
|
46
|
+
}
|
|
47
|
+
set(slug, user, tools) {
|
|
48
|
+
if (!this.perUserSlugs().includes(slug)) {
|
|
49
|
+
throw new Error(`perUserTools not declared: ${slug}`);
|
|
50
|
+
}
|
|
51
|
+
const name = userEntryName(slug, user);
|
|
52
|
+
if (tools.length === 0) {
|
|
53
|
+
const installed = this.registry.snapshot().extension(name);
|
|
54
|
+
if (installed !== undefined)
|
|
55
|
+
this.registry.uninstall(installed);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const extension = {
|
|
59
|
+
name,
|
|
60
|
+
tools: tools.map((tool) => this.wrap(tool, user)),
|
|
61
|
+
};
|
|
62
|
+
this.registry.install(extension);
|
|
63
|
+
}
|
|
64
|
+
removeUser(user) {
|
|
65
|
+
for (const installed of this.registry.snapshot().installed()) {
|
|
66
|
+
if (parseUserEntryName(installed.name)?.user === user) {
|
|
67
|
+
this.registry.uninstall(installed);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
removeSlug(slug) {
|
|
72
|
+
for (const installed of this.registry.snapshot().installed()) {
|
|
73
|
+
if (parseUserEntryName(installed.name)?.slug === slug) {
|
|
74
|
+
this.registry.uninstall(installed);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
wrap(tool, user) {
|
|
79
|
+
return {
|
|
80
|
+
...tool,
|
|
81
|
+
execute: async (args, api, context) => {
|
|
82
|
+
const meta = await api.snapshot(SessionMetaDoc, api.conversationId, context);
|
|
83
|
+
if (meta?.owner !== user) {
|
|
84
|
+
return {
|
|
85
|
+
output: [
|
|
86
|
+
{ type: "text", text: "This tool belongs to another user." },
|
|
87
|
+
],
|
|
88
|
+
isError: true,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
return tool.execute(args, api, context);
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/** Remove every per-user entry belonging to `from` from a name list. */
|
|
97
|
+
function withoutOldOwner(names, from) {
|
|
98
|
+
if (from === undefined)
|
|
99
|
+
return [...names];
|
|
100
|
+
const suffix = `@${from}`;
|
|
101
|
+
return names.filter((name) => !name.endsWith(suffix));
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Rewrite the stored extension selection alongside an owner write.
|
|
105
|
+
*
|
|
106
|
+
* Object/absent selections edit the host default: remove the old owner's
|
|
107
|
+
* entries and add the new owner's declared entries. ARRAY selections are
|
|
108
|
+
* exact: they lose every old-owner name and gain NOTHING. `to: undefined`
|
|
109
|
+
* removes old entries without adding any (ownerless create/stamp paths).
|
|
110
|
+
*/
|
|
111
|
+
export async function rewriteSelection(tx, conversationId, from, to, perUserSlugs) {
|
|
112
|
+
// Re-stamping/forking for the SAME owner is a true no-op: arrays are
|
|
113
|
+
// exact and object edits must not drop then duplicate-check away the
|
|
114
|
+
// owner's existing entries.
|
|
115
|
+
if (from !== undefined && from === to)
|
|
116
|
+
return;
|
|
117
|
+
const agent = await tx.doc(AgentDoc, conversationId);
|
|
118
|
+
const stored = agent.extensions;
|
|
119
|
+
const additions = to === undefined ? [] : perUserSlugs.map((slug) => userEntryName(slug, to));
|
|
120
|
+
if (Array.isArray(stored)) {
|
|
121
|
+
const next = withoutOldOwner(stored, from);
|
|
122
|
+
if (next.length !== stored.length)
|
|
123
|
+
agent.extensions = next;
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const add = [
|
|
127
|
+
...withoutOldOwner(stored?.add ?? [], from),
|
|
128
|
+
...additions.filter((name) => !(stored?.add ?? []).includes(name)),
|
|
129
|
+
];
|
|
130
|
+
const nextRemove = withoutOldOwner(stored?.remove ?? [], from).filter((name) => !additions.includes(name));
|
|
131
|
+
if (stored === undefined && additions.length === 0)
|
|
132
|
+
return;
|
|
133
|
+
agent.extensions = {
|
|
134
|
+
add,
|
|
135
|
+
...(nextRemove.length === 0 ? {} : { remove: nextRemove }),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
/** Page size for the boot's durable conversation scan. */
|
|
139
|
+
const USER_SWEEP_PAGE_SIZE = 1_000;
|
|
140
|
+
/**
|
|
141
|
+
* Boot reconciliation over durable conversations. Only conversations whose
|
|
142
|
+
* SessionMeta owner exists and whose Agent selection is object/absent are
|
|
143
|
+
* considered. Exact arrays remain exact. The returned count is the number
|
|
144
|
+
* of conversation commits actually needed; a second boot must report zero.
|
|
145
|
+
*/
|
|
146
|
+
export async function sweepUserSelections(harness, storage, perUserSlugs) {
|
|
147
|
+
if (perUserSlugs.length === 0)
|
|
148
|
+
return { scanned: 0, changed: 0 };
|
|
149
|
+
let scanned = 0;
|
|
150
|
+
let changed = 0;
|
|
151
|
+
let cursor;
|
|
152
|
+
do {
|
|
153
|
+
const page = await storage.scanConversations({ order: "ascending" }, USER_SWEEP_PAGE_SIZE, cursor, BACKGROUND_CONTEXT);
|
|
154
|
+
for (const record of page.items) {
|
|
155
|
+
scanned += 1;
|
|
156
|
+
const meta = await harness.snapshot(SessionMetaDoc, record.id, BACKGROUND_CONTEXT);
|
|
157
|
+
if (meta?.owner === undefined)
|
|
158
|
+
continue;
|
|
159
|
+
const agent = await harness.snapshot(AgentDoc, record.id, BACKGROUND_CONTEXT);
|
|
160
|
+
if (Array.isArray(agent?.extensions))
|
|
161
|
+
continue;
|
|
162
|
+
const selected = agent?.extensions?.add ?? [];
|
|
163
|
+
const owner = meta.owner;
|
|
164
|
+
const wanted = perUserSlugs.map((slug) => userEntryName(slug, owner));
|
|
165
|
+
if (wanted.every((name) => selected.includes(name)))
|
|
166
|
+
continue;
|
|
167
|
+
const conversation = await harness.conversation(record.id, BACKGROUND_CONTEXT);
|
|
168
|
+
if (conversation === undefined)
|
|
169
|
+
continue;
|
|
170
|
+
await conversation.commit(async (tx) => {
|
|
171
|
+
const draft = await tx.doc(SessionMetaDoc, record.id);
|
|
172
|
+
if (draft.owner === undefined)
|
|
173
|
+
return;
|
|
174
|
+
await addOwnerSelection(tx, record.id, draft.owner, perUserSlugs);
|
|
175
|
+
}, BACKGROUND_CONTEXT);
|
|
176
|
+
changed += 1;
|
|
177
|
+
}
|
|
178
|
+
cursor = page.next;
|
|
179
|
+
} while (cursor !== undefined);
|
|
180
|
+
return { scanned, changed };
|
|
181
|
+
}
|
|
182
|
+
/** Add the current owner's declared entries to a default-edited selection. */
|
|
183
|
+
export async function addOwnerSelection(tx, conversationId, owner, perUserSlugs) {
|
|
184
|
+
if (owner === undefined)
|
|
185
|
+
return;
|
|
186
|
+
await rewriteSelection(tx, conversationId, undefined, owner, perUserSlugs);
|
|
187
|
+
}
|