@corenel/harness 0.1.0 → 0.3.2
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/dist/acp/agentMap.d.ts +34 -0
- package/dist/acp/agentMap.d.ts.map +1 -0
- package/dist/acp/agentMap.js +110 -0
- package/dist/acp/agentMap.js.map +1 -0
- package/dist/acp/index.d.ts +4 -0
- package/dist/acp/index.d.ts.map +1 -0
- package/dist/acp/index.js +6 -0
- package/dist/acp/index.js.map +1 -0
- package/dist/acp/policyMap.d.ts +55 -0
- package/dist/acp/policyMap.d.ts.map +1 -0
- package/dist/acp/policyMap.js +121 -0
- package/dist/acp/policyMap.js.map +1 -0
- package/dist/acp/types.d.ts +41 -0
- package/dist/acp/types.d.ts.map +1 -0
- package/dist/acp/types.js +14 -0
- package/dist/acp/types.js.map +1 -0
- package/dist/api/backend.d.ts +39 -0
- package/dist/api/backend.d.ts.map +1 -0
- package/dist/api/backend.js +126 -0
- package/dist/api/backend.js.map +1 -0
- package/dist/api/client.d.ts +95 -0
- package/dist/api/client.d.ts.map +1 -0
- package/dist/api/client.js +212 -0
- package/dist/api/client.js.map +1 -0
- package/dist/api/fileMirrorSource.d.ts +24 -0
- package/dist/api/fileMirrorSource.d.ts.map +1 -0
- package/dist/api/fileMirrorSource.js +59 -0
- package/dist/api/fileMirrorSource.js.map +1 -0
- package/dist/api/mirror.d.ts +102 -0
- package/dist/api/mirror.d.ts.map +1 -0
- package/dist/api/mirror.js +204 -0
- package/dist/api/mirror.js.map +1 -0
- package/dist/api/mirrorScheduler.d.ts +56 -0
- package/dist/api/mirrorScheduler.d.ts.map +1 -0
- package/dist/api/mirrorScheduler.js +167 -0
- package/dist/api/mirrorScheduler.js.map +1 -0
- package/dist/conversation.d.ts +0 -11
- package/dist/conversation.d.ts.map +1 -1
- package/dist/conversation.js +1 -24
- package/dist/conversation.js.map +1 -1
- package/dist/core/budget.d.ts +40 -0
- package/dist/core/budget.d.ts.map +1 -0
- package/dist/core/budget.js +62 -0
- package/dist/core/budget.js.map +1 -0
- package/dist/core/loop.d.ts +11 -0
- package/dist/core/loop.d.ts.map +1 -1
- package/dist/core/loop.js +89 -20
- package/dist/core/loop.js.map +1 -1
- package/dist/core/pricing.d.ts +8 -0
- package/dist/core/pricing.d.ts.map +1 -0
- package/dist/core/pricing.js +12 -0
- package/dist/core/pricing.js.map +1 -0
- package/dist/core/recovery.d.ts +60 -0
- package/dist/core/recovery.d.ts.map +1 -0
- package/dist/core/recovery.js +128 -0
- package/dist/core/recovery.js.map +1 -0
- package/dist/guardrail/builtins.js +6 -6
- package/dist/guardrail/builtins.js.map +1 -1
- package/dist/guardrail/permission-service.js +1 -1
- package/dist/guardrail/permission-service.js.map +1 -1
- package/dist/guardrail/serialize.d.ts.map +1 -1
- package/dist/guardrail/serialize.js +6 -0
- package/dist/guardrail/serialize.js.map +1 -1
- package/dist/guardrail/validate.d.ts.map +1 -1
- package/dist/guardrail/validate.js +13 -0
- package/dist/guardrail/validate.js.map +1 -1
- package/dist/install/lockfile.d.ts +79 -0
- package/dist/install/lockfile.d.ts.map +1 -0
- package/dist/install/lockfile.js +155 -0
- package/dist/install/lockfile.js.map +1 -0
- package/dist/install/store.d.ts +13 -0
- package/dist/install/store.d.ts.map +1 -0
- package/dist/install/store.js +38 -0
- package/dist/install/store.js.map +1 -0
- package/dist/log.d.ts.map +1 -1
- package/dist/log.js +6 -1
- package/dist/log.js.map +1 -1
- package/dist/prompts/host.d.ts +4 -0
- package/dist/prompts/host.d.ts.map +1 -1
- package/dist/prompts/host.js.map +1 -1
- package/dist/prompts/index.d.ts +6 -0
- package/dist/prompts/index.d.ts.map +1 -1
- package/dist/prompts/index.js +16 -6
- package/dist/prompts/index.js.map +1 -1
- package/dist/prompts/library/defaults.generated.d.ts.map +1 -1
- package/dist/prompts/library/defaults.generated.js +22 -4
- package/dist/prompts/library/defaults.generated.js.map +1 -1
- package/dist/prompts/toolsMarker.d.ts +13 -0
- package/dist/prompts/toolsMarker.d.ts.map +1 -0
- package/dist/prompts/toolsMarker.js +21 -0
- package/dist/prompts/toolsMarker.js.map +1 -0
- package/dist/providers/gateway.d.ts +19 -1
- package/dist/providers/gateway.d.ts.map +1 -1
- package/dist/providers/gateway.js +33 -4
- package/dist/providers/gateway.js.map +1 -1
- package/dist/services.d.ts +13 -0
- package/dist/services.d.ts.map +1 -1
- package/dist/services.js +2 -0
- package/dist/services.js.map +1 -1
- package/dist/sessions/apiLeaseStore.d.ts +10 -0
- package/dist/sessions/apiLeaseStore.d.ts.map +1 -0
- package/dist/sessions/apiLeaseStore.js +41 -0
- package/dist/sessions/apiLeaseStore.js.map +1 -0
- package/dist/sessions/fileRecallStore.d.ts +33 -0
- package/dist/sessions/fileRecallStore.d.ts.map +1 -0
- package/dist/sessions/fileRecallStore.js +112 -0
- package/dist/sessions/fileRecallStore.js.map +1 -0
- package/dist/sessions/fileSessionStore.d.ts +20 -0
- package/dist/sessions/fileSessionStore.d.ts.map +1 -0
- package/dist/sessions/fileSessionStore.js +151 -0
- package/dist/sessions/fileSessionStore.js.map +1 -0
- package/dist/sessions/head.d.ts +90 -0
- package/dist/sessions/head.d.ts.map +1 -0
- package/dist/sessions/head.js +82 -0
- package/dist/sessions/head.js.map +1 -0
- package/dist/sessions/inflight.d.ts +58 -0
- package/dist/sessions/inflight.d.ts.map +1 -0
- package/dist/sessions/inflight.js +0 -0
- package/dist/sessions/inflight.js.map +1 -0
- package/dist/sessions/jsonl.d.ts +23 -0
- package/dist/sessions/jsonl.d.ts.map +1 -0
- package/dist/sessions/jsonl.js +52 -0
- package/dist/sessions/jsonl.js.map +1 -0
- package/dist/sessions/lease.d.ts +59 -0
- package/dist/sessions/lease.d.ts.map +1 -0
- package/dist/sessions/lease.js +115 -0
- package/dist/sessions/lease.js.map +1 -0
- package/dist/sessions/leaseStore.d.ts +46 -0
- package/dist/sessions/leaseStore.d.ts.map +1 -0
- package/dist/sessions/leaseStore.js +58 -0
- package/dist/sessions/leaseStore.js.map +1 -0
- package/dist/sessions/testFs.d.ts +5 -0
- package/dist/sessions/testFs.d.ts.map +1 -0
- package/dist/sessions/testFs.js +85 -0
- package/dist/sessions/testFs.js.map +1 -0
- package/dist/sessions/timeline.d.ts +134 -0
- package/dist/sessions/timeline.d.ts.map +1 -0
- package/dist/sessions/timeline.js +127 -0
- package/dist/sessions/timeline.js.map +1 -0
- package/dist/sidecar/host.d.ts +284 -2
- package/dist/sidecar/host.d.ts.map +1 -1
- package/dist/sidecar/host.js +725 -1
- package/dist/sidecar/host.js.map +1 -1
- package/dist/sidecar/server.d.ts +217 -2
- package/dist/sidecar/server.d.ts.map +1 -1
- package/dist/sidecar/server.js +828 -3
- package/dist/sidecar/server.js.map +1 -1
- package/dist/skills.d.ts +8 -3
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +71 -35
- package/dist/skills.js.map +1 -1
- package/dist/snapshot/effects.d.ts +69 -0
- package/dist/snapshot/effects.d.ts.map +1 -0
- package/dist/snapshot/effects.js +67 -0
- package/dist/snapshot/effects.js.map +1 -0
- package/dist/snapshot/manifest.d.ts +73 -0
- package/dist/snapshot/manifest.d.ts.map +1 -0
- package/dist/snapshot/manifest.js +141 -0
- package/dist/snapshot/manifest.js.map +1 -0
- package/dist/snapshot/tree.d.ts +62 -0
- package/dist/snapshot/tree.d.ts.map +1 -0
- package/dist/snapshot/tree.js +147 -0
- package/dist/snapshot/tree.js.map +1 -0
- package/dist/tools/builtins.d.ts +1 -0
- package/dist/tools/builtins.d.ts.map +1 -1
- package/dist/tools/builtins.js +150 -5
- package/dist/tools/builtins.js.map +1 -1
- package/dist/tools/files.d.ts.map +1 -1
- package/dist/tools/files.js +13 -0
- package/dist/tools/files.js.map +1 -1
- package/dist/tools/http.d.ts.map +1 -1
- package/dist/tools/http.js.map +1 -1
- package/dist/tools/namespaces.d.ts +1 -1
- package/dist/tools/namespaces.d.ts.map +1 -1
- package/dist/tools/namespaces.js +24 -0
- package/dist/tools/namespaces.js.map +1 -1
- package/dist/tools/todo.d.ts.map +1 -1
- package/dist/tools/todo.js +12 -0
- package/dist/tools/todo.js.map +1 -1
- package/package.json +26 -34
package/dist/sidecar/server.js
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
import { SIDECAR_PROTOCOL, errMessage, isLoopbackHost, } from '@corenel/protocol';
|
|
2
|
+
/** True for anything with a callable `.then` -- covers native Promises and any
|
|
3
|
+
* other thenable a caller's `onClient` might return, without assuming it's a
|
|
4
|
+
* real Promise instance (the declared type is `() => void`; an async function
|
|
5
|
+
* is assignable to that per TS, so the actual runtime return must be checked). */
|
|
6
|
+
function isThenable(v) {
|
|
7
|
+
return typeof v === 'object' && v !== null && typeof v.then === 'function';
|
|
8
|
+
}
|
|
9
|
+
/** Cap on `clientId`/`clientName` before they are forwarded to `onClient` (and,
|
|
10
|
+
* downstream, used as a manifest key / dirName seed) — the wire message is
|
|
11
|
+
* declared `string` but arrives via `JSON.parse(...) as SidecarMsg` with no
|
|
12
|
+
* runtime check, so an oversized field must never reach persistence unbounded. */
|
|
13
|
+
const MAX_IDENTITY_FIELD_LEN = 200;
|
|
2
14
|
/** Base64-encode bytes without a Node Buffer (btoa is in the browser + Node 18+),
|
|
3
15
|
* so this module stays free of node:* types for the shared harness build. */
|
|
4
16
|
function bytesToB64(bytes) {
|
|
@@ -57,6 +69,74 @@ export class SidecarServer {
|
|
|
57
69
|
writable: true,
|
|
58
70
|
value: new Map()
|
|
59
71
|
});
|
|
72
|
+
/** The connected host's dot-dir name, from `hello.stateDir` (informational —
|
|
73
|
+
* doesn't change any sidecar behavior yet; reserved for future workspace-state
|
|
74
|
+
* features that need to know the host's dir name). */
|
|
75
|
+
Object.defineProperty(this, "hostStateDir", {
|
|
76
|
+
enumerable: true,
|
|
77
|
+
configurable: true,
|
|
78
|
+
writable: true,
|
|
79
|
+
value: '.corenel'
|
|
80
|
+
});
|
|
81
|
+
/** The authenticated connection's `clientId`, retained here (from `hello`) so
|
|
82
|
+
* later `host-sync-*` messages can be attributed to their owner without
|
|
83
|
+
* trusting anything the client sends per-call. Set only when the `hello`
|
|
84
|
+
* passed BOTH the token check AND the identity `typeof` gate below --
|
|
85
|
+
* `undefined` means either "not authenticated yet" or "authenticated, but this
|
|
86
|
+
* connection carries no identity to attribute files to" (an older client).
|
|
87
|
+
* Either way, host-sync must be rejected; see `requireHostSyncIdentity`. */
|
|
88
|
+
Object.defineProperty(this, "clientId", {
|
|
89
|
+
enumerable: true,
|
|
90
|
+
configurable: true,
|
|
91
|
+
writable: true,
|
|
92
|
+
value: void 0
|
|
93
|
+
});
|
|
94
|
+
/** Detach function for this connection's attendance attachment, set on a
|
|
95
|
+
* successful `hello` when `opts.attendance` is wired. Called once, on channel
|
|
96
|
+
* close, so a dead connection stops receiving/counting toward asks. */
|
|
97
|
+
Object.defineProperty(this, "attendedDetach", {
|
|
98
|
+
enumerable: true,
|
|
99
|
+
configurable: true,
|
|
100
|
+
writable: true,
|
|
101
|
+
value: void 0
|
|
102
|
+
});
|
|
103
|
+
/** Detach callbacks for runs this connection is watching, so a dropped socket
|
|
104
|
+
* stops the hub fanning out into a dead channel. */
|
|
105
|
+
Object.defineProperty(this, "runDetachers", {
|
|
106
|
+
enumerable: true,
|
|
107
|
+
configurable: true,
|
|
108
|
+
writable: true,
|
|
109
|
+
value: new Set()
|
|
110
|
+
});
|
|
111
|
+
/** Processes THIS connection is watching. A shared process table means two
|
|
112
|
+
* browsers can be paired to one daemon, and a client that never asked about a
|
|
113
|
+
* process should not be streamed its output - so output is fanned out per
|
|
114
|
+
* attachment, not to everyone. Spawning attaches you; `proc-attach` is how a
|
|
115
|
+
* second client (or the same one after a reload) opts in. */
|
|
116
|
+
Object.defineProperty(this, "attachedProcs", {
|
|
117
|
+
enumerable: true,
|
|
118
|
+
configurable: true,
|
|
119
|
+
writable: true,
|
|
120
|
+
value: new Set()
|
|
121
|
+
});
|
|
122
|
+
Object.defineProperty(this, "procUnsub", {
|
|
123
|
+
enumerable: true,
|
|
124
|
+
configurable: true,
|
|
125
|
+
writable: true,
|
|
126
|
+
value: void 0
|
|
127
|
+
});
|
|
128
|
+
/* ---- sessions ---- */
|
|
129
|
+
/** Per-session stream stoppers for THIS connection.
|
|
130
|
+
*
|
|
131
|
+
* Per connection, not global: two browsers may watch one session, and a
|
|
132
|
+
* dropped socket must stop only its own follows. Keyed by session because a
|
|
133
|
+
* session outlives the request that started watching it. */
|
|
134
|
+
Object.defineProperty(this, "sessionWatch", {
|
|
135
|
+
enumerable: true,
|
|
136
|
+
configurable: true,
|
|
137
|
+
writable: true,
|
|
138
|
+
value: new Map()
|
|
139
|
+
});
|
|
60
140
|
Object.defineProperty(this, "memory", {
|
|
61
141
|
enumerable: true,
|
|
62
142
|
configurable: true,
|
|
@@ -139,10 +219,37 @@ export class SidecarServer {
|
|
|
139
219
|
for (const t of opts.tools)
|
|
140
220
|
this.tools.set(t.name, t);
|
|
141
221
|
this.ch.onMessage((m) => this.onMessage(m));
|
|
222
|
+
// Every connected server subscribes, which is what makes a root added by one
|
|
223
|
+
// paired client reach ALL of them: two browsers on one daemon must never
|
|
224
|
+
// disagree about what is reachable. The payload is the COMPLETE set.
|
|
225
|
+
const offRoots = opts.roots?.subscribe((s) => this.ch.send({ t: 'roots', root: s.root, roots: s.roots }));
|
|
226
|
+
this.ch.onClose?.(() => {
|
|
227
|
+
this.attendedDetach?.();
|
|
228
|
+
this.attendedDetach = undefined;
|
|
229
|
+
// A dropped connection must stop receiving run events, or the hub keeps
|
|
230
|
+
// fanning out into a dead channel for the rest of the run.
|
|
231
|
+
for (const detach of this.runDetachers)
|
|
232
|
+
detach();
|
|
233
|
+
this.runDetachers.clear();
|
|
234
|
+
offRoots?.();
|
|
235
|
+
// Stop streaming process output into a dead socket. The PROCESSES keep
|
|
236
|
+
// running - that is the point of the table living on the daemon - but this
|
|
237
|
+
// connection stops hearing about them until something re-attaches.
|
|
238
|
+
this.procUnsub?.();
|
|
239
|
+
this.procUnsub = undefined;
|
|
240
|
+
this.attachedProcs.clear();
|
|
241
|
+
// Same reasoning for sessions: the SESSIONS keep running — that is what
|
|
242
|
+
// makes resume cheap — but this connection stops being sent their
|
|
243
|
+
// metrics and logs.
|
|
244
|
+
for (const sid of [...this.sessionWatch.keys()])
|
|
245
|
+
this.stopWatching(sid);
|
|
246
|
+
});
|
|
142
247
|
}
|
|
143
248
|
onMessage(m) {
|
|
144
249
|
switch (m.t) {
|
|
145
250
|
case 'hello':
|
|
251
|
+
if (this.authed)
|
|
252
|
+
return; // identity is fixed at first hello; a second must not re-own the connection
|
|
146
253
|
this.onHello(m);
|
|
147
254
|
return;
|
|
148
255
|
case 'invoke':
|
|
@@ -170,24 +277,238 @@ export class SidecarServer {
|
|
|
170
277
|
}
|
|
171
278
|
void this.onHttpReq(m);
|
|
172
279
|
return;
|
|
280
|
+
case 'run':
|
|
281
|
+
if (!this.authed) {
|
|
282
|
+
this.ch.send({ t: 'run-error', id: m.id, error: 'not authenticated' });
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
void this.onRun(m);
|
|
286
|
+
return;
|
|
287
|
+
case 'subscribe':
|
|
288
|
+
// Guarded by auth like `run` (auth boundary). Pre-auth: drop silently —
|
|
289
|
+
// subscribe has no response channel, so no message is the correct
|
|
290
|
+
// pre-auth behavior (do not invent an ack variant).
|
|
291
|
+
if (!this.authed)
|
|
292
|
+
return;
|
|
293
|
+
this.onSubscribe(m);
|
|
294
|
+
return;
|
|
295
|
+
case 'acp-start':
|
|
296
|
+
if (!this.authed) {
|
|
297
|
+
this.ch.send({ t: 'acp-error', id: m.id, error: 'not authenticated' });
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
void this.onAcpStart(m);
|
|
301
|
+
return;
|
|
302
|
+
case 'acp-set-mode':
|
|
303
|
+
void this.onAcpSetMode(m);
|
|
304
|
+
return;
|
|
305
|
+
case 'acp-prompt':
|
|
306
|
+
if (!this.authed) {
|
|
307
|
+
this.ch.send({ t: 'acp-error', id: m.id, error: 'not authenticated' });
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
void this.onAcpPrompt(m);
|
|
311
|
+
return;
|
|
312
|
+
case 'acp-cancel':
|
|
313
|
+
// A notification-shaped message like `subscribe`: no response channel,
|
|
314
|
+
// so a pre-auth cancel is dropped silently rather than answered.
|
|
315
|
+
if (!this.authed)
|
|
316
|
+
return;
|
|
317
|
+
this.opts.acp?.cancel();
|
|
318
|
+
return;
|
|
319
|
+
case 'permission-answer':
|
|
320
|
+
// Guarded by auth like `run`/`subscribe` (auth boundary): a pre-auth
|
|
321
|
+
// answer is dropped silently rather than forwarded into the registry.
|
|
322
|
+
if (!this.authed)
|
|
323
|
+
return;
|
|
324
|
+
this.opts.attendance?.answer(m.id, m.decision);
|
|
325
|
+
return;
|
|
326
|
+
case 'roots-browse':
|
|
327
|
+
// Auth-gated like every other request: directory enumeration must never
|
|
328
|
+
// precede the pairing token. Failures answer ok:false rather than throwing
|
|
329
|
+
// across the channel.
|
|
330
|
+
if (!this.authed) {
|
|
331
|
+
this.ch.send({ t: 'roots-browse-result', id: m.id, ok: false, error: 'not authenticated' });
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
void this.onRootsBrowse(m);
|
|
335
|
+
return;
|
|
336
|
+
case 'roots-add':
|
|
337
|
+
if (!this.authed) {
|
|
338
|
+
this.ch.send({ t: 'roots-add-result', id: m.id, ok: false, error: 'not authenticated' });
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
void this.onRootsAdd(m);
|
|
342
|
+
return;
|
|
343
|
+
case 'proc-spawn':
|
|
344
|
+
if (!this.authed) {
|
|
345
|
+
this.ch.send({ t: 'proc-error', id: m.id, error: 'not authenticated' });
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
void this.onProcSpawn(m);
|
|
349
|
+
return;
|
|
350
|
+
case 'proc-attach':
|
|
351
|
+
if (!this.authed) {
|
|
352
|
+
this.ch.send({ t: 'proc-attach-result', id: m.id, ok: false, error: 'not authenticated' });
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
this.onProcAttach(m);
|
|
356
|
+
return;
|
|
357
|
+
case 'proc-input':
|
|
358
|
+
if (!this.authed) {
|
|
359
|
+
this.ch.send({ t: 'proc-input-result', id: m.id, ok: false, error: 'not authenticated' });
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
this.onProcSimple(m.id, 'proc-input-result', () => this.requireProcs().write(m.procId, m.text));
|
|
363
|
+
return;
|
|
364
|
+
case 'proc-kill':
|
|
365
|
+
if (!this.authed) {
|
|
366
|
+
this.ch.send({ t: 'proc-kill-result', id: m.id, ok: false, error: 'not authenticated' });
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
this.onProcSimple(m.id, 'proc-kill-result', () => this.requireProcs().kill(m.procId));
|
|
370
|
+
return;
|
|
371
|
+
case 'proc-list':
|
|
372
|
+
// An empty list is the honest answer for a daemon without the capability:
|
|
373
|
+
// it is running nothing, and an error here would make the common "what is
|
|
374
|
+
// running?" probe noisy on every sidecar that never allowed shell.
|
|
375
|
+
if (!this.authed) {
|
|
376
|
+
this.ch.send({ t: 'proc-list-result', id: m.id, procs: [] });
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
this.ch.send({ t: 'proc-list-result', id: m.id, procs: this.opts.procs?.list() ?? [] });
|
|
380
|
+
return;
|
|
381
|
+
case 'session-list':
|
|
382
|
+
// An empty list is the honest answer from a daemon with no session
|
|
383
|
+
// layer: it is running none. An error would make the common "what is
|
|
384
|
+
// there?" probe noisy on every daemon that never enabled sessions.
|
|
385
|
+
if (!this.authed) {
|
|
386
|
+
this.ch.send({ t: 'session-list-result', id: m.id, sessions: [] });
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
this.ch.send({ t: 'session-list-result', id: m.id, sessions: this.opts.sessions?.list() ?? [] });
|
|
390
|
+
return;
|
|
391
|
+
case 'session-create':
|
|
392
|
+
case 'session-pause':
|
|
393
|
+
case 'session-resume':
|
|
394
|
+
case 'session-destroy':
|
|
395
|
+
case 'session-commit':
|
|
396
|
+
case 'session-snapshots':
|
|
397
|
+
case 'session-watch':
|
|
398
|
+
case 'session-unwatch':
|
|
399
|
+
if (!this.authed) {
|
|
400
|
+
this.ch.send({ t: 'session-error', id: m.id, error: 'not authenticated' });
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
void this.onSession(m);
|
|
404
|
+
return;
|
|
405
|
+
case 'run-history':
|
|
406
|
+
if (!this.authed) {
|
|
407
|
+
this.ch.send({ t: 'run-history-result', id: m.id, runs: [] });
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
void this.onRunHistory(m);
|
|
411
|
+
return;
|
|
412
|
+
case 'run-transcript':
|
|
413
|
+
if (!this.authed) {
|
|
414
|
+
this.ch.send({ t: 'run-transcript-result', id: m.id, lines: [] });
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
void this.onRunTranscript(m);
|
|
418
|
+
return;
|
|
419
|
+
case 'host-sync-put':
|
|
420
|
+
void this.onHostSyncPut(m);
|
|
421
|
+
return;
|
|
422
|
+
case 'host-sync-delete':
|
|
423
|
+
void this.onHostSyncDelete(m);
|
|
424
|
+
return;
|
|
173
425
|
default:
|
|
174
|
-
|
|
426
|
+
// welcome / denied / invoke-result / host-call / event / run-accepted /
|
|
427
|
+
// run-done / permission-ask / permission-resolved are host-side.
|
|
428
|
+
return;
|
|
175
429
|
}
|
|
176
430
|
}
|
|
177
431
|
onHello(m) {
|
|
178
432
|
if (m.protocol !== SIDECAR_PROTOCOL) {
|
|
433
|
+
this.opts.log?.(`denied: unsupported protocol ${m.protocol}`);
|
|
179
434
|
this.ch.send({ t: 'denied', reason: `protocol ${m.protocol} unsupported` });
|
|
180
435
|
return;
|
|
181
436
|
}
|
|
182
437
|
if (!safeEqual(m.token ?? '', this.opts.token)) {
|
|
438
|
+
this.opts.log?.('denied: invalid token');
|
|
183
439
|
this.ch.send({ t: 'denied', reason: 'invalid token' });
|
|
184
440
|
return;
|
|
185
441
|
}
|
|
186
442
|
this.authed = true;
|
|
443
|
+
this.hostStateDir = m.stateDir ?? '.corenel';
|
|
444
|
+
// Runtime-validate identity, even though the declared type is `string` --
|
|
445
|
+
// the message crossed the wire via `JSON.parse(...) as SidecarMsg`, so
|
|
446
|
+
// nothing actually guaranteed that. A non-string `clientId` (e.g. a number
|
|
447
|
+
// or object) must never reach `onClient`: downstream consumers use it as an
|
|
448
|
+
// object key (a daemon's hosts manifest) or feed it to a directory-name
|
|
449
|
+
// sanitizer that assumes a string.
|
|
450
|
+
if (typeof m.clientId === 'string' && m.clientId) {
|
|
451
|
+
const clientId = m.clientId.slice(0, MAX_IDENTITY_FIELD_LEN);
|
|
452
|
+
this.clientId = clientId;
|
|
453
|
+
const name = typeof m.clientName === 'string' && m.clientName
|
|
454
|
+
? m.clientName.slice(0, MAX_IDENTITY_FIELD_LEN)
|
|
455
|
+
: clientId;
|
|
456
|
+
// Best-effort, fire-and-forget: a throwing/rejecting `onClient` must never
|
|
457
|
+
// deny/break an otherwise valid, authenticated handshake -- identity
|
|
458
|
+
// recording is informational, not part of the auth decision. Covers BOTH
|
|
459
|
+
// failure modes: a synchronous throw (try/catch) and a rejected promise
|
|
460
|
+
// from an async callback (the `.then(undefined, ...)` below), since the
|
|
461
|
+
// latter would otherwise surface as an unhandled rejection in the host
|
|
462
|
+
// process -- not caught by try/catch, which only sees the (resolved)
|
|
463
|
+
// Promise object the call returns, not its eventual rejection.
|
|
464
|
+
try {
|
|
465
|
+
const result = this.opts.onClient?.({ clientId, name });
|
|
466
|
+
if (isThenable(result)) {
|
|
467
|
+
result.then(undefined, (err) => {
|
|
468
|
+
this.opts.log?.(`onClient hook rejected: ${errMessage(err)}`);
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
catch (err) {
|
|
473
|
+
this.opts.log?.(`onClient hook threw: ${errMessage(err)}`);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
if (this.opts.attendance) {
|
|
477
|
+
this.attendedDetach = this.opts.attendance.attach({
|
|
478
|
+
send: (ask) => this.ch.send({ t: 'permission-ask', ...ask }),
|
|
479
|
+
resolved: (id, decision, by) => this.ch.send({ t: 'permission-resolved', id, decision, by }),
|
|
480
|
+
runActivity: (activity) => this.ch.send({ t: 'run-activity', activity }),
|
|
481
|
+
registrations: (registrations) => this.ch.send({ t: 'registrations', registrations }),
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
this.opts.log?.(`paired — ${this.tools.size} ${this.tools.size === 1 ? 'tool' : 'tools'} advertised (host dir: ${this.hostStateDir})`);
|
|
187
485
|
const tools = [...this.tools.values()].map(({ run: _run, ...decl }) => decl);
|
|
188
486
|
// Advertise the loopback HTTP proxy so the host can route local-LLM calls
|
|
189
|
-
// through us (server-side fetch, no browser CORS) instead of failing
|
|
190
|
-
|
|
487
|
+
// through us (server-side fetch, no browser CORS) instead of failing, and
|
|
488
|
+
// 'runner' only when a crew runner is actually wired — that's what lets the
|
|
489
|
+
// host safely enable daemon-tier triggers (schedule/continuous/self-directed)
|
|
490
|
+
// instead of adding one that would silently never fire. `info` (the confined
|
|
491
|
+
// root + platform) lets the host show the working directory when browsing
|
|
492
|
+
// this sidecar as a file source; omitted cleanly when not provided.
|
|
493
|
+
const features = [
|
|
494
|
+
'http-proxy',
|
|
495
|
+
...(this.opts.runner ? ['runner'] : []),
|
|
496
|
+
...(this.opts.acp ? ['acp'] : []),
|
|
497
|
+
...(this.opts.roots?.areas.length ? ['add-roots'] : []),
|
|
498
|
+
...(this.opts.procs ? ['procs'] : []),
|
|
499
|
+
...(this.opts.sessions ? ['sessions'] : []),
|
|
500
|
+
// The operator's transcript CEILING, advertised so a client offers the
|
|
501
|
+
// per-run choice only where it could be honoured. Withheld means every
|
|
502
|
+
// `run` here keeps nothing however it asks — a client showing a "keep
|
|
503
|
+
// this" control against such a daemon would be promising storage that
|
|
504
|
+
// never happens.
|
|
505
|
+
...(this.opts.sessions?.canKeepTranscripts ? ['keep-transcripts'] : []),
|
|
506
|
+
// Whether a session can be created FROM a repository. Off unless the
|
|
507
|
+
// operator declared somewhere a checkout may be written, so a client only
|
|
508
|
+
// offers "start from a repository" where it would actually work.
|
|
509
|
+
...(this.opts.sessions?.canClone ? ['session-clone'] : []),
|
|
510
|
+
];
|
|
511
|
+
this.ch.send({ t: 'welcome', tools, features, ...(this.opts.info ? { info: this.opts.info } : {}) });
|
|
191
512
|
}
|
|
192
513
|
/** Proxy a host HTTP request server-side. Restricted to LOOPBACK targets — the
|
|
193
514
|
* sidecar must never be usable as an SSRF pivot to internal/metadata hosts. The
|
|
@@ -225,20 +546,524 @@ export class SidecarServer {
|
|
|
225
546
|
this.ch.send({ t: 'http-err', id: m.id, error: errMessage(e) });
|
|
226
547
|
}
|
|
227
548
|
}
|
|
549
|
+
requireSessions() {
|
|
550
|
+
const s = this.opts.sessions;
|
|
551
|
+
if (!s)
|
|
552
|
+
throw new Error('this sidecar runs no sessions (start the daemon with --daemon)');
|
|
553
|
+
return s;
|
|
554
|
+
}
|
|
555
|
+
async onSession(m) {
|
|
556
|
+
const id = m.id;
|
|
557
|
+
try {
|
|
558
|
+
const sessions = this.requireSessions();
|
|
559
|
+
switch (m.t) {
|
|
560
|
+
case 'session-create':
|
|
561
|
+
this.ch.send({
|
|
562
|
+
t: 'session-created',
|
|
563
|
+
id,
|
|
564
|
+
session: await sessions.create({
|
|
565
|
+
...(m.workdir ? { workdir: m.workdir } : {}),
|
|
566
|
+
/* `repo` asks the daemon to CLONE the source rather than mount a
|
|
567
|
+
* directory the client named. Forwarded, not judged: whether
|
|
568
|
+
* cloning is permitted at all, where a checkout may land, and
|
|
569
|
+
* which URLs are refused all live on the daemon side, where the
|
|
570
|
+
* operator's declarations are. */
|
|
571
|
+
...(m.repo ? { repo: m.repo } : {}),
|
|
572
|
+
...(m.owner ? { owner: m.owner } : {}),
|
|
573
|
+
...(m.fromSnapshot ? { fromSnapshot: m.fromSnapshot } : {}),
|
|
574
|
+
// From the HANDSHAKE, never from this message: the dot-dir is a
|
|
575
|
+
// property of the client's workspace, and taking it from the
|
|
576
|
+
// request would let a caller point a session's state anywhere.
|
|
577
|
+
stateDir: this.hostStateDir,
|
|
578
|
+
}),
|
|
579
|
+
});
|
|
580
|
+
return;
|
|
581
|
+
case 'session-pause': {
|
|
582
|
+
// The drain result is carried through rather than reduced to ok/not:
|
|
583
|
+
// a caller about to capture has to know whether work was still
|
|
584
|
+
// running, because that is what makes a snapshot torn.
|
|
585
|
+
const r = await sessions.pause(m.sessionId, m.drainMs);
|
|
586
|
+
this.ch.send({ t: 'session-paused', id, drained: r.drained, remaining: r.remaining });
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
case 'session-resume':
|
|
590
|
+
await sessions.resume(m.sessionId);
|
|
591
|
+
this.ch.send({ t: 'session-ok', id });
|
|
592
|
+
return;
|
|
593
|
+
case 'session-destroy':
|
|
594
|
+
this.stopWatching(m.sessionId);
|
|
595
|
+
await sessions.destroy(m.sessionId);
|
|
596
|
+
this.ch.send({ t: 'session-ok', id });
|
|
597
|
+
return;
|
|
598
|
+
case 'session-commit': {
|
|
599
|
+
if (!sessions.commit)
|
|
600
|
+
throw new Error('this session backend cannot capture snapshots');
|
|
601
|
+
const snapshot = await sessions.commit(m.sessionId, m.name);
|
|
602
|
+
this.ch.send({ t: 'session-committed', id, snapshot });
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
case 'session-snapshots': {
|
|
606
|
+
// An empty list, not an error, when the backend cannot capture: the
|
|
607
|
+
// question "what could I restore from?" has an answer there, and it
|
|
608
|
+
// is "nothing". The client uses it to decide whether to offer the
|
|
609
|
+
// choice at all, and an error would make it look broken instead.
|
|
610
|
+
const snapshots = sessions.snapshots ? await sessions.snapshots() : [];
|
|
611
|
+
this.ch.send({ t: 'session-snapshots-result', id, snapshots });
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
case 'session-watch': {
|
|
615
|
+
// Idempotent: watching twice from one connection would double every
|
|
616
|
+
// sample rather than doing nothing, which is the kind of bug a
|
|
617
|
+
// reconnecting UI creates by accident.
|
|
618
|
+
if (this.sessionWatch.has(m.sessionId)) {
|
|
619
|
+
this.ch.send({ t: 'session-ok', id });
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
const stops = [];
|
|
623
|
+
const sid = m.sessionId;
|
|
624
|
+
if (sessions.watchMetrics) {
|
|
625
|
+
stops.push(sessions.watchMetrics(sid, (sample) => this.ch.send({ t: 'session-metrics', sessionId: sid, sample })));
|
|
626
|
+
}
|
|
627
|
+
if (sessions.watchLogs) {
|
|
628
|
+
stops.push(sessions.watchLogs(sid, (line) => this.ch.send({ t: 'session-log', sessionId: sid, line })));
|
|
629
|
+
}
|
|
630
|
+
this.sessionWatch.set(sid, stops);
|
|
631
|
+
this.ch.send({ t: 'session-ok', id });
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
case 'session-unwatch':
|
|
635
|
+
this.stopWatching(m.sessionId);
|
|
636
|
+
this.ch.send({ t: 'session-ok', id });
|
|
637
|
+
return;
|
|
638
|
+
default:
|
|
639
|
+
this.ch.send({ t: 'session-error', id, error: `unhandled session message: ${m.t}` });
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
catch (e) {
|
|
643
|
+
this.ch.send({ t: 'session-error', id, error: errMessage(e) });
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
stopWatching(sessionId) {
|
|
647
|
+
for (const stop of this.sessionWatch.get(sessionId) ?? []) {
|
|
648
|
+
try {
|
|
649
|
+
stop();
|
|
650
|
+
}
|
|
651
|
+
catch { /* already stopped */ }
|
|
652
|
+
}
|
|
653
|
+
this.sessionWatch.delete(sessionId);
|
|
654
|
+
}
|
|
655
|
+
/* ---- long-running processes ---- */
|
|
656
|
+
/** The table, or a refusal that names WHY. "unknown message" would send an
|
|
657
|
+
* operator hunting for a bug when the real answer is that they did not pass
|
|
658
|
+
* --allow-shell. */
|
|
659
|
+
requireProcs() {
|
|
660
|
+
const procs = this.opts.procs;
|
|
661
|
+
if (!procs)
|
|
662
|
+
throw new Error('this sidecar was not started with --allow-shell, so it cannot run processes');
|
|
663
|
+
return procs;
|
|
664
|
+
}
|
|
665
|
+
/** Begin fanning this connection's attached processes back over the wire.
|
|
666
|
+
* Subscribed lazily: a client that never touches a process should not pay to
|
|
667
|
+
* be woken for someone else's build output. */
|
|
668
|
+
ensureProcStream() {
|
|
669
|
+
if (this.procUnsub)
|
|
670
|
+
return;
|
|
671
|
+
this.procUnsub = this.requireProcs().subscribe((ev) => {
|
|
672
|
+
if (!this.attachedProcs.has(ev.procId))
|
|
673
|
+
return;
|
|
674
|
+
if (ev.kind === 'out') {
|
|
675
|
+
this.ch.send({ t: 'proc-out', procId: ev.procId, seq: ev.chunk.seq, stream: ev.chunk.stream, text: ev.chunk.text });
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
this.ch.send({
|
|
679
|
+
t: 'proc-exit',
|
|
680
|
+
procId: ev.procId,
|
|
681
|
+
code: ev.info.exitCode ?? null,
|
|
682
|
+
...(ev.info.signal ? { signal: ev.info.signal } : {}),
|
|
683
|
+
});
|
|
684
|
+
// Attachment ends with the process. Keeping it would leak an entry per run
|
|
685
|
+
// on a connection that stays open for hours.
|
|
686
|
+
this.attachedProcs.delete(ev.procId);
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
async onProcSpawn(m) {
|
|
690
|
+
try {
|
|
691
|
+
const procs = this.requireProcs();
|
|
692
|
+
// Attach BEFORE spawning: a command that prints immediately would otherwise
|
|
693
|
+
// race the subscription and lose its first lines.
|
|
694
|
+
this.ensureProcStream();
|
|
695
|
+
const info = await procs.spawn({
|
|
696
|
+
command: m.command,
|
|
697
|
+
...(m.cwd ? { cwd: m.cwd } : {}),
|
|
698
|
+
...(m.root ? { root: m.root } : {}),
|
|
699
|
+
...(m.label ? { label: m.label } : {}),
|
|
700
|
+
});
|
|
701
|
+
this.attachedProcs.add(info.procId);
|
|
702
|
+
this.ch.send({ t: 'proc-started', id: m.id, proc: info });
|
|
703
|
+
// A process can finish before the caller ever sees `proc-started` (`echo
|
|
704
|
+
// hi`). Replay whatever the table already holds so no output is lost to
|
|
705
|
+
// that race, and report the exit it already had.
|
|
706
|
+
const { chunks } = procs.backlog(info.procId);
|
|
707
|
+
for (const c of chunks)
|
|
708
|
+
this.ch.send({ t: 'proc-out', procId: info.procId, seq: c.seq, stream: c.stream, text: c.text });
|
|
709
|
+
const now = procs.get(info.procId);
|
|
710
|
+
if (now && !now.running) {
|
|
711
|
+
this.attachedProcs.delete(info.procId);
|
|
712
|
+
this.ch.send({ t: 'proc-exit', procId: info.procId, code: now.exitCode ?? null, ...(now.signal ? { signal: now.signal } : {}) });
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
catch (e) {
|
|
716
|
+
this.ch.send({ t: 'proc-error', id: m.id, error: errMessage(e) });
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
/** Re-attach to a process after a reload or from a second client. `sinceSeq`
|
|
720
|
+
* is what the client already has, so the replay is exactly the rest. */
|
|
721
|
+
onProcAttach(m) {
|
|
722
|
+
try {
|
|
723
|
+
const procs = this.requireProcs();
|
|
724
|
+
const info = procs.get(m.procId);
|
|
725
|
+
if (!info) {
|
|
726
|
+
this.ch.send({ t: 'proc-attach-result', id: m.id, ok: false, error: `no such process: ${m.procId}` });
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
this.ensureProcStream();
|
|
730
|
+
if (info.running)
|
|
731
|
+
this.attachedProcs.add(m.procId);
|
|
732
|
+
const { chunks, truncated } = procs.backlog(m.procId, m.sinceSeq);
|
|
733
|
+
this.ch.send({ t: 'proc-attach-result', id: m.id, ok: true, proc: info, ...(truncated ? { truncated: true } : {}) });
|
|
734
|
+
for (const c of chunks)
|
|
735
|
+
this.ch.send({ t: 'proc-out', procId: m.procId, seq: c.seq, stream: c.stream, text: c.text });
|
|
736
|
+
// An already-exited process still owes the client its ending, or a
|
|
737
|
+
// reattaching UI shows a spinner for something finished an hour ago.
|
|
738
|
+
if (!info.running) {
|
|
739
|
+
this.ch.send({ t: 'proc-exit', procId: m.procId, code: info.exitCode ?? null, ...(info.signal ? { signal: info.signal } : {}) });
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
catch (e) {
|
|
743
|
+
this.ch.send({ t: 'proc-attach-result', id: m.id, ok: false, error: errMessage(e) });
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
/** The kill/input pair: act, then answer with ok or the reason. */
|
|
747
|
+
onProcSimple(id, reply, act) {
|
|
748
|
+
try {
|
|
749
|
+
act();
|
|
750
|
+
this.ch.send({ t: reply, id, ok: true });
|
|
751
|
+
}
|
|
752
|
+
catch (e) {
|
|
753
|
+
this.ch.send({ t: reply, id, ok: false, error: errMessage(e) });
|
|
754
|
+
}
|
|
755
|
+
}
|
|
228
756
|
async onInvoke(m) {
|
|
229
757
|
const tool = this.tools.get(m.name);
|
|
230
758
|
if (!tool) {
|
|
759
|
+
this.opts.log?.(`invoke ${m.name}: unknown tool`);
|
|
231
760
|
this.ch.send({ t: 'invoke-result', id: m.id, ok: false, error: `unknown tool ${m.name}` });
|
|
232
761
|
return;
|
|
233
762
|
}
|
|
763
|
+
this.opts.log?.(`invoke ${m.name}`);
|
|
234
764
|
try {
|
|
235
765
|
const result = await tool.run(m.args, this.makeContext());
|
|
236
766
|
this.ch.send({ t: 'invoke-result', id: m.id, ok: true, result });
|
|
237
767
|
}
|
|
238
768
|
catch (e) {
|
|
769
|
+
this.opts.log?.(`invoke ${m.name} failed: ${errMessage(e)}`);
|
|
239
770
|
this.ch.send({ t: 'invoke-result', id: m.id, ok: false, error: errMessage(e) });
|
|
240
771
|
}
|
|
241
772
|
}
|
|
773
|
+
/** Runs a crew agent via the configured `runner` and streams its lifecycle back
|
|
774
|
+
* to the host: `run-accepted` (with the daemon-assigned runId) once the runner
|
|
775
|
+
* has started, one `event` per `onEvent` callback, then exactly one of
|
|
776
|
+
* `run-done` (final text + stop reason) or `run-error`.
|
|
777
|
+
*
|
|
778
|
+
* `runner.run` returns the runId asynchronously, but a runner is free to call
|
|
779
|
+
* `onEvent` synchronously before that promise settles (e.g. an immediate first
|
|
780
|
+
* turn). Events that arrive before we know the runId are buffered and flushed
|
|
781
|
+
* right after `run-accepted`, so they still carry the correct runId and never
|
|
782
|
+
* race ahead of it on the wire. */
|
|
783
|
+
async onAcpStart(m) {
|
|
784
|
+
const acp = this.opts.acp;
|
|
785
|
+
if (!acp) {
|
|
786
|
+
this.ch.send({ t: 'acp-error', id: m.id, error: 'this sidecar was not started with --acp' });
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
try {
|
|
790
|
+
const started = await acp.start(m.target, (update) => {
|
|
791
|
+
this.ch.send({ t: 'acp-update', id: m.id, update });
|
|
792
|
+
}, {
|
|
793
|
+
...(m.root ? { root: m.root } : {}),
|
|
794
|
+
...(m.modeId ? { modeId: m.modeId } : {}),
|
|
795
|
+
...(m.resume ? { resume: m.resume } : {}),
|
|
796
|
+
});
|
|
797
|
+
this.ch.send({ t: 'acp-started', id: m.id, ...started });
|
|
798
|
+
}
|
|
799
|
+
catch (err) {
|
|
800
|
+
this.ch.send({ t: 'acp-error', id: m.id, error: errMessage(err) });
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
async onAcpSetMode(m) {
|
|
804
|
+
const acp = this.opts.acp;
|
|
805
|
+
if (!acp) {
|
|
806
|
+
this.ch.send({ t: 'acp-error', id: m.id, error: 'this sidecar was not started with --acp' });
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
try {
|
|
810
|
+
await acp.setMode(m.modeId);
|
|
811
|
+
this.ch.send({ t: 'acp-mode-set', id: m.id, modeId: m.modeId });
|
|
812
|
+
}
|
|
813
|
+
catch (err) {
|
|
814
|
+
this.ch.send({ t: 'acp-error', id: m.id, error: errMessage(err) });
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
async onAcpPrompt(m) {
|
|
818
|
+
const acp = this.opts.acp;
|
|
819
|
+
if (!acp) {
|
|
820
|
+
this.ch.send({ t: 'acp-error', id: m.id, error: 'this sidecar was not started with --acp' });
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
try {
|
|
824
|
+
const { stopReason } = await acp.prompt(m.text, m.content);
|
|
825
|
+
this.ch.send({ t: 'acp-done', id: m.id, stopReason });
|
|
826
|
+
}
|
|
827
|
+
catch (err) {
|
|
828
|
+
this.ch.send({ t: 'acp-error', id: m.id, error: errMessage(err) });
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
/**
|
|
832
|
+
* Attach this connection as a VIEWER of a run it did not start.
|
|
833
|
+
*
|
|
834
|
+
* The replay comes first, so a client joining mid-run sees the whole run
|
|
835
|
+
* rather than the tail — a viewer that only sees what happens next cannot tell
|
|
836
|
+
* a stalled run from a quiet one. Events are tagged with the SUBSCRIBER's own
|
|
837
|
+
* message id, so a client correlating by id sees its own stream.
|
|
838
|
+
*/
|
|
839
|
+
onSubscribe(m) {
|
|
840
|
+
const hub = this.opts.runs;
|
|
841
|
+
if (!hub) {
|
|
842
|
+
this.ch.send({ t: 'run-error', id: m.id, error: 'this sidecar does not support watching runs' });
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
const attached = hub.subscribe(m.runId, {
|
|
846
|
+
event: (event) => this.ch.send({ t: 'event', id: m.id, runId: m.runId, event }),
|
|
847
|
+
done: (r) => {
|
|
848
|
+
this.ch.send({ t: 'run-done', id: m.id, runId: m.runId, text: r.text, stopReason: r.stopReason });
|
|
849
|
+
detachOnce();
|
|
850
|
+
},
|
|
851
|
+
error: (message) => {
|
|
852
|
+
this.ch.send({ t: 'run-error', id: m.id, error: message });
|
|
853
|
+
detachOnce();
|
|
854
|
+
},
|
|
855
|
+
});
|
|
856
|
+
if (!attached) {
|
|
857
|
+
this.ch.send({ t: 'run-error', id: m.id, error: `no such run: ${m.runId}` });
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
// Held so a dropped connection detaches; also removed on done/error below.
|
|
861
|
+
const detachOnce = () => {
|
|
862
|
+
if (!this.runDetachers.has(attached.detach))
|
|
863
|
+
return;
|
|
864
|
+
this.runDetachers.delete(attached.detach);
|
|
865
|
+
attached.detach();
|
|
866
|
+
};
|
|
867
|
+
this.runDetachers.add(attached.detach);
|
|
868
|
+
this.ch.send({ t: 'run-accepted', id: m.id, runId: m.runId });
|
|
869
|
+
if (attached.replay.dropped > 0) {
|
|
870
|
+
// Say so rather than handing over a tail that looks complete.
|
|
871
|
+
this.opts.log?.(`subscribe ${m.runId}: replaying with ${attached.replay.dropped} earlier event(s) dropped`);
|
|
872
|
+
}
|
|
873
|
+
for (const event of attached.replay.events) {
|
|
874
|
+
this.ch.send({ t: 'event', id: m.id, runId: m.runId, event });
|
|
875
|
+
}
|
|
876
|
+
// A run that already finished still replays, then closes immediately.
|
|
877
|
+
if (attached.replay.done) {
|
|
878
|
+
this.ch.send({ t: 'run-done', id: m.id, runId: m.runId, ...attached.replay.done });
|
|
879
|
+
detachOnce();
|
|
880
|
+
}
|
|
881
|
+
else if (attached.replay.error) {
|
|
882
|
+
this.ch.send({ t: 'run-error', id: m.id, error: attached.replay.error });
|
|
883
|
+
detachOnce();
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
/**
|
|
887
|
+
* What this caller's agent has actually done.
|
|
888
|
+
*
|
|
889
|
+
* AN EMPTY LIST IS AN ANSWER, not a failure: a daemon that keeps no record
|
|
890
|
+
* has no history, and a client uses the rows to render rather than to detect
|
|
891
|
+
* a broken daemon. The identity handed down is the untrusted `{ clientId }`
|
|
892
|
+
* this connection authenticated with — the runner resolves it through the
|
|
893
|
+
* hosts manifest, which is what makes the result owner-scoped by
|
|
894
|
+
* construction rather than by a filter written here.
|
|
895
|
+
*/
|
|
896
|
+
async onRunHistory(m) {
|
|
897
|
+
try {
|
|
898
|
+
const runs = this.opts.runner?.history
|
|
899
|
+
? await this.opts.runner.history({ clientId: this.clientId }, m.agent, m.limit)
|
|
900
|
+
: [];
|
|
901
|
+
this.ch.send({ t: 'run-history-result', id: m.id, runs });
|
|
902
|
+
}
|
|
903
|
+
catch {
|
|
904
|
+
// A history that cannot be read is not a reason to break the caller's
|
|
905
|
+
// page; the rows it already has stay, and it sees none added.
|
|
906
|
+
this.ch.send({ t: 'run-history-result', id: m.id, runs: [] });
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
/** One run's kept events. Empty when they were never kept — the ordinary
|
|
910
|
+
* case, and a state the caller renders rather than treats as a failure. */
|
|
911
|
+
async onRunTranscript(m) {
|
|
912
|
+
try {
|
|
913
|
+
const lines = this.opts.runner?.transcript
|
|
914
|
+
? await this.opts.runner.transcript({ clientId: this.clientId }, m.agent, m.runId)
|
|
915
|
+
: [];
|
|
916
|
+
this.ch.send({ t: 'run-transcript-result', id: m.id, lines });
|
|
917
|
+
}
|
|
918
|
+
catch {
|
|
919
|
+
this.ch.send({ t: 'run-transcript-result', id: m.id, lines: [] });
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
async onRun(m) {
|
|
923
|
+
/* WHERE the run happens, decided once, up front.
|
|
924
|
+
*
|
|
925
|
+
* A `sessionId` is a confinement request. A daemon that cannot honour it
|
|
926
|
+
* says so rather than running the work in its own process anyway — silently
|
|
927
|
+
* downgrading is the failure IsolationLevel exists to prevent, and it would
|
|
928
|
+
* be invisible to the client that asked. */
|
|
929
|
+
if (m.sessionId !== undefined) {
|
|
930
|
+
const sessions = this.opts.sessions;
|
|
931
|
+
if (!sessions?.run) {
|
|
932
|
+
this.ch.send({ t: 'run-error', id: m.id, error: 'this sidecar cannot run agents inside a session' });
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
else if (!this.opts.runner) {
|
|
937
|
+
this.ch.send({ t: 'run-error', id: m.id, error: 'this sidecar cannot run agents' });
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
const start = m.sessionId !== undefined
|
|
941
|
+
? (agent, input, onEvent, identity) =>
|
|
942
|
+
/* The request is forwarded, never resolved here. Whether it is honoured
|
|
943
|
+
* is the session layer's call, because the operator's permission lives
|
|
944
|
+
* there; this class would have to duplicate that rule to decide, and a
|
|
945
|
+
* duplicated permission rule is one that eventually disagrees. */
|
|
946
|
+
this.opts.sessions.run(m.sessionId, agent, input, onEvent, identity, { keepTranscript: m.keepTranscript === true })
|
|
947
|
+
: (agent, input, onEvent, identity) => this.opts.runner.run(agent, input, onEvent, identity);
|
|
948
|
+
let runId;
|
|
949
|
+
const pending = [];
|
|
950
|
+
const onEvent = (event) => {
|
|
951
|
+
// Publish to the hub as well as this connection, so another device can
|
|
952
|
+
// watch. Buffered until the runner reports its id, since the hub is keyed
|
|
953
|
+
// by it — the same reason `pending` exists for the commanding client.
|
|
954
|
+
if (runId !== undefined)
|
|
955
|
+
this.opts.runs?.publish(runId, event);
|
|
956
|
+
if (runId === undefined) {
|
|
957
|
+
pending.push(event);
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
this.ch.send({ t: 'event', id: m.id, runId, event });
|
|
961
|
+
};
|
|
962
|
+
try {
|
|
963
|
+
// Task 12 fix-wave: forward this connection's authenticated `clientId`
|
|
964
|
+
// (or `undefined` for an unidentified/older peer) as an UNTRUSTED
|
|
965
|
+
// `{ clientId }` identity -- this class must never resolve it itself
|
|
966
|
+
// (no manifest access here, by design; see the module doc), it just
|
|
967
|
+
// hands the identity it already holds to the runner, exactly like
|
|
968
|
+
// `onHostSyncPut`/`onHostSyncDelete` do for `hostSync.put`/`remove`.
|
|
969
|
+
const started = await start(m.agent, m.input, onEvent, { clientId: this.clientId });
|
|
970
|
+
runId = started.runId;
|
|
971
|
+
this.opts.runs?.open(runId);
|
|
972
|
+
this.ch.send({ t: 'run-accepted', id: m.id, runId });
|
|
973
|
+
for (const event of pending) {
|
|
974
|
+
this.opts.runs?.publish(runId, event);
|
|
975
|
+
this.ch.send({ t: 'event', id: m.id, runId, event });
|
|
976
|
+
}
|
|
977
|
+
const fin = await started.done;
|
|
978
|
+
this.opts.runs?.finish(runId, { text: fin.text, stopReason: fin.stopReason });
|
|
979
|
+
this.ch.send({ t: 'run-done', id: m.id, runId, text: fin.text, stopReason: fin.stopReason });
|
|
980
|
+
}
|
|
981
|
+
catch (e) {
|
|
982
|
+
const message = errMessage(e);
|
|
983
|
+
// Tell watchers too: a run that failed must not look merely silent to a
|
|
984
|
+
// second viewer.
|
|
985
|
+
if (runId !== undefined)
|
|
986
|
+
this.opts.runs?.fail(runId, message);
|
|
987
|
+
this.ch.send({ t: 'run-error', id: m.id, error: message });
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
/** The identity to attribute a host-sync write to. Returns either the
|
|
991
|
+
* authenticated `clientId`, or an error string explaining why there is none --
|
|
992
|
+
* either this connection is not authenticated yet, or it authenticated with no
|
|
993
|
+
* `clientId` (an older client). The latter is NOT an auth failure (the pairing
|
|
994
|
+
* token already authenticated the connection); it is a "no owner to attribute
|
|
995
|
+
* files to" rule -- a write with no owner must never reach the port. */
|
|
996
|
+
requireHostSyncIdentity() {
|
|
997
|
+
if (!this.authed)
|
|
998
|
+
return { error: 'not authenticated' };
|
|
999
|
+
if (this.clientId === undefined) {
|
|
1000
|
+
return { error: 'this connection carries no client identity to attribute host-sync writes to' };
|
|
1001
|
+
}
|
|
1002
|
+
return { clientId: this.clientId };
|
|
1003
|
+
}
|
|
1004
|
+
async onRootsBrowse(m) {
|
|
1005
|
+
if (!this.opts.roots) {
|
|
1006
|
+
this.ch.send({ t: 'roots-browse-result', id: m.id, ok: false, error: 'this sidecar cannot add working directories' });
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
try {
|
|
1010
|
+
this.ch.send({ t: 'roots-browse-result', id: m.id, ok: true, dirs: await this.opts.roots.browse(m.path) });
|
|
1011
|
+
}
|
|
1012
|
+
catch (e) {
|
|
1013
|
+
this.ch.send({ t: 'roots-browse-result', id: m.id, ok: false, error: errMessage(e) });
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
async onRootsAdd(m) {
|
|
1017
|
+
if (!this.opts.roots) {
|
|
1018
|
+
this.ch.send({ t: 'roots-add-result', id: m.id, ok: false, error: 'this sidecar cannot add working directories' });
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
try {
|
|
1022
|
+
// The broadcast to every client is the controller's subscription, not this
|
|
1023
|
+
// reply -- so a refusal (below) reaches only the asker and changes nothing.
|
|
1024
|
+
await this.opts.roots.add(m.path);
|
|
1025
|
+
this.ch.send({ t: 'roots-add-result', id: m.id, ok: true });
|
|
1026
|
+
}
|
|
1027
|
+
catch (e) {
|
|
1028
|
+
this.ch.send({ t: 'roots-add-result', id: m.id, ok: false, error: errMessage(e) });
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
async onHostSyncPut(m) {
|
|
1032
|
+
const identity = this.requireHostSyncIdentity();
|
|
1033
|
+
if ('error' in identity) {
|
|
1034
|
+
this.ch.send({ t: 'host-sync-result', id: m.id, ok: false, error: identity.error });
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
1037
|
+
if (!this.opts.hostSync) {
|
|
1038
|
+
this.ch.send({ t: 'host-sync-result', id: m.id, ok: false, error: 'this sidecar cannot sync host config' });
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
1041
|
+
try {
|
|
1042
|
+
await this.opts.hostSync.put(identity.clientId, m.path, m.content);
|
|
1043
|
+
this.ch.send({ t: 'host-sync-result', id: m.id, ok: true });
|
|
1044
|
+
}
|
|
1045
|
+
catch (e) {
|
|
1046
|
+
this.ch.send({ t: 'host-sync-result', id: m.id, ok: false, error: errMessage(e) });
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
async onHostSyncDelete(m) {
|
|
1050
|
+
const identity = this.requireHostSyncIdentity();
|
|
1051
|
+
if ('error' in identity) {
|
|
1052
|
+
this.ch.send({ t: 'host-sync-result', id: m.id, ok: false, error: identity.error });
|
|
1053
|
+
return;
|
|
1054
|
+
}
|
|
1055
|
+
if (!this.opts.hostSync) {
|
|
1056
|
+
this.ch.send({ t: 'host-sync-result', id: m.id, ok: false, error: 'this sidecar cannot sync host config' });
|
|
1057
|
+
return;
|
|
1058
|
+
}
|
|
1059
|
+
try {
|
|
1060
|
+
await this.opts.hostSync.remove(identity.clientId, m.path);
|
|
1061
|
+
this.ch.send({ t: 'host-sync-result', id: m.id, ok: true });
|
|
1062
|
+
}
|
|
1063
|
+
catch (e) {
|
|
1064
|
+
this.ch.send({ t: 'host-sync-result', id: m.id, ok: false, error: errMessage(e) });
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
242
1067
|
/** A ToolContext whose capabilities proxy back to the host (persist-nothing).
|
|
243
1068
|
* The stores, the host's file workspace, and the user prompts (confirm/ask) all
|
|
244
1069
|
* round-trip over the channel. confirm/ask default to allow/dismiss when the
|