@corenel/harness 0.12.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.
Files changed (62) hide show
  1. package/dist/core/fileChanges.d.ts +18 -0
  2. package/dist/core/fileChanges.d.ts.map +1 -0
  3. package/dist/core/fileChanges.js +85 -0
  4. package/dist/core/fileChanges.js.map +1 -0
  5. package/dist/core/loop.d.ts +10 -0
  6. package/dist/core/loop.d.ts.map +1 -1
  7. package/dist/core/loop.js +26 -1
  8. package/dist/core/loop.js.map +1 -1
  9. package/dist/core/spawn.d.ts +4 -0
  10. package/dist/core/spawn.d.ts.map +1 -1
  11. package/dist/core/spawn.js +11 -4
  12. package/dist/core/spawn.js.map +1 -1
  13. package/dist/dag/executeNode.d.ts +5 -0
  14. package/dist/dag/executeNode.d.ts.map +1 -1
  15. package/dist/dag/executeNode.js +5 -1
  16. package/dist/dag/executeNode.js.map +1 -1
  17. package/dist/guardrail/serialize.d.ts.map +1 -1
  18. package/dist/guardrail/serialize.js +4 -0
  19. package/dist/guardrail/serialize.js.map +1 -1
  20. package/dist/guardrail/validate.d.ts.map +1 -1
  21. package/dist/guardrail/validate.js +3 -1
  22. package/dist/guardrail/validate.js.map +1 -1
  23. package/dist/orchestrator/record.d.ts +94 -5
  24. package/dist/orchestrator/record.d.ts.map +1 -1
  25. package/dist/orchestrator/record.js +186 -5
  26. package/dist/orchestrator/record.js.map +1 -1
  27. package/dist/orchestrator/sidecarTools.d.ts +5 -0
  28. package/dist/orchestrator/sidecarTools.d.ts.map +1 -1
  29. package/dist/orchestrator/sidecarTools.js +53 -15
  30. package/dist/orchestrator/sidecarTools.js.map +1 -1
  31. package/dist/prompts/library/defaults.generated.js +2 -2
  32. package/dist/prompts/library/defaults.generated.js.map +1 -1
  33. package/dist/sidecar/deviceKeys.d.ts +75 -0
  34. package/dist/sidecar/deviceKeys.d.ts.map +1 -0
  35. package/dist/sidecar/deviceKeys.js +142 -0
  36. package/dist/sidecar/deviceKeys.js.map +1 -0
  37. package/dist/sidecar/host.d.ts +144 -24
  38. package/dist/sidecar/host.d.ts.map +1 -1
  39. package/dist/sidecar/host.js +306 -16
  40. package/dist/sidecar/host.js.map +1 -1
  41. package/dist/sidecar/identity.d.ts +41 -17
  42. package/dist/sidecar/identity.d.ts.map +1 -1
  43. package/dist/sidecar/identity.js +10 -21
  44. package/dist/sidecar/identity.js.map +1 -1
  45. package/dist/sidecar/server.d.ts +197 -28
  46. package/dist/sidecar/server.d.ts.map +1 -1
  47. package/dist/sidecar/server.js +690 -109
  48. package/dist/sidecar/server.js.map +1 -1
  49. package/dist/sidecar/testing.d.ts +97 -0
  50. package/dist/sidecar/testing.d.ts.map +1 -0
  51. package/dist/sidecar/testing.js +259 -0
  52. package/dist/sidecar/testing.js.map +1 -0
  53. package/dist/sidecar/wsChannel.d.ts +8 -1
  54. package/dist/sidecar/wsChannel.d.ts.map +1 -1
  55. package/dist/sidecar/wsChannel.js +17 -1
  56. package/dist/sidecar/wsChannel.js.map +1 -1
  57. package/dist/tools/callAgent.d.ts.map +1 -1
  58. package/dist/tools/callAgent.js +33 -13
  59. package/dist/tools/callAgent.js.map +1 -1
  60. package/dist/tools/orchestration.js +1 -1
  61. package/dist/tools/orchestration.js.map +1 -1
  62. package/package.json +2 -2
