@corenel/harness 0.11.0 → 0.13.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/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/loop.d.ts +10 -0
- package/dist/core/loop.d.ts.map +1 -1
- package/dist/core/loop.js +32 -2
- package/dist/core/loop.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/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 +4 -0
- package/dist/guardrail/permission-service.d.ts.map +1 -1
- package/dist/guardrail/permission-service.js +10 -3
- 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/agentSearch.d.ts.map +1 -1
- package/dist/orchestrator/agentSearch.js +10 -0
- package/dist/orchestrator/agentSearch.js.map +1 -1
- package/dist/orchestrator/planDoc.d.ts +26 -0
- package/dist/orchestrator/planDoc.d.ts.map +1 -1
- package/dist/orchestrator/planDoc.js +132 -10
- package/dist/orchestrator/planDoc.js.map +1 -1
- package/dist/orchestrator/record.d.ts +100 -4
- package/dist/orchestrator/record.d.ts.map +1 -1
- package/dist/orchestrator/record.js +238 -4
- 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/init.d.ts +12 -0
- package/dist/prompts/init.d.ts.map +1 -0
- package/dist/prompts/init.js +48 -0
- package/dist/prompts/init.js.map +1 -0
- package/dist/prompts/library/defaults.generated.js +2 -2
- package/dist/prompts/library/defaults.generated.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 +157 -23
- package/dist/sidecar/host.d.ts.map +1 -1
- package/dist/sidecar/host.js +340 -15
- 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 +202 -30
- package/dist/sidecar/server.d.ts.map +1 -1
- package/dist/sidecar/server.js +722 -109
- 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/orchestration.js +1 -1
- package/dist/tools/orchestration.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, CREW_DEPLOY_FEATURE } 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
|
|
@@ -584,11 +684,13 @@ export class SidecarServer {
|
|
|
584
684
|
// answer is dropped silently rather than forwarded into the registry.
|
|
585
685
|
if (!this.authed)
|
|
586
686
|
return;
|
|
587
|
-
|
|
687
|
+
// The note is the client's text: cleaned HERE, at the daemon's edge,
|
|
688
|
+
// and only a deny carries one.
|
|
689
|
+
this.opts.attendance?.answer(m.id, m.decision, this.attended, m.decision === 'deny' ? denialNote(m.note) : undefined);
|
|
588
690
|
return;
|
|
589
691
|
case 'roots-browse':
|
|
590
692
|
// Auth-gated like every other request: directory enumeration must never
|
|
591
|
-
// precede
|
|
693
|
+
// precede authentication. Failures answer ok:false rather than throwing
|
|
592
694
|
// across the channel.
|
|
593
695
|
if (!this.authed) {
|
|
594
696
|
this.ch.send({ t: 'roots-browse-result', id: m.id, ok: false, error: 'not authenticated' });
|
|
@@ -697,6 +799,7 @@ export class SidecarServer {
|
|
|
697
799
|
case 'config-list':
|
|
698
800
|
case 'config-read':
|
|
699
801
|
case 'config-write':
|
|
802
|
+
case 'config-append':
|
|
700
803
|
case 'config-delete':
|
|
701
804
|
// AUTHENTICATION FIRST, and a refusal rather than an empty answer: an
|
|
702
805
|
// unauthenticated caller must not learn whether a definition exists.
|
|
@@ -783,12 +886,36 @@ export class SidecarServer {
|
|
|
783
886
|
}
|
|
784
887
|
void this.onWorkspaceRelease(m);
|
|
785
888
|
return;
|
|
889
|
+
case 'run-plan-get':
|
|
890
|
+
// Authorized exactly like `subscribe` (watching a run): authenticated,
|
|
891
|
+
// any run id. The answer is narrower than the event stream a watcher
|
|
892
|
+
// already receives.
|
|
893
|
+
if (!this.authed) {
|
|
894
|
+
this.ch.send({ t: 'run-plan-get-result', id: m.id, ok: false, error: 'not authenticated' });
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
void this.onRunPlanGet(m);
|
|
898
|
+
return;
|
|
786
899
|
case 'push-subscribe':
|
|
787
900
|
void this.onPushSubscribe(m);
|
|
788
901
|
return;
|
|
789
902
|
case 'push-unsubscribe':
|
|
790
903
|
void this.onPushUnsubscribe(m);
|
|
791
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;
|
|
792
919
|
case 'hosts-list':
|
|
793
920
|
// `ok: false`, not an empty estate: telling an unauthenticated caller
|
|
794
921
|
// "this daemon holds no synced hosts" is a claim it has not earned --
|
|
@@ -922,21 +1049,375 @@ export class SidecarServer {
|
|
|
922
1049
|
this.ch.close();
|
|
923
1050
|
}
|
|
924
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
|
+
}
|
|
925
1372
|
async onHello(m) {
|
|
926
1373
|
if (m.protocol !== SIDECAR_PROTOCOL) {
|
|
927
|
-
|
|
928
|
-
|
|
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');
|
|
929
1381
|
this.countFailedHello();
|
|
930
1382
|
return;
|
|
931
1383
|
}
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
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;
|
|
936
1402
|
return;
|
|
937
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. */
|
|
938
1417
|
this.authed = true;
|
|
939
1418
|
this.clearHelloDeadline();
|
|
1419
|
+
this.noteOutcome('authed');
|
|
1420
|
+
this.startRecheck();
|
|
940
1421
|
this.hostStateDir = m.stateDir ?? DEFAULT_STATE_DIR;
|
|
941
1422
|
// Runtime-validate identity, even though the declared type is `string` --
|
|
942
1423
|
// the message crossed the wire via `JSON.parse(...) as SidecarMsg`, so
|
|
@@ -1032,66 +1513,32 @@ export class SidecarServer {
|
|
|
1032
1513
|
this.opts.log?.(`onClient hook threw: ${errMessage(err)}`);
|
|
1033
1514
|
}
|
|
1034
1515
|
}
|
|
1035
|
-
/* THE
|
|
1036
|
-
*
|
|
1037
|
-
*
|
|
1038
|
-
*
|
|
1039
|
-
*
|
|
1040
|
-
* own.
|
|
1041
|
-
*
|
|
1042
|
-
* The dirName lookup is STARTED here, before the identity verdict is
|
|
1043
|
-
* awaited, so the two bounded waits (DIR_NAME_TIMEOUT_MS,
|
|
1044
|
-
* IDENTITY_TIMEOUT_MS) overlap: the worst case stays one bound, not two.
|
|
1045
|
-
* Both only ever resolve (every failure is null / unverified), so starting
|
|
1046
|
-
* 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.
|
|
1047
1521
|
*
|
|
1048
|
-
*
|
|
1049
|
-
*
|
|
1050
|
-
*
|
|
1051
|
-
*
|
|
1052
|
-
*
|
|
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. */
|
|
1053
1528
|
const dirIdentity = this.clientId === undefined
|
|
1054
1529
|
? undefined
|
|
1055
1530
|
: { clientId: this.clientId, name: this.clientName ?? this.clientId };
|
|
1056
1531
|
const dirNamePending = this.opts.clientDirName && dirIdentity
|
|
1057
1532
|
? this.resolveDirName(this.opts.clientDirName, dirIdentity)
|
|
1058
1533
|
: undefined;
|
|
1059
|
-
/*
|
|
1060
|
-
*
|
|
1061
|
-
*
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
if (this.opts.identity) {
|
|
1068
|
-
if (!m.wantsIdentity) {
|
|
1069
|
-
this.identityResult = { verified: false, reason: 'not requested' };
|
|
1070
|
-
}
|
|
1071
|
-
else if (!this.opts.info?.id) {
|
|
1072
|
-
this.identityResult = { verified: false, reason: 'no daemon id' };
|
|
1073
|
-
}
|
|
1074
|
-
else {
|
|
1075
|
-
const sidecarId = this.opts.info.id;
|
|
1076
|
-
/* The hash of the pairing token THIS connection presented (it passed
|
|
1077
|
-
* the check above). The assertion's `cnf.tkh` must equal it: that
|
|
1078
|
-
* binds the assertion to the pairing the browser actually made, so a
|
|
1079
|
-
* daemon that holds our token cannot relay our challenge through a
|
|
1080
|
-
* browser paired with IT (that browser hashed its own, different,
|
|
1081
|
-
* token). STARTED here but not awaited: the digest is async, and the
|
|
1082
|
-
* challenge, the resolver and the bound timer must all be in place
|
|
1083
|
-
* synchronously -- it is awaited only once an assertion is in hand
|
|
1084
|
-
* (see verdictFromAssertion), inside the same bound. A digest that
|
|
1085
|
-
* rejects pairs unverified ('error'), never refused. */
|
|
1086
|
-
const tokenHash = pairingTokenHash(m.token ?? '').catch(() => null);
|
|
1087
|
-
const nonce = makeNonce();
|
|
1088
|
-
this.ch.send({ t: 'identity-challenge', nonce, sidecarId });
|
|
1089
|
-
const verdict = await this.awaitAndVerifyIdentity(sidecarId, nonce, tokenHash);
|
|
1090
|
-
if (verdict.ok)
|
|
1091
|
-
this.verifiedUserId = verdict.identity.userId;
|
|
1092
|
-
this.identityResult = verdict.ok ? { verified: true } : { verified: false, reason: verdict.reason };
|
|
1093
|
-
}
|
|
1094
|
-
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
|
+
});
|
|
1095
1542
|
}
|
|
1096
1543
|
/* OLDER APPS (crew deploy). A client that does not announce it can deploy
|
|
1097
1544
|
* has agents that sync here but never fire until deployed, and nothing on
|
|
@@ -1175,10 +1622,8 @@ export class SidecarServer {
|
|
|
1175
1622
|
* 'timed out' unverified for the non-assertion answers, 'assertion too
|
|
1176
1623
|
* long' WITHOUT calling the verifier for an oversized one, and otherwise
|
|
1177
1624
|
* the verifier's own verdict (never thrown -- a throw or rejection becomes
|
|
1178
|
-
* an unverified 'error').
|
|
1179
|
-
|
|
1180
|
-
* unverified 'error' without calling the verifier. */
|
|
1181
|
-
async verdictFromAssertion(answer, audience, nonce, tokenHashPending) {
|
|
1625
|
+
* an unverified 'error'). */
|
|
1626
|
+
async verdictFromAssertion(answer, audience, nonce, jkt) {
|
|
1182
1627
|
if (answer.kind === 'none')
|
|
1183
1628
|
return { ok: false, reason: 'no assertion' };
|
|
1184
1629
|
if (answer.kind === 'timeout')
|
|
@@ -1189,10 +1634,7 @@ export class SidecarServer {
|
|
|
1189
1634
|
const v = this.opts.identity;
|
|
1190
1635
|
if (!v)
|
|
1191
1636
|
return { ok: false, reason: 'no verifier' };
|
|
1192
|
-
const
|
|
1193
|
-
if (tokenHash === null)
|
|
1194
|
-
return { ok: false, reason: 'error' };
|
|
1195
|
-
const expect = { audience, nonce, tokenHash };
|
|
1637
|
+
const expect = { audience, nonce, jkt };
|
|
1196
1638
|
try {
|
|
1197
1639
|
const verdict = v.explain
|
|
1198
1640
|
? await v.explain(assertion, expect)
|
|
@@ -1205,20 +1647,21 @@ export class SidecarServer {
|
|
|
1205
1647
|
}
|
|
1206
1648
|
}
|
|
1207
1649
|
/** The identity verdict for this connection's challenge/assert exchange,
|
|
1208
|
-
* bounded by
|
|
1209
|
-
*
|
|
1210
|
-
*
|
|
1211
|
-
*
|
|
1212
|
-
*
|
|
1213
|
-
*
|
|
1214
|
-
* nowhere else. */
|
|
1215
|
-
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) {
|
|
1216
1658
|
let timer;
|
|
1217
1659
|
try {
|
|
1218
1660
|
const timed = new Promise((res) => {
|
|
1219
|
-
timer = setTimeout(() => res({ ok: false, reason: 'timed out' }),
|
|
1661
|
+
timer = setTimeout(() => res({ ok: false, reason: 'timed out' }), timeoutMs);
|
|
1662
|
+
timer.unref?.();
|
|
1220
1663
|
});
|
|
1221
|
-
const answered = this.awaitIdentityAssertion().then((answer) => this.verdictFromAssertion(answer, audience, nonce,
|
|
1664
|
+
const answered = this.awaitIdentityAssertion().then((answer) => this.verdictFromAssertion(answer, audience, nonce, jkt));
|
|
1222
1665
|
const got = await Promise.race([answered, timed]);
|
|
1223
1666
|
return normalizeVerdict(got);
|
|
1224
1667
|
}
|
|
@@ -1304,9 +1747,13 @@ export class SidecarServer {
|
|
|
1304
1747
|
...(this.opts.runner?.hasDefaultModel ? ['default-model'] : []),
|
|
1305
1748
|
// A client offers Stop on a watched daemon run only where it would work.
|
|
1306
1749
|
...(this.opts.runner?.cancel ? ['run-cancel'] : []),
|
|
1750
|
+
// `run-history` understands `crew` (global vs one workspace).
|
|
1751
|
+
...(this.opts.runner?.history ? ['run-history-scope'] : []),
|
|
1307
1752
|
// A client starts a task only where the daemon will honour it: an older
|
|
1308
1753
|
// runner ignores `task`, and a dropped task must never look like a start.
|
|
1309
1754
|
...(this.opts.runner?.supportsTasks ? [RUN_TASK_FEATURE] : []),
|
|
1755
|
+
// Whether this daemon answers run-plan-get (a runner that reads run records).
|
|
1756
|
+
...(this.opts.runner?.runPlan ? [RUN_PLAN_FEATURE] : []),
|
|
1310
1757
|
...(this.opts.acp ? ['acp'] : []),
|
|
1311
1758
|
...(this.opts.roots?.areas.length ? ['add-roots'] : []),
|
|
1312
1759
|
...(this.opts.roots?.areas.length && this.opts.roots.create ? ['create-roots'] : []),
|
|
@@ -1358,6 +1805,7 @@ export class SidecarServer {
|
|
|
1358
1805
|
// daemon's definitions as an empty workspace when what it actually has is
|
|
1359
1806
|
// no way to read them.
|
|
1360
1807
|
...(this.opts.config ? ['config'] : []),
|
|
1808
|
+
...(this.opts.config?.append ? ['config-append'] : []),
|
|
1361
1809
|
// Whether this daemon may file a SAME-MACHINE client under the account it
|
|
1362
1810
|
// is itself signed in as, rather than under the client's per-origin id.
|
|
1363
1811
|
// Advertised so a client can SAY that its directory is shared across the
|
|
@@ -1372,6 +1820,9 @@ export class SidecarServer {
|
|
|
1372
1820
|
// Whether this daemon verifies the browser's identity token. Advertised
|
|
1373
1821
|
// only with a real verifier behind it; the verdict itself is `identity`.
|
|
1374
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] : []),
|
|
1375
1826
|
];
|
|
1376
1827
|
this.ch.send({
|
|
1377
1828
|
t: 'welcome', tools, features,
|
|
@@ -1379,7 +1830,10 @@ export class SidecarServer {
|
|
|
1379
1830
|
...(dirName ? { dirName } : {}),
|
|
1380
1831
|
...(this.opts.push ? { vapidPublicKey: this.opts.push.publicKey } : {}),
|
|
1381
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 } : {}),
|
|
1382
1835
|
});
|
|
1836
|
+
this.issuedDevice = undefined;
|
|
1383
1837
|
}
|
|
1384
1838
|
/** Proxy a host HTTP request server-side. Restricted to LOOPBACK targets — the
|
|
1385
1839
|
* sidecar must never be usable as an SSRF pivot to internal/metadata hosts. The
|
|
@@ -1775,6 +2229,17 @@ export class SidecarServer {
|
|
|
1775
2229
|
this.ch.send({ t: 'run-error', id: m.id, error: 'this sidecar does not support watching runs' });
|
|
1776
2230
|
return;
|
|
1777
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
|
+
}
|
|
1778
2243
|
const attached = hub.subscribe(m.runId, {
|
|
1779
2244
|
event: (event) => this.ch.send({ t: 'event', id: m.id, runId: m.runId, event }),
|
|
1780
2245
|
done: (r) => {
|
|
@@ -1841,9 +2306,13 @@ export class SidecarServer {
|
|
|
1841
2306
|
*/
|
|
1842
2307
|
async onRunHistory(m) {
|
|
1843
2308
|
try {
|
|
1844
|
-
const
|
|
1845
|
-
|
|
1846
|
-
|
|
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);
|
|
1847
2316
|
this.ch.send({ t: 'run-history-result', id: m.id, runs });
|
|
1848
2317
|
}
|
|
1849
2318
|
catch {
|
|
@@ -1924,7 +2393,11 @@ export class SidecarServer {
|
|
|
1924
2393
|
: (agent, input, onEvent, identity) =>
|
|
1925
2394
|
// Task and autonomy are forwarded as asked; the runner, not this
|
|
1926
2395
|
// class, decides whether the agent may run that way.
|
|
1927
|
-
this.opts.runner.run(agent, input, onEvent, identity, undefined, undefined, {
|
|
2396
|
+
this.opts.runner.run(agent, input, onEvent, identity, undefined, undefined, {
|
|
2397
|
+
task: m.task, autonomy: m.autonomy,
|
|
2398
|
+
// From THIS connection's verified identity, never the message.
|
|
2399
|
+
...(this.verifiedUserId ? { startedBy: this.verifiedUserId } : {}),
|
|
2400
|
+
});
|
|
1928
2401
|
let runId;
|
|
1929
2402
|
const pending = [];
|
|
1930
2403
|
const onEvent = (event) => {
|
|
@@ -1948,7 +2421,7 @@ export class SidecarServer {
|
|
|
1948
2421
|
// `onHostSyncPut`/`onHostSyncDelete` do for `hostSync.put`/`remove`.
|
|
1949
2422
|
const started = await start(m.agent, m.input, onEvent, { clientId: this.clientId });
|
|
1950
2423
|
runId = started.runId;
|
|
1951
|
-
this.opts.runs?.open(runId);
|
|
2424
|
+
this.opts.runs?.open(runId, this.verifiedUserId);
|
|
1952
2425
|
this.ch.send({ t: 'run-accepted', id: m.id, runId });
|
|
1953
2426
|
for (const event of pending) {
|
|
1954
2427
|
this.opts.runs?.publish(runId, event);
|
|
@@ -2024,6 +2497,96 @@ export class SidecarServer {
|
|
|
2024
2497
|
this.ch.send({ t: 'roots-create-result', id: m.id, ok: false, error: errMessage(e) });
|
|
2025
2498
|
}
|
|
2026
2499
|
}
|
|
2500
|
+
/** Every paired device, WITHOUT secret hashes, with `self` stamped from the
|
|
2501
|
+
* device THIS connection authenticated as -- never from the message. */
|
|
2502
|
+
async deviceSummaries() {
|
|
2503
|
+
const devices = this.opts.devices;
|
|
2504
|
+
if (!devices)
|
|
2505
|
+
return [];
|
|
2506
|
+
return (await devices.list()).map((d) => ({
|
|
2507
|
+
id: d.id,
|
|
2508
|
+
name: d.name,
|
|
2509
|
+
jkt: d.jkt,
|
|
2510
|
+
createdAt: d.createdAt,
|
|
2511
|
+
...(d.lastSeenAt !== undefined ? { lastSeenAt: d.lastSeenAt } : {}),
|
|
2512
|
+
...(d.revokedAt !== undefined ? { revokedAt: d.revokedAt } : {}),
|
|
2513
|
+
...(d.verifiedUserId !== undefined ? { verifiedUserId: d.verifiedUserId } : {}),
|
|
2514
|
+
self: this.deviceId !== undefined && d.id === this.deviceId,
|
|
2515
|
+
}));
|
|
2516
|
+
}
|
|
2517
|
+
async onDevicesList(m) {
|
|
2518
|
+
if (!this.opts.devices) {
|
|
2519
|
+
this.ch.send({ t: 'devices-list-result', id: m.id, ok: false, error: 'this daemon does not keep paired devices' });
|
|
2520
|
+
return;
|
|
2521
|
+
}
|
|
2522
|
+
try {
|
|
2523
|
+
this.ch.send({ t: 'devices-list-result', id: m.id, ok: true, devices: await this.deviceSummaries() });
|
|
2524
|
+
}
|
|
2525
|
+
catch (err) {
|
|
2526
|
+
this.ch.send({ t: 'devices-list-result', id: m.id, ok: false, error: errMessage(err) });
|
|
2527
|
+
}
|
|
2528
|
+
}
|
|
2529
|
+
/**
|
|
2530
|
+
* Revoke one device. The store's revocation fan-out closes that device's
|
|
2531
|
+
* live connections, on every per-connection server at once.
|
|
2532
|
+
*
|
|
2533
|
+
* The CALLER'S OWN device is refused here, from this connection's own
|
|
2534
|
+
* credential rather than anything in the message: revoking it would cut off
|
|
2535
|
+
* the screen the person is using, with no way back from it but a fresh code.
|
|
2536
|
+
*/
|
|
2537
|
+
async onDevicesRevoke(m) {
|
|
2538
|
+
const devices = this.opts.devices;
|
|
2539
|
+
if (!devices) {
|
|
2540
|
+
this.ch.send({ t: 'devices-revoke-result', id: m.id, ok: false, error: 'this daemon does not keep paired devices' });
|
|
2541
|
+
return;
|
|
2542
|
+
}
|
|
2543
|
+
if (typeof m.deviceId !== 'string' || !m.deviceId || m.deviceId.length > MAX_DEVICE_ID_LEN) {
|
|
2544
|
+
this.ch.send({ t: 'devices-revoke-result', id: m.id, ok: false, error: 'deviceId must be a device id' });
|
|
2545
|
+
return;
|
|
2546
|
+
}
|
|
2547
|
+
if (this.deviceId !== undefined && m.deviceId === this.deviceId) {
|
|
2548
|
+
this.ch.send({
|
|
2549
|
+
t: 'devices-revoke-result', id: m.id, ok: false,
|
|
2550
|
+
error: 'That is the device you are using. Revoke it from another device, or run `devices revoke` on the machine.',
|
|
2551
|
+
});
|
|
2552
|
+
return;
|
|
2553
|
+
}
|
|
2554
|
+
/* TEAM MODE: YOUR OWN DEVICES ONLY. Everyone on a team daemon is an org
|
|
2555
|
+
* member, so "paired" no longer means "the same person": a member may
|
|
2556
|
+
* revoke a device only when its recorded verified user is this
|
|
2557
|
+
* connection's VERIFIED user (never anything the message claims). A
|
|
2558
|
+
* connection with no verified user revokes nothing. The operator's local
|
|
2559
|
+
* `devices revoke` verb keeps full power; it does not come through here. */
|
|
2560
|
+
if (this.opts.admission?.ownDevicesOnly) {
|
|
2561
|
+
let owner;
|
|
2562
|
+
try {
|
|
2563
|
+
owner = (await devices.get(m.deviceId))?.verifiedUserId;
|
|
2564
|
+
}
|
|
2565
|
+
catch (err) {
|
|
2566
|
+
this.ch.send({ t: 'devices-revoke-result', id: m.id, ok: false, error: errMessage(err) });
|
|
2567
|
+
return;
|
|
2568
|
+
}
|
|
2569
|
+
if (!this.verifiedUserId || owner !== this.verifiedUserId) {
|
|
2570
|
+
this.ch.send({
|
|
2571
|
+
t: 'devices-revoke-result', id: m.id, ok: false,
|
|
2572
|
+
error: 'You can revoke only your own devices here. The machine owner can revoke any device with `corenel-sidecar devices revoke`.',
|
|
2573
|
+
});
|
|
2574
|
+
return;
|
|
2575
|
+
}
|
|
2576
|
+
}
|
|
2577
|
+
try {
|
|
2578
|
+
const revoked = await devices.revoke(m.deviceId, this.now());
|
|
2579
|
+
if (!revoked) {
|
|
2580
|
+
this.ch.send({ t: 'devices-revoke-result', id: m.id, ok: false, error: 'no such device, or it is already revoked' });
|
|
2581
|
+
return;
|
|
2582
|
+
}
|
|
2583
|
+
this.opts.log?.(`device ${m.deviceId} revoked by a paired client`);
|
|
2584
|
+
this.ch.send({ t: 'devices-revoke-result', id: m.id, ok: true, devices: await this.deviceSummaries() });
|
|
2585
|
+
}
|
|
2586
|
+
catch (err) {
|
|
2587
|
+
this.ch.send({ t: 'devices-revoke-result', id: m.id, ok: false, error: errMessage(err) });
|
|
2588
|
+
}
|
|
2589
|
+
}
|
|
2027
2590
|
/** Every synced host directory, with `self` stamped from THIS connection's
|
|
2028
2591
|
* own resolved directory -- never from anything the client sent. */
|
|
2029
2592
|
async onHostsList(m) {
|
|
@@ -2324,6 +2887,37 @@ export class SidecarServer {
|
|
|
2324
2887
|
this.ch.send({ t: 'workspace-release-result', id: m.id, ok: false, error: errMessage(err) });
|
|
2325
2888
|
}
|
|
2326
2889
|
}
|
|
2890
|
+
async onRunPlanGet(m) {
|
|
2891
|
+
const runner = this.opts.runner;
|
|
2892
|
+
if (!runner?.runPlan) {
|
|
2893
|
+
this.ch.send({ t: 'run-plan-get-result', id: m.id, ok: false, error: 'this daemon does not report run plans' });
|
|
2894
|
+
return;
|
|
2895
|
+
}
|
|
2896
|
+
// Runtime-validate: the message arrived via `JSON.parse(...) as SidecarMsg`.
|
|
2897
|
+
if (typeof m.runId !== 'string' || !m.runId) {
|
|
2898
|
+
this.ch.send({ t: 'run-plan-get-result', id: m.id, ok: false, error: 'runId must be a non-empty string' });
|
|
2899
|
+
return;
|
|
2900
|
+
}
|
|
2901
|
+
const output = m.output === true;
|
|
2902
|
+
try {
|
|
2903
|
+
/* TEAM MODE: A RUN'S OUTPUT IS ITS STARTER'S. Reports, check output and
|
|
2904
|
+
* the file list are answered only to the verified user who started the
|
|
2905
|
+
* run (recorded with it, restarts included). A run with no recorded
|
|
2906
|
+
* starter (the daemon's own clock, or an unverified start) is refused
|
|
2907
|
+
* too: fail closed. The plan alone is not gated. */
|
|
2908
|
+
if (output && this.opts.admission?.ownRunsOnly) {
|
|
2909
|
+
const starter = runner.runStartedBy ? await runner.runStartedBy(m.runId) : null;
|
|
2910
|
+
if (!this.verifiedUserId || starter !== this.verifiedUserId) {
|
|
2911
|
+
this.ch.send({ t: 'run-plan-get-result', id: m.id, ok: false, error: 'You can see the output only of runs you started.' });
|
|
2912
|
+
return;
|
|
2913
|
+
}
|
|
2914
|
+
}
|
|
2915
|
+
this.ch.send({ t: 'run-plan-get-result', id: m.id, ok: true, plan: await runner.runPlan(m.runId, { output }) });
|
|
2916
|
+
}
|
|
2917
|
+
catch (err) {
|
|
2918
|
+
this.ch.send({ t: 'run-plan-get-result', id: m.id, ok: false, error: errMessage(err) });
|
|
2919
|
+
}
|
|
2920
|
+
}
|
|
2327
2921
|
/** The identity a push subscription/unsubscription is filed under -- ALWAYS
|
|
2328
2922
|
* this connection's own authenticated `clientId`/`accounts`, never anything
|
|
2329
2923
|
* the message itself carries. Auth-gated first, same as every other
|
|
@@ -2807,6 +3401,11 @@ export class SidecarServer {
|
|
|
2807
3401
|
}
|
|
2808
3402
|
if (m.t === 'config-write')
|
|
2809
3403
|
await svc.write(m.path, m.content);
|
|
3404
|
+
else if (m.t === 'config-append') {
|
|
3405
|
+
if (!svc.append)
|
|
3406
|
+
throw new Error('this sidecar cannot append to its harness config');
|
|
3407
|
+
await svc.append(m.path, m.content);
|
|
3408
|
+
}
|
|
2810
3409
|
else
|
|
2811
3410
|
await svc.remove(m.path);
|
|
2812
3411
|
this.ch.send({ t: 'config-result', id: m.id, ok: true });
|
|
@@ -2876,4 +3475,18 @@ export class SidecarServer {
|
|
|
2876
3475
|
});
|
|
2877
3476
|
}
|
|
2878
3477
|
}
|
|
3478
|
+
/** `run-history`'s `crew`, off the wire: anything but the two known shapes is
|
|
3479
|
+
* treated as absent (the global crew, as an older client meant). The id is
|
|
3480
|
+
* only a selector among the CALLER's own roots -- the runner matches it
|
|
3481
|
+
* exactly against the ones it enumerated -- so it is passed as given. */
|
|
3482
|
+
function runHistoryCrewOf(crew) {
|
|
3483
|
+
if (!crew || typeof crew !== 'object')
|
|
3484
|
+
return undefined;
|
|
3485
|
+
const c = crew;
|
|
3486
|
+
if (c.scope === 'global')
|
|
3487
|
+
return { scope: 'global' };
|
|
3488
|
+
if (c.scope !== 'workspace')
|
|
3489
|
+
return undefined;
|
|
3490
|
+
return typeof c.workspaceId === 'string' && c.workspaceId ? { scope: 'workspace', workspaceId: c.workspaceId } : { scope: 'workspace' };
|
|
3491
|
+
}
|
|
2879
3492
|
//# sourceMappingURL=server.js.map
|