@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.
Files changed (77) 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 +32 -2
  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/permission-service.d.ts +4 -0
  18. package/dist/guardrail/permission-service.d.ts.map +1 -1
  19. package/dist/guardrail/permission-service.js +10 -3
  20. package/dist/guardrail/permission-service.js.map +1 -1
  21. package/dist/guardrail/serialize.d.ts.map +1 -1
  22. package/dist/guardrail/serialize.js +4 -0
  23. package/dist/guardrail/serialize.js.map +1 -1
  24. package/dist/guardrail/validate.d.ts.map +1 -1
  25. package/dist/guardrail/validate.js +3 -1
  26. package/dist/guardrail/validate.js.map +1 -1
  27. package/dist/orchestrator/agentSearch.d.ts.map +1 -1
  28. package/dist/orchestrator/agentSearch.js +10 -0
  29. package/dist/orchestrator/agentSearch.js.map +1 -1
  30. package/dist/orchestrator/planDoc.d.ts +26 -0
  31. package/dist/orchestrator/planDoc.d.ts.map +1 -1
  32. package/dist/orchestrator/planDoc.js +132 -10
  33. package/dist/orchestrator/planDoc.js.map +1 -1
  34. package/dist/orchestrator/record.d.ts +100 -4
  35. package/dist/orchestrator/record.d.ts.map +1 -1
  36. package/dist/orchestrator/record.js +238 -4
  37. package/dist/orchestrator/record.js.map +1 -1
  38. package/dist/orchestrator/sidecarTools.d.ts +5 -0
  39. package/dist/orchestrator/sidecarTools.d.ts.map +1 -1
  40. package/dist/orchestrator/sidecarTools.js +53 -15
  41. package/dist/orchestrator/sidecarTools.js.map +1 -1
  42. package/dist/prompts/init.d.ts +12 -0
  43. package/dist/prompts/init.d.ts.map +1 -0
  44. package/dist/prompts/init.js +48 -0
  45. package/dist/prompts/init.js.map +1 -0
  46. package/dist/prompts/library/defaults.generated.js +2 -2
  47. package/dist/prompts/library/defaults.generated.js.map +1 -1
  48. package/dist/sidecar/deviceKeys.d.ts +75 -0
  49. package/dist/sidecar/deviceKeys.d.ts.map +1 -0
  50. package/dist/sidecar/deviceKeys.js +142 -0
  51. package/dist/sidecar/deviceKeys.js.map +1 -0
  52. package/dist/sidecar/host.d.ts +157 -23
  53. package/dist/sidecar/host.d.ts.map +1 -1
  54. package/dist/sidecar/host.js +340 -15
  55. package/dist/sidecar/host.js.map +1 -1
  56. package/dist/sidecar/identity.d.ts +41 -17
  57. package/dist/sidecar/identity.d.ts.map +1 -1
  58. package/dist/sidecar/identity.js +10 -21
  59. package/dist/sidecar/identity.js.map +1 -1
  60. package/dist/sidecar/server.d.ts +202 -30
  61. package/dist/sidecar/server.d.ts.map +1 -1
  62. package/dist/sidecar/server.js +722 -109
  63. package/dist/sidecar/server.js.map +1 -1
  64. package/dist/sidecar/testing.d.ts +97 -0
  65. package/dist/sidecar/testing.d.ts.map +1 -0
  66. package/dist/sidecar/testing.js +259 -0
  67. package/dist/sidecar/testing.js.map +1 -0
  68. package/dist/sidecar/wsChannel.d.ts +8 -1
  69. package/dist/sidecar/wsChannel.d.ts.map +1 -1
  70. package/dist/sidecar/wsChannel.js +17 -1
  71. package/dist/sidecar/wsChannel.js.map +1 -1
  72. package/dist/tools/callAgent.d.ts.map +1 -1
  73. package/dist/tools/callAgent.js +33 -13
  74. package/dist/tools/callAgent.js.map +1 -1
  75. package/dist/tools/orchestration.js +1 -1
  76. package/dist/tools/orchestration.js.map +1 -1
  77. package/package.json +2 -2
@@ -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 { 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
@@ -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
- this.opts.attendance?.answer(m.id, m.decision, this.attended);
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 the pairing token. Failures answer ok:false rather than throwing
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
- this.opts.log?.(`denied: unsupported protocol ${m.protocol}`);
928
- 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');
929
1381
  this.countFailedHello();
930
1382
  return;
931
1383
  }
932
- if (!safeEqual(m.token ?? '', this.opts.token)) {
933
- this.opts.log?.('denied: invalid token');
934
- this.ch.send({ t: 'denied', reason: 'invalid token' });
935
- 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;
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 WAITS, started together. The welcome carries the synced directory
1036
- * name this daemon resolved for this connection, and the client has no
1037
- * other way to learn it -- it could previously only deduce it, and got null
1038
- * whenever no workspace was open or two hosts held the same workspace id,
1039
- * leaving a management surface unable to say which registrations were its
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
- * Bounded, and every dirName failure ends the same way as an older daemon:
1049
- * no `dirName` on the welcome. A handshake that never arrives would be a
1050
- * far worse trade than a client that falls back to deducing. With neither
1051
- * a resolver nor a verifier nothing is awaited at all, and the welcome
1052
- * 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. */
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
- /* IDENTITY, decided BEFORE attendance or push can see this connection.
1060
- * Reached only past the pairing-token check above -- the challenge is
1061
- * never sent for a bad pairing token. Whatever the verdict, the
1062
- * connection pairs: unverified only withholds the widened trust a verified
1063
- * id earns. `verifiedUserId` is assigned here and nowhere else, once. The
1064
- * assertion is never logged; only the reason is. The Clerk token itself
1065
- * never reaches this class -- this exchange only ever sees a corenel-api
1066
- * pairing assertion (spec amendment 2026-09-25b). */
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'). The expectation is built here, once the
1179
- * pairing-token hash has settled; a hash that could not be computed is an
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 tokenHash = await tokenHashPending;
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 IDENTITY_TIMEOUT_MS FROM THE CHALLENGE -- covering both the
1209
- * wait for `identity-assert` and the verify call, in one race, so a slow
1210
- * verifier cannot add a second timeout's worth of wait on top of a slow
1211
- * browser. Every failure mode (no assertion, an oversized one, a throw, a
1212
- * rejection, a malformed answer, a timeout) is an UNVERIFIED verdict with a
1213
- * reason -- never a refusal. The assertion is passed to the verifier and
1214
- * nowhere else. */
1215
- 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) {
1216
1658
  let timer;
1217
1659
  try {
1218
1660
  const timed = new Promise((res) => {
1219
- timer = setTimeout(() => res({ ok: false, reason: 'timed out' }), IDENTITY_TIMEOUT_MS);
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, tokenHash));
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 runs = this.opts.runner?.history
1845
- ? await this.opts.runner.history({ clientId: this.clientId }, m.agent, m.limit)
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, { 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
+ });
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