@@ -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 { pairingTokenHash } from './identity.js';
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
- export const HELLO_DEADLINE_MS = 10000;
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 on the identity verifier before going out
16
- * UNVERIFIED. The same bound as `DIR_NAME_TIMEOUT_MS`, and run concurrently
17
- * with it, so a verifier that hangs never makes the handshake slower than a
18
- * wedged dirName lookup already could. A timeout never refuses the connection:
19
- * it only withholds the widened trust a verified id would earn. */
20
- export const IDENTITY_TIMEOUT_MS = 3000;
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
- /** Constant-time string compare for the pairing token — avoids leaking it via the
73
- * early-exit timing of `!==`. Pure JS so this stays host-agnostic (no node:crypto in
74
- * a possibly-bundled module). The token is a fixed-length hex string, so comparing
75
- * lengths first reveals nothing secret. */
76
- function safeEqual(a, b) {
77
- if (a.length !== b.length)
78
- return false;
79
- let diff = 0;
80
- for (let i = 0; i < a.length; i++)
81
- diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
82
- return diff === 0;
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 { ok: true, identity: { userId, issuer: typeof issuer === 'string' ? issuer : '' } };
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 pairing token passed and the verifier answered. It is
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 the pairing token. Failures answer ok:false rather than throwing
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
- this.opts.log?.(`denied: unsupported protocol ${m.protocol}`);
940
- this.ch.send({ t: 'denied', reason: `protocol ${m.protocol} unsupported` });
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
- if (!safeEqual(m.token ?? '', this.opts.token)) {
945
- this.opts.log?.('denied: invalid token');
946
- this.ch.send({ t: 'denied', reason: 'invalid token' });
947
- this.countFailedHello();
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 WAITS, started together. The welcome carries the synced directory
1048
- * name this daemon resolved for this connection, and the client has no
1049
- * other way to learn it -- it could previously only deduce it, and got null
1050
- * whenever no workspace was open or two hosts held the same workspace id,
1051
- * leaving a management surface unable to say which registrations were its
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
- * Bounded, and every dirName failure ends the same way as an older daemon:
1061
- * no `dirName` on the welcome. A handshake that never arrives would be a
1062
- * far worse trade than a client that falls back to deducing. With neither
1063
- * a resolver nor a verifier nothing is awaited at all, and the welcome
1064
- * goes out in the same tick as today. */
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
- /* IDENTITY, decided BEFORE attendance or push can see this connection.
1072
- * Reached only past the pairing-token check above -- the challenge is
1073
- * never sent for a bad pairing token. Whatever the verdict, the
1074
- * connection pairs: unverified only withholds the widened trust a verified
1075
- * id earns. `verifiedUserId` is assigned here and nowhere else, once. The
1076
- * assertion is never logged; only the reason is. The Clerk token itself
1077
- * never reaches this class -- this exchange only ever sees a corenel-api
1078
- * pairing assertion (spec amendment 2026-09-25b). */
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'). The expectation is built here, once the
1191
- * pairing-token hash has settled; a hash that could not be computed is an
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 tokenHash = await tokenHashPending;
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 IDENTITY_TIMEOUT_MS FROM THE CHALLENGE -- covering both the
1221
- * wait for `identity-assert` and the verify call, in one race, so a slow
1222
- * verifier cannot add a second timeout's worth of wait on top of a slow
1223
- * browser. Every failure mode (no assertion, an oversized one, a throw, a
1224
- * rejection, a malformed answer, a timeout) is an UNVERIFIED verdict with a
1225
- * reason -- never a refusal. The assertion is passed to the verifier and
1226
- * nowhere else. */
1227
- async awaitAndVerifyIdentity(audience, nonce, tokenHash) {
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' }), IDENTITY_TIMEOUT_MS);
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, tokenHash));
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 runs = this.opts.runner?.history
1859
- ? await this.opts.runner.history({ clientId: this.clientId }, m.agent, m.limit)
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 {
@@ -1938,7 +2393,11 @@ export class SidecarServer {
1938
2393
  : (agent, input, onEvent, identity) =>
1939
2394
  // Task and autonomy are forwarded as asked; the runner, not this
1940
2395
  // class, decides whether the agent may run that way.
1941
- this.opts.runner.run(agent, input, onEvent, identity, undefined, undefined, { task: m.task, autonomy: m.autonomy });
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
+ });
1942
2401
  let runId;
1943
2402
  const pending = [];
1944
2403
  const onEvent = (event) => {
@@ -1962,7 +2421,7 @@ export class SidecarServer {
1962
2421
  // `onHostSyncPut`/`onHostSyncDelete` do for `hostSync.put`/`remove`.
1963
2422
  const started = await start(m.agent, m.input, onEvent, { clientId: this.clientId });
1964
2423
  runId = started.runId;
1965
- this.opts.runs?.open(runId);
2424
+ this.opts.runs?.open(runId, this.verifiedUserId);
1966
2425
  this.ch.send({ t: 'run-accepted', id: m.id, runId });
1967
2426
  for (const event of pending) {
1968
2427
  this.opts.runs?.publish(runId, event);
@@ -2038,6 +2497,96 @@ export class SidecarServer {
2038
2497
  this.ch.send({ t: 'roots-create-result', id: m.id, ok: false, error: errMessage(e) });
2039
2498
  }
2040
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
+ }
2041
2590
  /** Every synced host directory, with `self` stamped from THIS connection's
2042
2591
  * own resolved directory -- never from anything the client sent. */
2043
2592
  async onHostsList(m) {
@@ -2349,8 +2898,21 @@ export class SidecarServer {
2349
2898
  this.ch.send({ t: 'run-plan-get-result', id: m.id, ok: false, error: 'runId must be a non-empty string' });
2350
2899
  return;
2351
2900
  }
2901
+ const output = m.output === true;
2352
2902
  try {
2353
- this.ch.send({ t: 'run-plan-get-result', id: m.id, ok: true, plan: await runner.runPlan(m.runId) });
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 }) });
2354
2916
  }
2355
2917
  catch (err) {
2356
2918
  this.ch.send({ t: 'run-plan-get-result', id: m.id, ok: false, error: errMessage(err) });
@@ -2839,6 +3401,11 @@ export class SidecarServer {
2839
3401
  }
2840
3402
  if (m.t === 'config-write')
2841
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
+ }
2842
3409
  else
2843
3410
  await svc.remove(m.path);
2844
3411
  this.ch.send({ t: 'config-result', id: m.id, ok: true });
@@ -2908,4 +3475,18 @@ export class SidecarServer {
2908
3475
  });
2909
3476
  }
2910
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
+ }
2911
3492
  //# sourceMappingURL=server.js.map