@corenel/harness 0.12.0 → 0.14.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/dist/audit/recorder.d.ts +6 -1
- package/dist/audit/recorder.d.ts.map +1 -1
- package/dist/audit/recorder.js +9 -2
- package/dist/audit/recorder.js.map +1 -1
- package/dist/audit/runAudit.d.ts.map +1 -1
- package/dist/audit/runAudit.js +2 -0
- package/dist/audit/runAudit.js.map +1 -1
- package/dist/conversation.d.ts +33 -0
- package/dist/conversation.d.ts.map +1 -1
- package/dist/conversation.js +44 -1
- package/dist/conversation.js.map +1 -1
- package/dist/core/compactHistory.d.ts +27 -18
- package/dist/core/compactHistory.d.ts.map +1 -1
- package/dist/core/compactHistory.js +106 -40
- package/dist/core/compactHistory.js.map +1 -1
- package/dist/core/fileChanges.d.ts +18 -0
- package/dist/core/fileChanges.d.ts.map +1 -0
- package/dist/core/fileChanges.js +85 -0
- package/dist/core/fileChanges.js.map +1 -0
- package/dist/core/foldBlocks.d.ts +43 -0
- package/dist/core/foldBlocks.d.ts.map +1 -0
- package/dist/core/foldBlocks.js +114 -0
- package/dist/core/foldBlocks.js.map +1 -0
- package/dist/core/foldTypes.d.ts +25 -0
- package/dist/core/foldTypes.d.ts.map +1 -0
- package/dist/core/foldTypes.js +2 -0
- package/dist/core/foldTypes.js.map +1 -0
- package/dist/core/loop.d.ts +29 -5
- package/dist/core/loop.d.ts.map +1 -1
- package/dist/core/loop.js +166 -70
- package/dist/core/loop.js.map +1 -1
- package/dist/core/recovery.d.ts +1 -0
- package/dist/core/recovery.d.ts.map +1 -1
- package/dist/core/recovery.js +12 -0
- package/dist/core/recovery.js.map +1 -1
- package/dist/core/spawn.d.ts +4 -0
- package/dist/core/spawn.d.ts.map +1 -1
- package/dist/core/spawn.js +11 -4
- package/dist/core/spawn.js.map +1 -1
- package/dist/core/summarize.d.ts +9 -3
- package/dist/core/summarize.d.ts.map +1 -1
- package/dist/core/summarize.js +97 -42
- package/dist/core/summarize.js.map +1 -1
- package/dist/dag/executeNode.d.ts +5 -0
- package/dist/dag/executeNode.d.ts.map +1 -1
- package/dist/dag/executeNode.js +5 -1
- package/dist/dag/executeNode.js.map +1 -1
- package/dist/guardrail/permission-service.d.ts.map +1 -1
- package/dist/guardrail/permission-service.js +11 -2
- package/dist/guardrail/permission-service.js.map +1 -1
- package/dist/guardrail/serialize.d.ts.map +1 -1
- package/dist/guardrail/serialize.js +4 -0
- package/dist/guardrail/serialize.js.map +1 -1
- package/dist/guardrail/validate.d.ts.map +1 -1
- package/dist/guardrail/validate.js +3 -1
- package/dist/guardrail/validate.js.map +1 -1
- package/dist/orchestrator/record.d.ts +94 -5
- package/dist/orchestrator/record.d.ts.map +1 -1
- package/dist/orchestrator/record.js +186 -5
- package/dist/orchestrator/record.js.map +1 -1
- package/dist/orchestrator/sidecarTools.d.ts +5 -0
- package/dist/orchestrator/sidecarTools.d.ts.map +1 -1
- package/dist/orchestrator/sidecarTools.js +53 -15
- package/dist/orchestrator/sidecarTools.js.map +1 -1
- package/dist/prompts/library/defaults.generated.js +2 -2
- package/dist/prompts/library/defaults.generated.js.map +1 -1
- package/dist/sessions/directivesStore.d.ts +9 -0
- package/dist/sessions/directivesStore.d.ts.map +1 -0
- package/dist/sessions/directivesStore.js +54 -0
- package/dist/sessions/directivesStore.js.map +1 -0
- package/dist/sessions/fileSessionStore.d.ts +2 -2
- package/dist/sessions/fileSessionStore.d.ts.map +1 -1
- package/dist/sessions/fileSessionStore.js +19 -8
- package/dist/sessions/fileSessionStore.js.map +1 -1
- package/dist/sessions/foldStorage.d.ts +15 -0
- package/dist/sessions/foldStorage.d.ts.map +1 -0
- package/dist/sessions/foldStorage.js +121 -0
- package/dist/sessions/foldStorage.js.map +1 -0
- package/dist/sessions/jsonl.d.ts +6 -0
- package/dist/sessions/jsonl.d.ts.map +1 -1
- package/dist/sessions/jsonl.js +15 -1
- package/dist/sessions/jsonl.js.map +1 -1
- package/dist/sidecar/deviceKeys.d.ts +75 -0
- package/dist/sidecar/deviceKeys.d.ts.map +1 -0
- package/dist/sidecar/deviceKeys.js +142 -0
- package/dist/sidecar/deviceKeys.js.map +1 -0
- package/dist/sidecar/host.d.ts +144 -24
- package/dist/sidecar/host.d.ts.map +1 -1
- package/dist/sidecar/host.js +306 -16
- package/dist/sidecar/host.js.map +1 -1
- package/dist/sidecar/identity.d.ts +41 -17
- package/dist/sidecar/identity.d.ts.map +1 -1
- package/dist/sidecar/identity.js +10 -21
- package/dist/sidecar/identity.js.map +1 -1
- package/dist/sidecar/server.d.ts +197 -28
- package/dist/sidecar/server.d.ts.map +1 -1
- package/dist/sidecar/server.js +701 -110
- package/dist/sidecar/server.js.map +1 -1
- package/dist/sidecar/testing.d.ts +97 -0
- package/dist/sidecar/testing.d.ts.map +1 -0
- package/dist/sidecar/testing.js +259 -0
- package/dist/sidecar/testing.js.map +1 -0
- package/dist/sidecar/wsChannel.d.ts +8 -1
- package/dist/sidecar/wsChannel.d.ts.map +1 -1
- package/dist/sidecar/wsChannel.js +17 -1
- package/dist/sidecar/wsChannel.js.map +1 -1
- package/dist/tools/callAgent.d.ts.map +1 -1
- package/dist/tools/callAgent.js +33 -13
- package/dist/tools/callAgent.js.map +1 -1
- package/dist/tools/foldTools.d.ts +5 -0
- package/dist/tools/foldTools.d.ts.map +1 -0
- package/dist/tools/foldTools.js +123 -0
- package/dist/tools/foldTools.js.map +1 -0
- package/dist/tools/orchestration.js +1 -1
- package/dist/tools/orchestration.js.map +1 -1
- package/dist/tools/skillTool.d.ts.map +1 -1
- package/dist/tools/skillTool.js +7 -2
- package/dist/tools/skillTool.js.map +1 -1
- package/package.json +2 -2
package/dist/sidecar/server.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { DEFAULT_STATE_DIR, RUN_TASK_FEATURE, RUN_PLAN_FEATURE, CREW_DEPLOY_FEATURE, denialNote } from '@corenel/protocol';
|
|
2
|
-
import { SIDECAR_PROTOCOL, errMessage, isLoopbackHost, isLoopbackAddress, parseMcpToolAddress, isWebPushSubscription, MAX_ASSERTION_LEN, } from '@corenel/protocol';
|
|
3
|
-
import {
|
|
1
|
+
import { DEFAULT_STATE_DIR, RUN_TASK_FEATURE, RUN_PLAN_FEATURE, CREW_DEPLOY_FEATURE, DEVICES_FEATURE, denialNote } from '@corenel/protocol';
|
|
2
|
+
import { SIDECAR_PROTOCOL, errMessage, isLoopbackHost, isLoopbackAddress, parseMcpToolAddress, isWebPushSubscription, MAX_ASSERTION_LEN, isClerkOrgId, } from '@corenel/protocol';
|
|
3
|
+
import { asEcPublicJwk, jwkThumbprint, verifySignature, randomToken, serverProofPayload, clientProofPayload, bytesToB64url, codeProof, sameB64, B64URL_32, } from './deviceKeys.js';
|
|
4
4
|
const NO_CHECKS = 'this sidecar does not run checks (start it with --allow-shell)';
|
|
5
|
-
/** Default pre-pairing deadline: a real client sends `hello` immediately.
|
|
6
|
-
|
|
5
|
+
/** Default pre-pairing deadline: a real client sends `hello` immediately.
|
|
6
|
+
* It covers the proof exchange AND the identity exchange (IDENTITY_TIMEOUT_MS),
|
|
7
|
+
* which now happens before a connection counts as authenticated. */
|
|
8
|
+
export const HELLO_DEADLINE_MS = 20000;
|
|
7
9
|
/** Denied hellos one connection may send before it is closed: a client with a
|
|
8
10
|
* stale token needs one, not an unbounded guessing loop. */
|
|
9
11
|
export const MAX_FAILED_HELLOS = 3;
|
|
@@ -12,12 +14,16 @@ export const MAX_FAILED_HELLOS = 3;
|
|
|
12
14
|
* a client left with no handshake at all is worse than one that has to fall back
|
|
13
15
|
* to deducing its own directory. */
|
|
14
16
|
const DIR_NAME_TIMEOUT_MS = 3000;
|
|
15
|
-
/** How long pairing waits
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
export const IDENTITY_TIMEOUT_MS =
|
|
17
|
+
/** How long pairing waits for the identity exchange (the client fetching an
|
|
18
|
+
* assertion from corenel-api, then the verify), from the challenge. Identity
|
|
19
|
+
* is required, so a timeout REFUSES the hello. Long enough for a browser's own
|
|
20
|
+
* bounds (a Clerk token, then the assertion request, which may include a
|
|
21
|
+
* membership lookup at Clerk). */
|
|
22
|
+
export const IDENTITY_TIMEOUT_MS = 10000;
|
|
23
|
+
/** How long an open connection has to answer a periodic re-check
|
|
24
|
+
* (`SidecarAdmission.recheckMs`) before it is closed. Generous: the client is
|
|
25
|
+
* mid-session, and a slow answer is not a refusal. */
|
|
26
|
+
export const RECHECK_TIMEOUT_MS = 30000;
|
|
21
27
|
/** True for anything with a callable `.then` -- covers native Promises and any
|
|
22
28
|
* other thenable a caller's `onClient` might return, without assuming it's a
|
|
23
29
|
* real Promise instance (the declared type is `() => void`; an async function
|
|
@@ -51,6 +57,13 @@ function announcedCapabilities(v) {
|
|
|
51
57
|
const list = v;
|
|
52
58
|
return list.every((c) => typeof c === 'string' && c.length <= MAX_CAPABILITY_LEN) ? list : [];
|
|
53
59
|
}
|
|
60
|
+
/** Whether a hello announced that its client persists an issued device
|
|
61
|
+
* credential (`DEVICES_FEATURE` in `capabilities`). Informational like every
|
|
62
|
+
* capability: it grants nothing, it only decides whether issuing one is worth
|
|
63
|
+
* anything. */
|
|
64
|
+
function keepsDevices(m) {
|
|
65
|
+
return announcedCapabilities(m.capabilities).includes(DEVICES_FEATURE);
|
|
66
|
+
}
|
|
54
67
|
/** Base64-encode bytes without a Node Buffer (btoa is in the browser + Node 18+),
|
|
55
68
|
* so this module stays free of node:* types for the shared harness build. */
|
|
56
69
|
function bytesToB64(bytes) {
|
|
@@ -69,17 +82,18 @@ function makeNonce() {
|
|
|
69
82
|
crypto.getRandomValues(bytes);
|
|
70
83
|
return bytesToB64(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
71
84
|
}
|
|
72
|
-
/**
|
|
73
|
-
*
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
85
|
+
/** Bound on a device id, checked before any store lookup. A real one is ~20
|
|
86
|
+
* characters. */
|
|
87
|
+
const MAX_DEVICE_ID_LEN = 64;
|
|
88
|
+
/** How long the daemon waits for `client-proof` after its `server-proof`.
|
|
89
|
+
* The hello deadline closes an unauthenticated socket anyway; this bounds the
|
|
90
|
+
* wait inside it. */
|
|
91
|
+
export const PROOF_TIMEOUT_MS = 10000;
|
|
92
|
+
/** A fresh device id: random, not secret. */
|
|
93
|
+
function makeDeviceId() {
|
|
94
|
+
const b = new Uint8Array(12);
|
|
95
|
+
crypto.getRandomValues(b);
|
|
96
|
+
return `dev_${bytesToB64url(b)}`;
|
|
83
97
|
}
|
|
84
98
|
/** Longest verdict reason the harness will put on a welcome or in a log. */
|
|
85
99
|
const MAX_REASON_LEN = 80;
|
|
@@ -103,8 +117,17 @@ function normalizeVerdict(got) {
|
|
|
103
117
|
if (r.ok === true) {
|
|
104
118
|
const userId = r.identity?.userId;
|
|
105
119
|
const issuer = r.identity?.issuer;
|
|
120
|
+
const orgId = r.identity?.orgId;
|
|
106
121
|
if (typeof userId === 'string' && userId && userId.length <= MAX_IDENTITY_FIELD_LEN) {
|
|
107
|
-
return {
|
|
122
|
+
return {
|
|
123
|
+
ok: true,
|
|
124
|
+
identity: {
|
|
125
|
+
userId, issuer: typeof issuer === 'string' ? issuer : '',
|
|
126
|
+
// Carried only in a usable shape; a malformed org is no org, which
|
|
127
|
+
// the team policy then refuses.
|
|
128
|
+
...(isClerkOrgId(orgId) ? { orgId } : {}),
|
|
129
|
+
},
|
|
130
|
+
};
|
|
108
131
|
}
|
|
109
132
|
return { ok: false, reason: 'malformed verdict' };
|
|
110
133
|
}
|
|
@@ -147,6 +170,67 @@ export class SidecarServer {
|
|
|
147
170
|
writable: true,
|
|
148
171
|
value: 0
|
|
149
172
|
});
|
|
173
|
+
/** A hello's credential is being checked (the check awaits the device
|
|
174
|
+
* store). A second hello in that window is ignored, exactly as one after
|
|
175
|
+
* `authed` is. */
|
|
176
|
+
Object.defineProperty(this, "helloInFlight", {
|
|
177
|
+
enumerable: true,
|
|
178
|
+
configurable: true,
|
|
179
|
+
writable: true,
|
|
180
|
+
value: false
|
|
181
|
+
});
|
|
182
|
+
/** The device this connection authenticated as (or was just issued), when
|
|
183
|
+
* the daemon keeps a device store. Stamps `self` in `devices-list`, and is
|
|
184
|
+
* what a revocation closes. */
|
|
185
|
+
Object.defineProperty(this, "deviceId", {
|
|
186
|
+
enumerable: true,
|
|
187
|
+
configurable: true,
|
|
188
|
+
writable: true,
|
|
189
|
+
value: void 0
|
|
190
|
+
});
|
|
191
|
+
/** The device id to hand over on the welcome, set only by an enrollment,
|
|
192
|
+
* cleared the moment the welcome is sent. */
|
|
193
|
+
Object.defineProperty(this, "issuedDevice", {
|
|
194
|
+
enumerable: true,
|
|
195
|
+
configurable: true,
|
|
196
|
+
writable: true,
|
|
197
|
+
value: void 0
|
|
198
|
+
});
|
|
199
|
+
/** Resolves the pending `client-proof` wait (single use). */
|
|
200
|
+
Object.defineProperty(this, "clientProofResolve", {
|
|
201
|
+
enumerable: true,
|
|
202
|
+
configurable: true,
|
|
203
|
+
writable: true,
|
|
204
|
+
value: void 0
|
|
205
|
+
});
|
|
206
|
+
/** Unsubscribe from the device store's revocations. */
|
|
207
|
+
Object.defineProperty(this, "revocationOff", {
|
|
208
|
+
enumerable: true,
|
|
209
|
+
configurable: true,
|
|
210
|
+
writable: true,
|
|
211
|
+
value: void 0
|
|
212
|
+
});
|
|
213
|
+
/** The thumbprint of the device key this connection proved: what every
|
|
214
|
+
* identity assertion on it, at pairing and at each re-check, must bind. */
|
|
215
|
+
Object.defineProperty(this, "deviceJkt", {
|
|
216
|
+
enumerable: true,
|
|
217
|
+
configurable: true,
|
|
218
|
+
writable: true,
|
|
219
|
+
value: void 0
|
|
220
|
+
});
|
|
221
|
+
/** The periodic identity re-check (team mode), cleared on close. */
|
|
222
|
+
Object.defineProperty(this, "recheckTimer", {
|
|
223
|
+
enumerable: true,
|
|
224
|
+
configurable: true,
|
|
225
|
+
writable: true,
|
|
226
|
+
value: void 0
|
|
227
|
+
});
|
|
228
|
+
Object.defineProperty(this, "recheckInFlight", {
|
|
229
|
+
enumerable: true,
|
|
230
|
+
configurable: true,
|
|
231
|
+
writable: true,
|
|
232
|
+
value: false
|
|
233
|
+
});
|
|
150
234
|
Object.defineProperty(this, "clearHelloDeadline", {
|
|
151
235
|
enumerable: true,
|
|
152
236
|
configurable: true,
|
|
@@ -205,7 +289,7 @@ export class SidecarServer {
|
|
|
205
289
|
value: []
|
|
206
290
|
});
|
|
207
291
|
/** The VERIFIED user id (the identity token's `sub`), set at most once, in
|
|
208
|
-
* `onHello`, after the
|
|
292
|
+
* `onHello`, after the device key was proven and the verifier answered. It is
|
|
209
293
|
* never re-assigned: no message re-authenticates, so a hijacked socket cannot
|
|
210
294
|
* swap whose connection this is. Undefined = unverified, which is every
|
|
211
295
|
* connection on a daemon with no `identity` verifier. The token itself is
|
|
@@ -446,6 +530,12 @@ export class SidecarServer {
|
|
|
446
530
|
this.ch.onClose?.(() => {
|
|
447
531
|
this.closed = true;
|
|
448
532
|
this.clearHelloDeadline();
|
|
533
|
+
this.unwatchRevocation();
|
|
534
|
+
if (this.recheckTimer !== undefined)
|
|
535
|
+
clearInterval(this.recheckTimer);
|
|
536
|
+
this.recheckTimer = undefined;
|
|
537
|
+
this.clientProofResolve?.(null);
|
|
538
|
+
this.clientProofResolve = undefined;
|
|
449
539
|
// A connection that drops while `onHello` is awaiting `identity-assert`
|
|
450
540
|
// must not leave that promise pending until the unrelated 3s timer
|
|
451
541
|
// fires -- settle it now, exactly like a timeout, so onHello observes
|
|
@@ -485,13 +575,23 @@ export class SidecarServer {
|
|
|
485
575
|
* connection. `authed` is set SYNCHRONOUSLY inside onHello, before its
|
|
486
576
|
* first await, so this also refuses a second hello that arrives while
|
|
487
577
|
* the first is still waiting on the identity verifier. */
|
|
488
|
-
if (this.authed)
|
|
578
|
+
if (this.authed || this.helloInFlight)
|
|
489
579
|
return;
|
|
490
580
|
/* onHello is async now, so a throw after its awaits (attendance.attach,
|
|
491
581
|
* sendWelcome, hosts.attach) would be an UNHANDLED rejection -- which
|
|
492
582
|
* takes the whole daemon down, not just this connection. Log it. */
|
|
493
583
|
void this.onHello(m).catch((err) => this.opts.log?.(`hello failed: ${errMessage(err)}`));
|
|
494
584
|
return;
|
|
585
|
+
case 'client-proof': {
|
|
586
|
+
// Only the proof the daemon is waiting for counts; the resolver is
|
|
587
|
+
// cleared on first use, so a second (or unsolicited) one is ignored.
|
|
588
|
+
const resolve = this.clientProofResolve;
|
|
589
|
+
this.clientProofResolve = undefined;
|
|
590
|
+
resolve?.(typeof m.sig === 'string'
|
|
591
|
+
? { sig: m.sig, ...(typeof m.codeProof === 'string' ? { codeProof: m.codeProof } : {}) }
|
|
592
|
+
: null);
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
495
595
|
case 'identity-assert': {
|
|
496
596
|
// Only the FIRST identity-assert on a connection counts -- the
|
|
497
597
|
// resolver is cleared the instant it is used (right here), so a
|
|
@@ -590,7 +690,7 @@ export class SidecarServer {
|
|
|
590
690
|
return;
|
|
591
691
|
case 'roots-browse':
|
|
592
692
|
// Auth-gated like every other request: directory enumeration must never
|
|
593
|
-
// precede
|
|
693
|
+
// precede authentication. Failures answer ok:false rather than throwing
|
|
594
694
|
// across the channel.
|
|
595
695
|
if (!this.authed) {
|
|
596
696
|
this.ch.send({ t: 'roots-browse-result', id: m.id, ok: false, error: 'not authenticated' });
|
|
@@ -699,6 +799,7 @@ export class SidecarServer {
|
|
|
699
799
|
case 'config-list':
|
|
700
800
|
case 'config-read':
|
|
701
801
|
case 'config-write':
|
|
802
|
+
case 'config-append':
|
|
702
803
|
case 'config-delete':
|
|
703
804
|
// AUTHENTICATION FIRST, and a refusal rather than an empty answer: an
|
|
704
805
|
// unauthenticated caller must not learn whether a definition exists.
|
|
@@ -801,6 +902,20 @@ export class SidecarServer {
|
|
|
801
902
|
case 'push-unsubscribe':
|
|
802
903
|
void this.onPushUnsubscribe(m);
|
|
803
904
|
return;
|
|
905
|
+
case 'devices-list':
|
|
906
|
+
if (!this.authed) {
|
|
907
|
+
this.ch.send({ t: 'devices-list-result', id: m.id, ok: false, error: 'not authenticated' });
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
void this.onDevicesList(m);
|
|
911
|
+
return;
|
|
912
|
+
case 'devices-revoke':
|
|
913
|
+
if (!this.authed) {
|
|
914
|
+
this.ch.send({ t: 'devices-revoke-result', id: m.id, ok: false, error: 'not authenticated' });
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
void this.onDevicesRevoke(m);
|
|
918
|
+
return;
|
|
804
919
|
case 'hosts-list':
|
|
805
920
|
// `ok: false`, not an empty estate: telling an unauthenticated caller
|
|
806
921
|
// "this daemon holds no synced hosts" is a claim it has not earned --
|
|
@@ -934,21 +1049,375 @@ export class SidecarServer {
|
|
|
934
1049
|
this.ch.close();
|
|
935
1050
|
}
|
|
936
1051
|
}
|
|
1052
|
+
/** Refuse a hello's credential. ONE reason for every credential failure --
|
|
1053
|
+
* a wrong or unknown device, a revoked one, an expired, used or unknown
|
|
1054
|
+
* code, a hello with no device or code at all, a store that could not be
|
|
1055
|
+
* read -- so a denial is never an oracle for which it was. The log line
|
|
1056
|
+
* says which path failed (the operator's terminal, not the wire). Every
|
|
1057
|
+
* one counts toward MAX_FAILED_HELLOS. */
|
|
1058
|
+
denyCredential(path) {
|
|
1059
|
+
this.opts.log?.(`denied: invalid credentials (${path})`);
|
|
1060
|
+
this.ch.send({ t: 'denied', reason: 'invalid credentials' });
|
|
1061
|
+
this.noteOutcome('denied');
|
|
1062
|
+
this.countFailedHello();
|
|
1063
|
+
}
|
|
1064
|
+
/** Tell the daemon's rate limiter how a hello ended. Its failure is never
|
|
1065
|
+
* the handshake's. */
|
|
1066
|
+
noteOutcome(outcome) {
|
|
1067
|
+
try {
|
|
1068
|
+
this.opts.onHelloOutcome?.(outcome);
|
|
1069
|
+
}
|
|
1070
|
+
catch (err) {
|
|
1071
|
+
this.opts.log?.(`onHelloOutcome threw: ${errMessage(err)}`);
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
/** Whether the daemon's policy admits this VERIFIED identity. A policy that
|
|
1075
|
+
* throws admits nobody. No policy (the harness's own tests) admits any
|
|
1076
|
+
* verified identity. */
|
|
1077
|
+
admits(identity) {
|
|
1078
|
+
const a = this.opts.admission;
|
|
1079
|
+
if (!a)
|
|
1080
|
+
return true;
|
|
1081
|
+
try {
|
|
1082
|
+
return a.admits(identity) === true;
|
|
1083
|
+
}
|
|
1084
|
+
catch (err) {
|
|
1085
|
+
this.opts.log?.(`admission policy threw, refusing: ${errMessage(err)}`);
|
|
1086
|
+
return false;
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
/** THE IDENTITY GATE (spec 2026-09-29-device-enrollment-design, Phase 2),
|
|
1090
|
+
* run inside the credential check once the device key is proven -- for an
|
|
1091
|
+
* enrollment, BEFORE the code is burned -- so a code alone never enrolls an
|
|
1092
|
+
* unverified or unadmitted identity. With no verifier wired it passes (the
|
|
1093
|
+
* harness's own tests); the node daemon always wires one, which makes a
|
|
1094
|
+
* verified, admitted identity REQUIRED. The challenge names the org the
|
|
1095
|
+
* policy wants asserted (team mode), so the client asks the issuer for it.
|
|
1096
|
+
* On success `verifiedUserId` is set, here and nowhere else. Every failure
|
|
1097
|
+
* is logged with its reason on the operator's terminal and left to the
|
|
1098
|
+
* caller's uniform denial on the wire. */
|
|
1099
|
+
async identityGate(jkt, boundUser) {
|
|
1100
|
+
if (!this.opts.identity)
|
|
1101
|
+
return true;
|
|
1102
|
+
const sidecarId = this.opts.info?.id;
|
|
1103
|
+
if (!sidecarId) {
|
|
1104
|
+
this.opts.log?.('identity: refused (this daemon has no id for an assertion to name)');
|
|
1105
|
+
return false;
|
|
1106
|
+
}
|
|
1107
|
+
const nonce = makeNonce();
|
|
1108
|
+
const orgId = this.opts.admission?.orgId;
|
|
1109
|
+
this.ch.send({ t: 'identity-challenge', nonce, sidecarId, ...(orgId ? { orgId } : {}) });
|
|
1110
|
+
const verdict = await this.awaitAndVerifyIdentity(sidecarId, nonce, jkt, IDENTITY_TIMEOUT_MS);
|
|
1111
|
+
if (!verdict.ok) {
|
|
1112
|
+
this.opts.log?.(`identity: unverified (${verdict.reason})`);
|
|
1113
|
+
return false;
|
|
1114
|
+
}
|
|
1115
|
+
if (!this.admits(verdict.identity)) {
|
|
1116
|
+
this.opts.log?.(orgId ? `identity: verified, but not a member of ${orgId}` : 'identity: verified, but not the account this daemon belongs to');
|
|
1117
|
+
return false;
|
|
1118
|
+
}
|
|
1119
|
+
/* TEAM: ONE USER PER DEVICE. A device belongs to the first verified user
|
|
1120
|
+
* it connected as; another member signed in on the same browser does not
|
|
1121
|
+
* inherit it (its revocations and run ownership follow the recorded
|
|
1122
|
+
* user). A device recorded with no user yet is taken by this one. */
|
|
1123
|
+
if (this.opts.admission?.oneUserPerDevice && boundUser !== undefined && boundUser !== verdict.identity.userId) {
|
|
1124
|
+
this.opts.log?.('identity: verified, but this device belongs to another user');
|
|
1125
|
+
return false;
|
|
1126
|
+
}
|
|
1127
|
+
this.verifiedUserId = verdict.identity.userId;
|
|
1128
|
+
this.identityResult = { verified: true };
|
|
1129
|
+
this.opts.log?.('identity: verified');
|
|
1130
|
+
return true;
|
|
1131
|
+
}
|
|
1132
|
+
/** Start the periodic re-check of an authenticated connection's identity
|
|
1133
|
+
* (`SidecarAdmission.recheckMs`, team mode). */
|
|
1134
|
+
startRecheck() {
|
|
1135
|
+
const every = this.opts.admission?.recheckMs;
|
|
1136
|
+
if (!this.opts.identity || !every || every <= 0 || this.closed)
|
|
1137
|
+
return;
|
|
1138
|
+
const timer = setInterval(() => { void this.recheck(); }, every);
|
|
1139
|
+
timer.unref?.();
|
|
1140
|
+
this.recheckTimer = timer;
|
|
1141
|
+
}
|
|
1142
|
+
/** Re-run the challenge/assert exchange on an open connection. It stays open
|
|
1143
|
+
* only when the SAME user re-asserts within RECHECK_TIMEOUT_MS and the
|
|
1144
|
+
* policy still admits them; a member removed from the org is out at the
|
|
1145
|
+
* next tick. */
|
|
1146
|
+
async recheck() {
|
|
1147
|
+
if (this.closed || !this.authed || this.recheckInFlight)
|
|
1148
|
+
return;
|
|
1149
|
+
const sidecarId = this.opts.info?.id;
|
|
1150
|
+
const jkt = this.deviceJkt;
|
|
1151
|
+
if (!sidecarId || !jkt) {
|
|
1152
|
+
this.ch.close();
|
|
1153
|
+
return;
|
|
1154
|
+
}
|
|
1155
|
+
this.recheckInFlight = true;
|
|
1156
|
+
try {
|
|
1157
|
+
const nonce = makeNonce();
|
|
1158
|
+
const orgId = this.opts.admission?.orgId;
|
|
1159
|
+
this.ch.send({ t: 'identity-challenge', nonce, sidecarId, ...(orgId ? { orgId } : {}) });
|
|
1160
|
+
const verdict = await this.awaitAndVerifyIdentity(sidecarId, nonce, jkt, RECHECK_TIMEOUT_MS);
|
|
1161
|
+
if (this.closed)
|
|
1162
|
+
return;
|
|
1163
|
+
const reason = !verdict.ok ? verdict.reason
|
|
1164
|
+
: verdict.identity.userId !== this.verifiedUserId ? 'a different user answered'
|
|
1165
|
+
: !this.admits(verdict.identity) ? 'no longer admitted'
|
|
1166
|
+
: null;
|
|
1167
|
+
if (reason !== null) {
|
|
1168
|
+
this.opts.log?.(`identity re-check failed (${reason}); closing the connection`);
|
|
1169
|
+
this.ch.close();
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
finally {
|
|
1173
|
+
this.recheckInFlight = false;
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
now() {
|
|
1177
|
+
return this.opts.now?.() ?? Date.now();
|
|
1178
|
+
}
|
|
1179
|
+
/* THE ONE PLACE A CREDENTIAL IS CHECKED is `onHello`, through the helpers
|
|
1180
|
+
* below (spec 2026-09-29-device-enrollment-design). Exactly one credential
|
|
1181
|
+
* is examined, chosen in this order; there is no other, and a hello with
|
|
1182
|
+
* neither is refused:
|
|
1183
|
+
* 1. `device`: the daemon proves its key (`server-proof`), the client
|
|
1184
|
+
* proves the key recorded for that device id (`client-proof`); unknown
|
|
1185
|
+
* and revoked ids are refused.
|
|
1186
|
+
* 2. `enroll`: the same exchange over the key the hello SUBMITTED, so the
|
|
1187
|
+
* client proves possession BEFORE the code is burned; then the code is
|
|
1188
|
+
* burned and the key recorded, in one store write.
|
|
1189
|
+
* No secret crosses the wire. */
|
|
1190
|
+
/** Send `server-proof` over `subject` and wait for the client's signature.
|
|
1191
|
+
* Null for no answer (timeout, close). The payload is built from values the
|
|
1192
|
+
* DAEMON chose or checked, never from anything the client can echo back. */
|
|
1193
|
+
async exchangeProofs(clientNonce, subject) {
|
|
1194
|
+
const key = this.opts.sidecarKey;
|
|
1195
|
+
if (!key)
|
|
1196
|
+
return null;
|
|
1197
|
+
const serverNonce = randomToken();
|
|
1198
|
+
const sidecarId = this.opts.info?.id ?? '';
|
|
1199
|
+
const sig = bytesToB64url(await key.sign(serverProofPayload({ clientNonce, serverNonce, sidecarId, sidecarJkt: key.jkt, subject })));
|
|
1200
|
+
if (this.closed)
|
|
1201
|
+
return null;
|
|
1202
|
+
const answer = new Promise((resolve) => {
|
|
1203
|
+
this.clientProofResolve = resolve;
|
|
1204
|
+
});
|
|
1205
|
+
this.ch.send({ t: 'server-proof', serverNonce, sidecarId, publicKey: key.publicJwk, sig });
|
|
1206
|
+
let timer;
|
|
1207
|
+
const timeout = new Promise((resolve) => { timer = setTimeout(() => resolve(null), PROOF_TIMEOUT_MS); });
|
|
1208
|
+
timer?.unref?.();
|
|
1209
|
+
try {
|
|
1210
|
+
const got = await Promise.race([answer, timeout]);
|
|
1211
|
+
return got ? { serverNonce, ...got } : null;
|
|
1212
|
+
}
|
|
1213
|
+
finally {
|
|
1214
|
+
if (timer !== undefined)
|
|
1215
|
+
clearTimeout(timer);
|
|
1216
|
+
this.clientProofResolve = undefined;
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
/** Whether `sig` is `key`'s signature over the client-proof payload for
|
|
1220
|
+
* this exchange. */
|
|
1221
|
+
async clientProofValid(key, clientNonce, serverNonce, subject, sig) {
|
|
1222
|
+
const sidecarKey = this.opts.sidecarKey;
|
|
1223
|
+
if (!sidecarKey)
|
|
1224
|
+
return false;
|
|
1225
|
+
return verifySignature(key, clientProofPayload({
|
|
1226
|
+
serverNonce, clientNonce, sidecarId: this.opts.info?.id ?? '', sidecarJkt: sidecarKey.jkt, subject,
|
|
1227
|
+
}), sig);
|
|
1228
|
+
}
|
|
1229
|
+
/** The device or enrollment path. Resolves the device this connection now
|
|
1230
|
+
* is and its key's jkt (what the pairing assertion binds), or null after a
|
|
1231
|
+
* denial has already been sent. */
|
|
1232
|
+
async checkDeviceOrCode(m, deviceName) {
|
|
1233
|
+
const devices = this.opts.devices;
|
|
1234
|
+
const clientNonce = m.clientNonce;
|
|
1235
|
+
const ready = !!devices && !!this.opts.sidecarKey && typeof clientNonce === 'string' && B64URL_32.test(clientNonce);
|
|
1236
|
+
if (m.device !== undefined) {
|
|
1237
|
+
const d = m.device;
|
|
1238
|
+
const id = typeof d === 'object' && d !== null ? d.id : undefined;
|
|
1239
|
+
if (!ready || !devices || typeof clientNonce !== 'string' || typeof id !== 'string' || !id || id.length > MAX_DEVICE_ID_LEN) {
|
|
1240
|
+
this.denyCredential('device');
|
|
1241
|
+
return null;
|
|
1242
|
+
}
|
|
1243
|
+
/* Subscribed BEFORE the exchange, so a revocation that lands between
|
|
1244
|
+
* the read and the welcome still closes this connection. Dropped again
|
|
1245
|
+
* on any refusal. */
|
|
1246
|
+
this.watchRevocation(id);
|
|
1247
|
+
const proof = await this.exchangeProofs(clientNonce, id);
|
|
1248
|
+
let record = null;
|
|
1249
|
+
if (proof) {
|
|
1250
|
+
try {
|
|
1251
|
+
record = await devices.get(id);
|
|
1252
|
+
}
|
|
1253
|
+
catch (err) {
|
|
1254
|
+
// FAIL CLOSED: a store that cannot be read admits no device.
|
|
1255
|
+
this.opts.log?.(`device store unreadable: ${errMessage(err)}`);
|
|
1256
|
+
record = null;
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
const key = record ? asEcPublicJwk(record.publicKeyJwk) : null;
|
|
1260
|
+
if (!proof || !record || !key || record.id !== id || record.revokedAt !== undefined
|
|
1261
|
+
|| !(await this.clientProofValid(key, clientNonce, proof.serverNonce, id, proof.sig))) {
|
|
1262
|
+
this.unwatchRevocation();
|
|
1263
|
+
this.denyCredential('device');
|
|
1264
|
+
return null;
|
|
1265
|
+
}
|
|
1266
|
+
const deviceJkt = await jwkThumbprint(key);
|
|
1267
|
+
if (!(await this.identityGate(deviceJkt, record.verifiedUserId))) {
|
|
1268
|
+
this.unwatchRevocation();
|
|
1269
|
+
this.denyCredential('identity');
|
|
1270
|
+
return null;
|
|
1271
|
+
}
|
|
1272
|
+
return { deviceId: id, deviceJkt };
|
|
1273
|
+
}
|
|
1274
|
+
if (m.enroll !== undefined) {
|
|
1275
|
+
const e = m.enroll;
|
|
1276
|
+
const submitted = typeof e === 'object' && e !== null ? asEcPublicJwk(e.publicKey) : null;
|
|
1277
|
+
/* A client that did not announce it can KEEP a device would throw the
|
|
1278
|
+
* one it was issued away, leaving a ghost device behind and the code
|
|
1279
|
+
* spent. Only a new client can obtain a code at all, so this refuses
|
|
1280
|
+
* nobody real. All checked before the store, so nothing is burned. The
|
|
1281
|
+
* same uniform denial as every other failure. */
|
|
1282
|
+
if (!ready || !devices || typeof clientNonce !== 'string' || !keepsDevices(m) || !submitted) {
|
|
1283
|
+
this.denyCredential('enrollment code');
|
|
1284
|
+
return null;
|
|
1285
|
+
}
|
|
1286
|
+
const keyJkt = await jwkThumbprint(submitted);
|
|
1287
|
+
const proof = await this.exchangeProofs(clientNonce, keyJkt);
|
|
1288
|
+
/* PROOF OF POSSESSION FIRST: a hello that cannot sign with the key it
|
|
1289
|
+
* submitted is refused BEFORE the store is touched, so no code is burned
|
|
1290
|
+
* by someone who does not hold the key. */
|
|
1291
|
+
if (!proof || !(await this.clientProofValid(submitted, clientNonce, proof.serverNonce, keyJkt, proof.sig))) {
|
|
1292
|
+
this.denyCredential('enrollment code');
|
|
1293
|
+
return null;
|
|
1294
|
+
}
|
|
1295
|
+
/* WHICH CODE. The client never sent one: it proved it knows one with an
|
|
1296
|
+
* HMAC keyed by the code's hash over this exchange's client-proof
|
|
1297
|
+
* payload. Each redeemable code's hash is tried (there are only ever a
|
|
1298
|
+
* few), in constant time per comparison. A code minted for another
|
|
1299
|
+
* sidecar key never reaches here with a proof, because the client pinned
|
|
1300
|
+
* that key and stopped at this daemon's server-proof. */
|
|
1301
|
+
let matched = null;
|
|
1302
|
+
try {
|
|
1303
|
+
const sidecarKey = this.opts.sidecarKey;
|
|
1304
|
+
if (proof.codeProof && sidecarKey) {
|
|
1305
|
+
const payload = clientProofPayload({
|
|
1306
|
+
serverNonce: proof.serverNonce, clientNonce, sidecarId: this.opts.info?.id ?? '', sidecarJkt: sidecarKey.jkt, subject: keyJkt,
|
|
1307
|
+
});
|
|
1308
|
+
for (const h of await devices.codeHashes(this.now())) {
|
|
1309
|
+
if (sameB64(await codeProof(h, payload), proof.codeProof))
|
|
1310
|
+
matched = h;
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
catch (err) {
|
|
1315
|
+
this.opts.log?.(`enrollment failed: ${errMessage(err)}`);
|
|
1316
|
+
matched = null;
|
|
1317
|
+
}
|
|
1318
|
+
if (!matched) {
|
|
1319
|
+
this.denyCredential('enrollment code');
|
|
1320
|
+
return null;
|
|
1321
|
+
}
|
|
1322
|
+
/* WHO, before anything is written: an unverified or unadmitted identity
|
|
1323
|
+
* is refused here with the code still unburned. */
|
|
1324
|
+
if (!(await this.identityGate(keyJkt))) {
|
|
1325
|
+
this.denyCredential('identity');
|
|
1326
|
+
return null;
|
|
1327
|
+
}
|
|
1328
|
+
const deviceId = makeDeviceId();
|
|
1329
|
+
this.watchRevocation(deviceId);
|
|
1330
|
+
let ok = false;
|
|
1331
|
+
try {
|
|
1332
|
+
const now = this.now();
|
|
1333
|
+
ok = await devices.enroll(matched, {
|
|
1334
|
+
id: deviceId, name: deviceName, createdAt: now, lastSeenAt: now, publicKeyJwk: submitted, jkt: keyJkt,
|
|
1335
|
+
...(this.verifiedUserId ? { verifiedUserId: this.verifiedUserId } : {}),
|
|
1336
|
+
}, now);
|
|
1337
|
+
}
|
|
1338
|
+
catch (err) {
|
|
1339
|
+
this.opts.log?.(`enrollment failed: ${errMessage(err)}`);
|
|
1340
|
+
ok = false;
|
|
1341
|
+
}
|
|
1342
|
+
if (!ok) {
|
|
1343
|
+
this.unwatchRevocation();
|
|
1344
|
+
this.denyCredential('enrollment code');
|
|
1345
|
+
return null;
|
|
1346
|
+
}
|
|
1347
|
+
this.opts.log?.(`enrolled device ${deviceId} (${deviceName})`);
|
|
1348
|
+
return { deviceId, deviceJkt: keyJkt, issued: { id: deviceId } };
|
|
1349
|
+
}
|
|
1350
|
+
// Unreachable: onHello only calls this with `device` or `enroll` present.
|
|
1351
|
+
this.denyCredential('none');
|
|
1352
|
+
return null;
|
|
1353
|
+
}
|
|
1354
|
+
/** Close this connection the moment its device is revoked. At most one
|
|
1355
|
+
* subscription per connection. */
|
|
1356
|
+
watchRevocation(deviceId) {
|
|
1357
|
+
this.unwatchRevocation();
|
|
1358
|
+
const devices = this.opts.devices;
|
|
1359
|
+
if (!devices)
|
|
1360
|
+
return;
|
|
1361
|
+
this.revocationOff = devices.onRevoked((id) => {
|
|
1362
|
+
if (id !== deviceId)
|
|
1363
|
+
return;
|
|
1364
|
+
this.opts.log?.(`device ${deviceId} revoked; closing its connection`);
|
|
1365
|
+
this.ch.close();
|
|
1366
|
+
});
|
|
1367
|
+
}
|
|
1368
|
+
unwatchRevocation() {
|
|
1369
|
+
this.revocationOff?.();
|
|
1370
|
+
this.revocationOff = undefined;
|
|
1371
|
+
}
|
|
937
1372
|
async onHello(m) {
|
|
938
1373
|
if (m.protocol !== SIDECAR_PROTOCOL) {
|
|
939
|
-
|
|
940
|
-
|
|
1374
|
+
/* Echoed only as a number: the field crossed the wire unvalidated, and
|
|
1375
|
+
* anything else (a long string, newlines) would be written into the
|
|
1376
|
+
* operator's log and back onto the wire verbatim. */
|
|
1377
|
+
const named = typeof m.protocol === 'number' && Number.isSafeInteger(m.protocol) ? String(m.protocol) : 'unknown';
|
|
1378
|
+
this.opts.log?.(`denied: unsupported protocol ${named}`);
|
|
1379
|
+
this.ch.send({ t: 'denied', reason: `protocol ${named} unsupported` });
|
|
1380
|
+
this.noteOutcome('denied');
|
|
941
1381
|
this.countFailedHello();
|
|
942
1382
|
return;
|
|
943
1383
|
}
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
1384
|
+
const deviceName = typeof m.clientName === 'string' && m.clientName
|
|
1385
|
+
? m.clientName.slice(0, MAX_IDENTITY_FIELD_LEN)
|
|
1386
|
+
: 'unnamed device';
|
|
1387
|
+
/* NO CREDENTIAL, NO CONNECTION. There is no shared pairing token: a hello
|
|
1388
|
+
* that names neither a device nor an enrollment is refused like any other
|
|
1389
|
+
* failure (a token sent by an older client included). */
|
|
1390
|
+
if (m.device === undefined && m.enroll === undefined) {
|
|
1391
|
+
this.denyCredential('no device or enrollment');
|
|
1392
|
+
return;
|
|
1393
|
+
}
|
|
1394
|
+
/* Latched SYNCHRONOUSLY, before the store is consulted, so a second hello
|
|
1395
|
+
* that arrives while this one is still being checked is ignored (see the
|
|
1396
|
+
* 'hello' case). Released on a denial so a client may try again, up to
|
|
1397
|
+
* MAX_FAILED_HELLOS. */
|
|
1398
|
+
this.helloInFlight = true;
|
|
1399
|
+
const cred = await this.checkDeviceOrCode(m, deviceName);
|
|
1400
|
+
if (!cred) {
|
|
1401
|
+
this.helloInFlight = false;
|
|
948
1402
|
return;
|
|
949
1403
|
}
|
|
1404
|
+
// Dropped while the store was consulted: nothing to authenticate.
|
|
1405
|
+
if (this.closed) {
|
|
1406
|
+
this.unwatchRevocation();
|
|
1407
|
+
return;
|
|
1408
|
+
}
|
|
1409
|
+
/** What every pairing assertion on this connection binds: the thumbprint
|
|
1410
|
+
* of the device key it proved. */
|
|
1411
|
+
this.deviceJkt = cred.deviceJkt;
|
|
1412
|
+
this.deviceId = cred.deviceId;
|
|
1413
|
+
this.issuedDevice = cred.issued;
|
|
1414
|
+
/* Authenticated only now: the device key is proven AND, when a verifier
|
|
1415
|
+
* is wired, the identity was verified and admitted (inside the credential
|
|
1416
|
+
* check). Nothing this connection sent before this point was served. */
|
|
950
1417
|
this.authed = true;
|
|
951
1418
|
this.clearHelloDeadline();
|
|
1419
|
+
this.noteOutcome('authed');
|
|
1420
|
+
this.startRecheck();
|
|
952
1421
|
this.hostStateDir = m.stateDir ?? DEFAULT_STATE_DIR;
|
|
953
1422
|
// Runtime-validate identity, even though the declared type is `string` --
|
|
954
1423
|
// the message crossed the wire via `JSON.parse(...) as SidecarMsg`, so
|
|
@@ -1044,66 +1513,32 @@ export class SidecarServer {
|
|
|
1044
1513
|
this.opts.log?.(`onClient hook threw: ${errMessage(err)}`);
|
|
1045
1514
|
}
|
|
1046
1515
|
}
|
|
1047
|
-
/* THE
|
|
1048
|
-
*
|
|
1049
|
-
*
|
|
1050
|
-
*
|
|
1051
|
-
*
|
|
1052
|
-
* own.
|
|
1053
|
-
*
|
|
1054
|
-
* The dirName lookup is STARTED here, before the identity verdict is
|
|
1055
|
-
* awaited, so the two bounded waits (DIR_NAME_TIMEOUT_MS,
|
|
1056
|
-
* IDENTITY_TIMEOUT_MS) overlap: the worst case stays one bound, not two.
|
|
1057
|
-
* Both only ever resolve (every failure is null / unverified), so starting
|
|
1058
|
-
* one and then awaiting the other cannot leave a rejection unhandled.
|
|
1516
|
+
/* THE DIRNAME WAIT. The welcome carries the synced directory name this
|
|
1517
|
+
* daemon resolved for this connection, and the client has no other way to
|
|
1518
|
+
* learn it -- it could previously only deduce it, and got null whenever no
|
|
1519
|
+
* workspace was open or two hosts held the same workspace id, leaving a
|
|
1520
|
+
* management surface unable to say which registrations were its own.
|
|
1059
1521
|
*
|
|
1060
|
-
*
|
|
1061
|
-
*
|
|
1062
|
-
*
|
|
1063
|
-
*
|
|
1064
|
-
*
|
|
1522
|
+
* The identity was already decided inside the credential check (it gates
|
|
1523
|
+
* authentication itself), so this is the only wait left. Bounded
|
|
1524
|
+
* (DIR_NAME_TIMEOUT_MS), and every dirName failure ends the same way as an
|
|
1525
|
+
* older daemon: no `dirName` on the welcome. A handshake that never
|
|
1526
|
+
* arrives would be a far worse trade than a client that falls back to
|
|
1527
|
+
* deducing. With no resolver nothing is awaited at all. */
|
|
1065
1528
|
const dirIdentity = this.clientId === undefined
|
|
1066
1529
|
? undefined
|
|
1067
1530
|
: { clientId: this.clientId, name: this.clientName ?? this.clientId };
|
|
1068
1531
|
const dirNamePending = this.opts.clientDirName && dirIdentity
|
|
1069
1532
|
? this.resolveDirName(this.opts.clientDirName, dirIdentity)
|
|
1070
1533
|
: undefined;
|
|
1071
|
-
/*
|
|
1072
|
-
*
|
|
1073
|
-
*
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
if (this.opts.identity) {
|
|
1080
|
-
if (!m.wantsIdentity) {
|
|
1081
|
-
this.identityResult = { verified: false, reason: 'not requested' };
|
|
1082
|
-
}
|
|
1083
|
-
else if (!this.opts.info?.id) {
|
|
1084
|
-
this.identityResult = { verified: false, reason: 'no daemon id' };
|
|
1085
|
-
}
|
|
1086
|
-
else {
|
|
1087
|
-
const sidecarId = this.opts.info.id;
|
|
1088
|
-
/* The hash of the pairing token THIS connection presented (it passed
|
|
1089
|
-
* the check above). The assertion's `cnf.tkh` must equal it: that
|
|
1090
|
-
* binds the assertion to the pairing the browser actually made, so a
|
|
1091
|
-
* daemon that holds our token cannot relay our challenge through a
|
|
1092
|
-
* browser paired with IT (that browser hashed its own, different,
|
|
1093
|
-
* token). STARTED here but not awaited: the digest is async, and the
|
|
1094
|
-
* challenge, the resolver and the bound timer must all be in place
|
|
1095
|
-
* synchronously -- it is awaited only once an assertion is in hand
|
|
1096
|
-
* (see verdictFromAssertion), inside the same bound. A digest that
|
|
1097
|
-
* rejects pairs unverified ('error'), never refused. */
|
|
1098
|
-
const tokenHash = pairingTokenHash(m.token ?? '').catch(() => null);
|
|
1099
|
-
const nonce = makeNonce();
|
|
1100
|
-
this.ch.send({ t: 'identity-challenge', nonce, sidecarId });
|
|
1101
|
-
const verdict = await this.awaitAndVerifyIdentity(sidecarId, nonce, tokenHash);
|
|
1102
|
-
if (verdict.ok)
|
|
1103
|
-
this.verifiedUserId = verdict.identity.userId;
|
|
1104
|
-
this.identityResult = verdict.ok ? { verified: true } : { verified: false, reason: verdict.reason };
|
|
1105
|
-
}
|
|
1106
|
-
this.opts.log?.(`identity: ${this.identityResult.verified ? 'verified' : `unverified (${this.identityResult.reason})`}`);
|
|
1534
|
+
/* Record the connect on the device. Best-effort and never awaited: a store
|
|
1535
|
+
* that cannot be written must not delay or break a handshake that already
|
|
1536
|
+
* passed. */
|
|
1537
|
+
if (this.deviceId && this.opts.devices) {
|
|
1538
|
+
const deviceId = this.deviceId;
|
|
1539
|
+
void this.opts.devices.touch(deviceId, this.now(), this.verifiedUserId).catch((err) => {
|
|
1540
|
+
this.opts.log?.(`could not record a connect for device ${deviceId}: ${errMessage(err)}`);
|
|
1541
|
+
});
|
|
1107
1542
|
}
|
|
1108
1543
|
/* OLDER APPS (crew deploy). A client that does not announce it can deploy
|
|
1109
1544
|
* has agents that sync here but never fire until deployed, and nothing on
|
|
@@ -1187,10 +1622,8 @@ export class SidecarServer {
|
|
|
1187
1622
|
* 'timed out' unverified for the non-assertion answers, 'assertion too
|
|
1188
1623
|
* long' WITHOUT calling the verifier for an oversized one, and otherwise
|
|
1189
1624
|
* the verifier's own verdict (never thrown -- a throw or rejection becomes
|
|
1190
|
-
* an unverified 'error').
|
|
1191
|
-
|
|
1192
|
-
* unverified 'error' without calling the verifier. */
|
|
1193
|
-
async verdictFromAssertion(answer, audience, nonce, tokenHashPending) {
|
|
1625
|
+
* an unverified 'error'). */
|
|
1626
|
+
async verdictFromAssertion(answer, audience, nonce, jkt) {
|
|
1194
1627
|
if (answer.kind === 'none')
|
|
1195
1628
|
return { ok: false, reason: 'no assertion' };
|
|
1196
1629
|
if (answer.kind === 'timeout')
|
|
@@ -1201,10 +1634,7 @@ export class SidecarServer {
|
|
|
1201
1634
|
const v = this.opts.identity;
|
|
1202
1635
|
if (!v)
|
|
1203
1636
|
return { ok: false, reason: 'no verifier' };
|
|
1204
|
-
const
|
|
1205
|
-
if (tokenHash === null)
|
|
1206
|
-
return { ok: false, reason: 'error' };
|
|
1207
|
-
const expect = { audience, nonce, tokenHash };
|
|
1637
|
+
const expect = { audience, nonce, jkt };
|
|
1208
1638
|
try {
|
|
1209
1639
|
const verdict = v.explain
|
|
1210
1640
|
? await v.explain(assertion, expect)
|
|
@@ -1217,20 +1647,21 @@ export class SidecarServer {
|
|
|
1217
1647
|
}
|
|
1218
1648
|
}
|
|
1219
1649
|
/** The identity verdict for this connection's challenge/assert exchange,
|
|
1220
|
-
* bounded by
|
|
1221
|
-
*
|
|
1222
|
-
*
|
|
1223
|
-
*
|
|
1224
|
-
*
|
|
1225
|
-
*
|
|
1226
|
-
* nowhere else. */
|
|
1227
|
-
async awaitAndVerifyIdentity(audience, nonce,
|
|
1650
|
+
* bounded by `timeoutMs` FROM THE CHALLENGE -- covering both the wait for
|
|
1651
|
+
* `identity-assert` and the verify call, in one race, so a slow verifier
|
|
1652
|
+
* cannot add a second timeout's worth of wait on top of a slow browser.
|
|
1653
|
+
* Every failure mode (no assertion, an oversized one, a throw, a rejection,
|
|
1654
|
+
* a malformed answer, a timeout) is an UNVERIFIED verdict with a reason; the
|
|
1655
|
+
* caller (`identityGate`, `recheck`) refuses on it. The assertion is passed
|
|
1656
|
+
* to the verifier and nowhere else. */
|
|
1657
|
+
async awaitAndVerifyIdentity(audience, nonce, jkt, timeoutMs) {
|
|
1228
1658
|
let timer;
|
|
1229
1659
|
try {
|
|
1230
1660
|
const timed = new Promise((res) => {
|
|
1231
|
-
timer = setTimeout(() => res({ ok: false, reason: 'timed out' }),
|
|
1661
|
+
timer = setTimeout(() => res({ ok: false, reason: 'timed out' }), timeoutMs);
|
|
1662
|
+
timer.unref?.();
|
|
1232
1663
|
});
|
|
1233
|
-
const answered = this.awaitIdentityAssertion().then((answer) => this.verdictFromAssertion(answer, audience, nonce,
|
|
1664
|
+
const answered = this.awaitIdentityAssertion().then((answer) => this.verdictFromAssertion(answer, audience, nonce, jkt));
|
|
1234
1665
|
const got = await Promise.race([answered, timed]);
|
|
1235
1666
|
return normalizeVerdict(got);
|
|
1236
1667
|
}
|
|
@@ -1316,6 +1747,8 @@ export class SidecarServer {
|
|
|
1316
1747
|
...(this.opts.runner?.hasDefaultModel ? ['default-model'] : []),
|
|
1317
1748
|
// A client offers Stop on a watched daemon run only where it would work.
|
|
1318
1749
|
...(this.opts.runner?.cancel ? ['run-cancel'] : []),
|
|
1750
|
+
// `run-history` understands `crew` (global vs one workspace).
|
|
1751
|
+
...(this.opts.runner?.history ? ['run-history-scope'] : []),
|
|
1319
1752
|
// A client starts a task only where the daemon will honour it: an older
|
|
1320
1753
|
// runner ignores `task`, and a dropped task must never look like a start.
|
|
1321
1754
|
...(this.opts.runner?.supportsTasks ? [RUN_TASK_FEATURE] : []),
|
|
@@ -1372,6 +1805,7 @@ export class SidecarServer {
|
|
|
1372
1805
|
// daemon's definitions as an empty workspace when what it actually has is
|
|
1373
1806
|
// no way to read them.
|
|
1374
1807
|
...(this.opts.config ? ['config'] : []),
|
|
1808
|
+
...(this.opts.config?.append ? ['config-append'] : []),
|
|
1375
1809
|
// Whether this daemon may file a SAME-MACHINE client under the account it
|
|
1376
1810
|
// is itself signed in as, rather than under the client's per-origin id.
|
|
1377
1811
|
// Advertised so a client can SAY that its directory is shared across the
|
|
@@ -1386,6 +1820,9 @@ export class SidecarServer {
|
|
|
1386
1820
|
// Whether this daemon verifies the browser's identity token. Advertised
|
|
1387
1821
|
// only with a real verifier behind it; the verdict itself is `identity`.
|
|
1388
1822
|
...(this.opts.identity ? ['verified-identity'] : []),
|
|
1823
|
+
// Whether this daemon keeps a device store: pairs with enrollment codes,
|
|
1824
|
+
// issues device credentials, and answers devices-list/devices-revoke.
|
|
1825
|
+
...(this.opts.devices ? [DEVICES_FEATURE] : []),
|
|
1389
1826
|
];
|
|
1390
1827
|
this.ch.send({
|
|
1391
1828
|
t: 'welcome', tools, features,
|
|
@@ -1393,7 +1830,10 @@ export class SidecarServer {
|
|
|
1393
1830
|
...(dirName ? { dirName } : {}),
|
|
1394
1831
|
...(this.opts.push ? { vapidPublicKey: this.opts.push.publicKey } : {}),
|
|
1395
1832
|
...(this.opts.identity ? { identity: this.identityResult } : {}),
|
|
1833
|
+
// The secret leaves the daemon here, once, and is not kept.
|
|
1834
|
+
...(this.issuedDevice ? { device: this.issuedDevice } : {}),
|
|
1396
1835
|
});
|
|
1836
|
+
this.issuedDevice = undefined;
|
|
1397
1837
|
}
|
|
1398
1838
|
/** Proxy a host HTTP request server-side. Restricted to LOOPBACK targets — the
|
|
1399
1839
|
* sidecar must never be usable as an SSRF pivot to internal/metadata hosts. The
|
|
@@ -1789,6 +2229,17 @@ export class SidecarServer {
|
|
|
1789
2229
|
this.ch.send({ t: 'run-error', id: m.id, error: 'this sidecar does not support watching runs' });
|
|
1790
2230
|
return;
|
|
1791
2231
|
}
|
|
2232
|
+
/* TEAM MODE: A RUN IS ITS STARTER'S TO WATCH. Watching streams the run's
|
|
2233
|
+
* events and its final text, which is its content, so it follows the same
|
|
2234
|
+
* rule as a run's output: only the verified user who started it. A run
|
|
2235
|
+
* with no recorded starter (the daemon's own clock) is refused. */
|
|
2236
|
+
if (this.opts.admission?.ownRunsOnly) {
|
|
2237
|
+
const starter = typeof m.runId === 'string' ? hub.startedBy?.(m.runId) : undefined;
|
|
2238
|
+
if (!this.verifiedUserId || starter !== this.verifiedUserId) {
|
|
2239
|
+
this.ch.send({ t: 'run-error', id: m.id, error: 'You can watch only runs you started.' });
|
|
2240
|
+
return;
|
|
2241
|
+
}
|
|
2242
|
+
}
|
|
1792
2243
|
const attached = hub.subscribe(m.runId, {
|
|
1793
2244
|
event: (event) => this.ch.send({ t: 'event', id: m.id, runId: m.runId, event }),
|
|
1794
2245
|
done: (r) => {
|
|
@@ -1855,9 +2306,13 @@ export class SidecarServer {
|
|
|
1855
2306
|
*/
|
|
1856
2307
|
async onRunHistory(m) {
|
|
1857
2308
|
try {
|
|
1858
|
-
const
|
|
1859
|
-
|
|
1860
|
-
|
|
2309
|
+
const history = this.opts.runner?.history?.bind(this.opts.runner);
|
|
2310
|
+
const crew = runHistoryCrewOf(m.crew);
|
|
2311
|
+
const identity = { clientId: this.clientId };
|
|
2312
|
+
// No crew: asked exactly as an older client asks.
|
|
2313
|
+
const runs = !history ? [] : crew
|
|
2314
|
+
? await history(identity, m.agent, m.limit, crew)
|
|
2315
|
+
: await history(identity, m.agent, m.limit);
|
|
1861
2316
|
this.ch.send({ t: 'run-history-result', id: m.id, runs });
|
|
1862
2317
|
}
|
|
1863
2318
|
catch {
|
|
@@ -1934,11 +2389,25 @@ export class SidecarServer {
|
|
|
1934
2389
|
// derived from an agent's `session:` block. A client that could set it
|
|
1935
2390
|
// would grant itself mutating tools inside a sandbox. Deliberately
|
|
1936
2391
|
// absent -- see session/guestRunner.ts.
|
|
1937
|
-
this.opts.sessions.run(m.sessionId, agent, input, onEvent, identity, {
|
|
2392
|
+
this.opts.sessions.run(m.sessionId, agent, input, onEvent, identity, {
|
|
2393
|
+
keepTranscript: m.keepTranscript === true,
|
|
2394
|
+
// Who asked and why, for the run's audit lines: from THIS
|
|
2395
|
+
// connection's verified identity, never the message, and a run a
|
|
2396
|
+
// connection asked for is a person's run whatever the message says.
|
|
2397
|
+
...(this.verifiedUserId ? { startedBy: this.verifiedUserId } : {}),
|
|
2398
|
+
origin: 'user',
|
|
2399
|
+
})
|
|
1938
2400
|
: (agent, input, onEvent, identity) =>
|
|
1939
2401
|
// Task and autonomy are forwarded as asked; the runner, not this
|
|
1940
2402
|
// class, decides whether the agent may run that way.
|
|
1941
|
-
this.opts.runner.run(agent, input, onEvent, identity, undefined, undefined, {
|
|
2403
|
+
this.opts.runner.run(agent, input, onEvent, identity, undefined, undefined, {
|
|
2404
|
+
task: m.task, autonomy: m.autonomy,
|
|
2405
|
+
// From THIS connection's verified identity, never the message.
|
|
2406
|
+
...(this.verifiedUserId ? { startedBy: this.verifiedUserId } : {}),
|
|
2407
|
+
// A run a connection asked for is a person's run (audit `origin`);
|
|
2408
|
+
// only the daemon's own dispatcher says otherwise.
|
|
2409
|
+
origin: 'user',
|
|
2410
|
+
});
|
|
1942
2411
|
let runId;
|
|
1943
2412
|
const pending = [];
|
|
1944
2413
|
const onEvent = (event) => {
|
|
@@ -1962,7 +2431,7 @@ export class SidecarServer {
|
|
|
1962
2431
|
// `onHostSyncPut`/`onHostSyncDelete` do for `hostSync.put`/`remove`.
|
|
1963
2432
|
const started = await start(m.agent, m.input, onEvent, { clientId: this.clientId });
|
|
1964
2433
|
runId = started.runId;
|
|
1965
|
-
this.opts.runs?.open(runId);
|
|
2434
|
+
this.opts.runs?.open(runId, this.verifiedUserId);
|
|
1966
2435
|
this.ch.send({ t: 'run-accepted', id: m.id, runId });
|
|
1967
2436
|
for (const event of pending) {
|
|
1968
2437
|
this.opts.runs?.publish(runId, event);
|
|
@@ -2038,6 +2507,96 @@ export class SidecarServer {
|
|
|
2038
2507
|
this.ch.send({ t: 'roots-create-result', id: m.id, ok: false, error: errMessage(e) });
|
|
2039
2508
|
}
|
|
2040
2509
|
}
|
|
2510
|
+
/** Every paired device, WITHOUT secret hashes, with `self` stamped from the
|
|
2511
|
+
* device THIS connection authenticated as -- never from the message. */
|
|
2512
|
+
async deviceSummaries() {
|
|
2513
|
+
const devices = this.opts.devices;
|
|
2514
|
+
if (!devices)
|
|
2515
|
+
return [];
|
|
2516
|
+
return (await devices.list()).map((d) => ({
|
|
2517
|
+
id: d.id,
|
|
2518
|
+
name: d.name,
|
|
2519
|
+
jkt: d.jkt,
|
|
2520
|
+
createdAt: d.createdAt,
|
|
2521
|
+
...(d.lastSeenAt !== undefined ? { lastSeenAt: d.lastSeenAt } : {}),
|
|
2522
|
+
...(d.revokedAt !== undefined ? { revokedAt: d.revokedAt } : {}),
|
|
2523
|
+
...(d.verifiedUserId !== undefined ? { verifiedUserId: d.verifiedUserId } : {}),
|
|
2524
|
+
self: this.deviceId !== undefined && d.id === this.deviceId,
|
|
2525
|
+
}));
|
|
2526
|
+
}
|
|
2527
|
+
async onDevicesList(m) {
|
|
2528
|
+
if (!this.opts.devices) {
|
|
2529
|
+
this.ch.send({ t: 'devices-list-result', id: m.id, ok: false, error: 'this daemon does not keep paired devices' });
|
|
2530
|
+
return;
|
|
2531
|
+
}
|
|
2532
|
+
try {
|
|
2533
|
+
this.ch.send({ t: 'devices-list-result', id: m.id, ok: true, devices: await this.deviceSummaries() });
|
|
2534
|
+
}
|
|
2535
|
+
catch (err) {
|
|
2536
|
+
this.ch.send({ t: 'devices-list-result', id: m.id, ok: false, error: errMessage(err) });
|
|
2537
|
+
}
|
|
2538
|
+
}
|
|
2539
|
+
/**
|
|
2540
|
+
* Revoke one device. The store's revocation fan-out closes that device's
|
|
2541
|
+
* live connections, on every per-connection server at once.
|
|
2542
|
+
*
|
|
2543
|
+
* The CALLER'S OWN device is refused here, from this connection's own
|
|
2544
|
+
* credential rather than anything in the message: revoking it would cut off
|
|
2545
|
+
* the screen the person is using, with no way back from it but a fresh code.
|
|
2546
|
+
*/
|
|
2547
|
+
async onDevicesRevoke(m) {
|
|
2548
|
+
const devices = this.opts.devices;
|
|
2549
|
+
if (!devices) {
|
|
2550
|
+
this.ch.send({ t: 'devices-revoke-result', id: m.id, ok: false, error: 'this daemon does not keep paired devices' });
|
|
2551
|
+
return;
|
|
2552
|
+
}
|
|
2553
|
+
if (typeof m.deviceId !== 'string' || !m.deviceId || m.deviceId.length > MAX_DEVICE_ID_LEN) {
|
|
2554
|
+
this.ch.send({ t: 'devices-revoke-result', id: m.id, ok: false, error: 'deviceId must be a device id' });
|
|
2555
|
+
return;
|
|
2556
|
+
}
|
|
2557
|
+
if (this.deviceId !== undefined && m.deviceId === this.deviceId) {
|
|
2558
|
+
this.ch.send({
|
|
2559
|
+
t: 'devices-revoke-result', id: m.id, ok: false,
|
|
2560
|
+
error: 'That is the device you are using. Revoke it from another device, or run `devices revoke` on the machine.',
|
|
2561
|
+
});
|
|
2562
|
+
return;
|
|
2563
|
+
}
|
|
2564
|
+
/* TEAM MODE: YOUR OWN DEVICES ONLY. Everyone on a team daemon is an org
|
|
2565
|
+
* member, so "paired" no longer means "the same person": a member may
|
|
2566
|
+
* revoke a device only when its recorded verified user is this
|
|
2567
|
+
* connection's VERIFIED user (never anything the message claims). A
|
|
2568
|
+
* connection with no verified user revokes nothing. The operator's local
|
|
2569
|
+
* `devices revoke` verb keeps full power; it does not come through here. */
|
|
2570
|
+
if (this.opts.admission?.ownDevicesOnly) {
|
|
2571
|
+
let owner;
|
|
2572
|
+
try {
|
|
2573
|
+
owner = (await devices.get(m.deviceId))?.verifiedUserId;
|
|
2574
|
+
}
|
|
2575
|
+
catch (err) {
|
|
2576
|
+
this.ch.send({ t: 'devices-revoke-result', id: m.id, ok: false, error: errMessage(err) });
|
|
2577
|
+
return;
|
|
2578
|
+
}
|
|
2579
|
+
if (!this.verifiedUserId || owner !== this.verifiedUserId) {
|
|
2580
|
+
this.ch.send({
|
|
2581
|
+
t: 'devices-revoke-result', id: m.id, ok: false,
|
|
2582
|
+
error: 'You can revoke only your own devices here. The machine owner can revoke any device with `corenel-sidecar devices revoke`.',
|
|
2583
|
+
});
|
|
2584
|
+
return;
|
|
2585
|
+
}
|
|
2586
|
+
}
|
|
2587
|
+
try {
|
|
2588
|
+
const revoked = await devices.revoke(m.deviceId, this.now());
|
|
2589
|
+
if (!revoked) {
|
|
2590
|
+
this.ch.send({ t: 'devices-revoke-result', id: m.id, ok: false, error: 'no such device, or it is already revoked' });
|
|
2591
|
+
return;
|
|
2592
|
+
}
|
|
2593
|
+
this.opts.log?.(`device ${m.deviceId} revoked by a paired client`);
|
|
2594
|
+
this.ch.send({ t: 'devices-revoke-result', id: m.id, ok: true, devices: await this.deviceSummaries() });
|
|
2595
|
+
}
|
|
2596
|
+
catch (err) {
|
|
2597
|
+
this.ch.send({ t: 'devices-revoke-result', id: m.id, ok: false, error: errMessage(err) });
|
|
2598
|
+
}
|
|
2599
|
+
}
|
|
2041
2600
|
/** Every synced host directory, with `self` stamped from THIS connection's
|
|
2042
2601
|
* own resolved directory -- never from anything the client sent. */
|
|
2043
2602
|
async onHostsList(m) {
|
|
@@ -2349,8 +2908,21 @@ export class SidecarServer {
|
|
|
2349
2908
|
this.ch.send({ t: 'run-plan-get-result', id: m.id, ok: false, error: 'runId must be a non-empty string' });
|
|
2350
2909
|
return;
|
|
2351
2910
|
}
|
|
2911
|
+
const output = m.output === true;
|
|
2352
2912
|
try {
|
|
2353
|
-
|
|
2913
|
+
/* TEAM MODE: A RUN'S OUTPUT IS ITS STARTER'S. Reports, check output and
|
|
2914
|
+
* the file list are answered only to the verified user who started the
|
|
2915
|
+
* run (recorded with it, restarts included). A run with no recorded
|
|
2916
|
+
* starter (the daemon's own clock, or an unverified start) is refused
|
|
2917
|
+
* too: fail closed. The plan alone is not gated. */
|
|
2918
|
+
if (output && this.opts.admission?.ownRunsOnly) {
|
|
2919
|
+
const starter = runner.runStartedBy ? await runner.runStartedBy(m.runId) : null;
|
|
2920
|
+
if (!this.verifiedUserId || starter !== this.verifiedUserId) {
|
|
2921
|
+
this.ch.send({ t: 'run-plan-get-result', id: m.id, ok: false, error: 'You can see the output only of runs you started.' });
|
|
2922
|
+
return;
|
|
2923
|
+
}
|
|
2924
|
+
}
|
|
2925
|
+
this.ch.send({ t: 'run-plan-get-result', id: m.id, ok: true, plan: await runner.runPlan(m.runId, { output }) });
|
|
2354
2926
|
}
|
|
2355
2927
|
catch (err) {
|
|
2356
2928
|
this.ch.send({ t: 'run-plan-get-result', id: m.id, ok: false, error: errMessage(err) });
|
|
@@ -2839,6 +3411,11 @@ export class SidecarServer {
|
|
|
2839
3411
|
}
|
|
2840
3412
|
if (m.t === 'config-write')
|
|
2841
3413
|
await svc.write(m.path, m.content);
|
|
3414
|
+
else if (m.t === 'config-append') {
|
|
3415
|
+
if (!svc.append)
|
|
3416
|
+
throw new Error('this sidecar cannot append to its harness config');
|
|
3417
|
+
await svc.append(m.path, m.content);
|
|
3418
|
+
}
|
|
2842
3419
|
else
|
|
2843
3420
|
await svc.remove(m.path);
|
|
2844
3421
|
this.ch.send({ t: 'config-result', id: m.id, ok: true });
|
|
@@ -2908,4 +3485,18 @@ export class SidecarServer {
|
|
|
2908
3485
|
});
|
|
2909
3486
|
}
|
|
2910
3487
|
}
|
|
3488
|
+
/** `run-history`'s `crew`, off the wire: anything but the two known shapes is
|
|
3489
|
+
* treated as absent (the global crew, as an older client meant). The id is
|
|
3490
|
+
* only a selector among the CALLER's own roots -- the runner matches it
|
|
3491
|
+
* exactly against the ones it enumerated -- so it is passed as given. */
|
|
3492
|
+
function runHistoryCrewOf(crew) {
|
|
3493
|
+
if (!crew || typeof crew !== 'object')
|
|
3494
|
+
return undefined;
|
|
3495
|
+
const c = crew;
|
|
3496
|
+
if (c.scope === 'global')
|
|
3497
|
+
return { scope: 'global' };
|
|
3498
|
+
if (c.scope !== 'workspace')
|
|
3499
|
+
return undefined;
|
|
3500
|
+
return typeof c.workspaceId === 'string' && c.workspaceId ? { scope: 'workspace', workspaceId: c.workspaceId } : { scope: 'workspace' };
|
|
3501
|
+
}
|
|
2911
3502
|
//# sourceMappingURL=server.js.map
|