@corenel/harness 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/dist/audit/recorder.d.ts +6 -1
  2. package/dist/audit/recorder.d.ts.map +1 -1
  3. package/dist/audit/recorder.js +9 -2
  4. package/dist/audit/recorder.js.map +1 -1
  5. package/dist/audit/runAudit.d.ts.map +1 -1
  6. package/dist/audit/runAudit.js +2 -0
  7. package/dist/audit/runAudit.js.map +1 -1
  8. package/dist/conversation.d.ts +33 -0
  9. package/dist/conversation.d.ts.map +1 -1
  10. package/dist/conversation.js +44 -1
  11. package/dist/conversation.js.map +1 -1
  12. package/dist/core/compactHistory.d.ts +27 -18
  13. package/dist/core/compactHistory.d.ts.map +1 -1
  14. package/dist/core/compactHistory.js +106 -40
  15. package/dist/core/compactHistory.js.map +1 -1
  16. package/dist/core/fileChanges.d.ts +18 -0
  17. package/dist/core/fileChanges.d.ts.map +1 -0
  18. package/dist/core/fileChanges.js +85 -0
  19. package/dist/core/fileChanges.js.map +1 -0
  20. package/dist/core/foldBlocks.d.ts +43 -0
  21. package/dist/core/foldBlocks.d.ts.map +1 -0
  22. package/dist/core/foldBlocks.js +114 -0
  23. package/dist/core/foldBlocks.js.map +1 -0
  24. package/dist/core/foldTypes.d.ts +25 -0
  25. package/dist/core/foldTypes.d.ts.map +1 -0
  26. package/dist/core/foldTypes.js +2 -0
  27. package/dist/core/foldTypes.js.map +1 -0
  28. package/dist/core/loop.d.ts +29 -5
  29. package/dist/core/loop.d.ts.map +1 -1
  30. package/dist/core/loop.js +166 -70
  31. package/dist/core/loop.js.map +1 -1
  32. package/dist/core/recovery.d.ts +1 -0
  33. package/dist/core/recovery.d.ts.map +1 -1
  34. package/dist/core/recovery.js +12 -0
  35. package/dist/core/recovery.js.map +1 -1
  36. package/dist/core/spawn.d.ts +4 -0
  37. package/dist/core/spawn.d.ts.map +1 -1
  38. package/dist/core/spawn.js +11 -4
  39. package/dist/core/spawn.js.map +1 -1
  40. package/dist/core/summarize.d.ts +9 -3
  41. package/dist/core/summarize.d.ts.map +1 -1
  42. package/dist/core/summarize.js +97 -42
  43. package/dist/core/summarize.js.map +1 -1
  44. package/dist/dag/executeNode.d.ts +5 -0
  45. package/dist/dag/executeNode.d.ts.map +1 -1
  46. package/dist/dag/executeNode.js +5 -1
  47. package/dist/dag/executeNode.js.map +1 -1
  48. package/dist/guardrail/permission-service.d.ts.map +1 -1
  49. package/dist/guardrail/permission-service.js +11 -2
  50. package/dist/guardrail/permission-service.js.map +1 -1
  51. package/dist/guardrail/serialize.d.ts.map +1 -1
  52. package/dist/guardrail/serialize.js +4 -0
  53. package/dist/guardrail/serialize.js.map +1 -1
  54. package/dist/guardrail/validate.d.ts.map +1 -1
  55. package/dist/guardrail/validate.js +3 -1
  56. package/dist/guardrail/validate.js.map +1 -1
  57. package/dist/orchestrator/record.d.ts +94 -5
  58. package/dist/orchestrator/record.d.ts.map +1 -1
  59. package/dist/orchestrator/record.js +186 -5
  60. package/dist/orchestrator/record.js.map +1 -1
  61. package/dist/orchestrator/sidecarTools.d.ts +5 -0
  62. package/dist/orchestrator/sidecarTools.d.ts.map +1 -1
  63. package/dist/orchestrator/sidecarTools.js +53 -15
  64. package/dist/orchestrator/sidecarTools.js.map +1 -1
  65. package/dist/prompts/library/defaults.generated.js +2 -2
  66. package/dist/prompts/library/defaults.generated.js.map +1 -1
  67. package/dist/sessions/directivesStore.d.ts +9 -0
  68. package/dist/sessions/directivesStore.d.ts.map +1 -0
  69. package/dist/sessions/directivesStore.js +54 -0
  70. package/dist/sessions/directivesStore.js.map +1 -0
  71. package/dist/sessions/fileSessionStore.d.ts +2 -2
  72. package/dist/sessions/fileSessionStore.d.ts.map +1 -1
  73. package/dist/sessions/fileSessionStore.js +19 -8
  74. package/dist/sessions/fileSessionStore.js.map +1 -1
  75. package/dist/sessions/foldStorage.d.ts +15 -0
  76. package/dist/sessions/foldStorage.d.ts.map +1 -0
  77. package/dist/sessions/foldStorage.js +121 -0
  78. package/dist/sessions/foldStorage.js.map +1 -0
  79. package/dist/sessions/jsonl.d.ts +6 -0
  80. package/dist/sessions/jsonl.d.ts.map +1 -1
  81. package/dist/sessions/jsonl.js +15 -1
  82. package/dist/sessions/jsonl.js.map +1 -1
  83. package/dist/sidecar/deviceKeys.d.ts +75 -0
  84. package/dist/sidecar/deviceKeys.d.ts.map +1 -0
  85. package/dist/sidecar/deviceKeys.js +142 -0
  86. package/dist/sidecar/deviceKeys.js.map +1 -0
  87. package/dist/sidecar/host.d.ts +144 -24
  88. package/dist/sidecar/host.d.ts.map +1 -1
  89. package/dist/sidecar/host.js +306 -16
  90. package/dist/sidecar/host.js.map +1 -1
  91. package/dist/sidecar/identity.d.ts +41 -17
  92. package/dist/sidecar/identity.d.ts.map +1 -1
  93. package/dist/sidecar/identity.js +10 -21
  94. package/dist/sidecar/identity.js.map +1 -1
  95. package/dist/sidecar/server.d.ts +197 -28
  96. package/dist/sidecar/server.d.ts.map +1 -1
  97. package/dist/sidecar/server.js +701 -110
  98. package/dist/sidecar/server.js.map +1 -1
  99. package/dist/sidecar/testing.d.ts +97 -0
  100. package/dist/sidecar/testing.d.ts.map +1 -0
  101. package/dist/sidecar/testing.js +259 -0
  102. package/dist/sidecar/testing.js.map +1 -0
  103. package/dist/sidecar/wsChannel.d.ts +8 -1
  104. package/dist/sidecar/wsChannel.d.ts.map +1 -1
  105. package/dist/sidecar/wsChannel.js +17 -1
  106. package/dist/sidecar/wsChannel.js.map +1 -1
  107. package/dist/tools/callAgent.d.ts.map +1 -1
  108. package/dist/tools/callAgent.js +33 -13
  109. package/dist/tools/callAgent.js.map +1 -1
  110. package/dist/tools/foldTools.d.ts +5 -0
  111. package/dist/tools/foldTools.d.ts.map +1 -0
  112. package/dist/tools/foldTools.js +123 -0
  113. package/dist/tools/foldTools.js.map +1 -0
  114. package/dist/tools/orchestration.js +1 -1
  115. package/dist/tools/orchestration.js.map +1 -1
  116. package/dist/tools/skillTool.d.ts.map +1 -1
  117. package/dist/tools/skillTool.js +7 -2
  118. package/dist/tools/skillTool.js.map +1 -1
  119. package/package.json +2 -2
@@ -1,4 +1,5 @@
1
- import { SIDECAR_PROTOCOL, HOST_METHODS, errMessage, RUN_TASK_FEATURE, RUN_PLAN_FEATURE, CREW_DEPLOY_FEATURE, } from '@corenel/protocol';
1
+ import { asEcPublicJwk, jwkThumbprint, verifySignature, randomToken, serverProofPayload, clientProofPayload, parseEnrollmentCode, bytesToB64url, codeProof, codeHash, } from './deviceKeys.js';
2
+ import { SIDECAR_PROTOCOL, HOST_METHODS, errMessage, RUN_TASK_FEATURE, RUN_PLAN_FEATURE, CREW_DEPLOY_FEATURE, DEVICES_FEATURE, isClerkOrgId, } from '@corenel/protocol';
2
3
  /** Decode a base64 chunk to bytes (atob is available in the browser and Node 18+). */
3
4
  function b64ToBytes(b64) {
4
5
  const bin = atob(b64);
@@ -7,6 +8,34 @@ function b64ToBytes(b64) {
7
8
  out[i] = bin.charCodeAt(i);
8
9
  return out;
9
10
  }
11
+ /** Prefix of the error a connection rejects with when the daemon's key is not
12
+ * the one this client pinned (or it cannot prove it holds it). A caller shows
13
+ * this state as its own, never as a generic connection failure, and never
14
+ * retries it. */
15
+ export const SIDECAR_KEY_MISMATCH = 'sidecar key mismatch';
16
+ /** The mismatch error, recognisable by `isSidecarKeyMismatch`. */
17
+ export function sidecarKeyMismatchError() {
18
+ return new Error(`${SIDECAR_KEY_MISMATCH}: this is not the sidecar you paired with`);
19
+ }
20
+ /** Prefix of the error `connect()` rejects with when this client has nothing
21
+ * to pair with (no device key, no code). */
22
+ export const SIDECAR_NOT_PAIRED = 'sidecar not paired';
23
+ export function isSidecarKeyMismatch(e) {
24
+ const msg = e instanceof Error ? e.message : String(e);
25
+ return msg.startsWith(SIDECAR_KEY_MISMATCH);
26
+ }
27
+ /* NOT ADMITTED, AS OPPOSED TO A DEAD DEVICE. A daemon sends identity-challenge
28
+ * only AFTER the device (or enrollment) proof passed, so a denial that follows
29
+ * a challenge in the same handshake is about the ACCOUNT (not verified, not
30
+ * the owner, not a member, another user's device), never the device. This
31
+ * needs no new signal from the daemon: the order of its own messages says it.
32
+ * Deliberately a different prefix from 'sidecar denied connection', so no
33
+ * caller that forgets a device on a denial can mistake one for the other. */
34
+ export const SIDECAR_NOT_ADMITTED = 'sidecar did not admit this account';
35
+ export function isSidecarAccountRefusal(e) {
36
+ const msg = e instanceof Error ? e.message : String(e);
37
+ return msg.startsWith(SIDECAR_NOT_ADMITTED);
38
+ }
10
39
  export class SidecarHost {
11
40
  constructor(opts) {
12
41
  Object.defineProperty(this, "ch", {
@@ -389,6 +418,98 @@ export class SidecarHost {
389
418
  writable: true,
390
419
  value: {}
391
420
  });
421
+ /** This client's device on this daemon: given, or just recorded. */
422
+ Object.defineProperty(this, "device", {
423
+ enumerable: true,
424
+ configurable: true,
425
+ writable: true,
426
+ value: void 0
427
+ });
428
+ /** How the CURRENT hello authenticates, and what its proofs are bound to. */
429
+ Object.defineProperty(this, "mode", {
430
+ enumerable: true,
431
+ configurable: true,
432
+ writable: true,
433
+ value: 'none'
434
+ });
435
+ Object.defineProperty(this, "clientNonce", {
436
+ enumerable: true,
437
+ configurable: true,
438
+ writable: true,
439
+ value: ''
440
+ });
441
+ /** The sidecar key this connection is pinned to. */
442
+ Object.defineProperty(this, "pinnedJkt", {
443
+ enumerable: true,
444
+ configurable: true,
445
+ writable: true,
446
+ value: null
447
+ });
448
+ /** The device id (device hello) or the offered key's jkt (enroll) that both
449
+ * proofs name. */
450
+ Object.defineProperty(this, "proofSubject", {
451
+ enumerable: true,
452
+ configurable: true,
453
+ writable: true,
454
+ value: ''
455
+ });
456
+ /** Whether a proof exchange is expected on this connection at all. */
457
+ Object.defineProperty(this, "expectProof", {
458
+ enumerable: true,
459
+ configurable: true,
460
+ writable: true,
461
+ value: false
462
+ });
463
+ /** The sidecar key this connection VERIFIED, once it has. */
464
+ Object.defineProperty(this, "verifiedSidecarJkt", {
465
+ enumerable: true,
466
+ configurable: true,
467
+ writable: true,
468
+ value: null
469
+ });
470
+ /** The sidecar id the daemon signed into its server-proof. An identity
471
+ * challenge is answered only for this daemon, and only once it proved the
472
+ * pinned key: an assertion is never fetched for a peer that has not. */
473
+ Object.defineProperty(this, "provenSidecarId", {
474
+ enumerable: true,
475
+ configurable: true,
476
+ writable: true,
477
+ value: null
478
+ });
479
+ /** An identity challenge arrived from the proven daemon during this
480
+ * handshake: the device credential was accepted (SIDECAR_NOT_ADMITTED). */
481
+ Object.defineProperty(this, "challengedInHandshake", {
482
+ enumerable: true,
483
+ configurable: true,
484
+ writable: true,
485
+ value: false
486
+ });
487
+ /** The credential failed before sending (a malformed code, no key). */
488
+ Object.defineProperty(this, "credentialError", {
489
+ enumerable: true,
490
+ configurable: true,
491
+ writable: true,
492
+ value: null
493
+ });
494
+ /** The pairing code's random part, for `codeProof` (enroll only). */
495
+ Object.defineProperty(this, "codeSecret", {
496
+ enumerable: true,
497
+ configurable: true,
498
+ writable: true,
499
+ value: null
500
+ });
501
+ Object.defineProperty(this, "devicesSeq", {
502
+ enumerable: true,
503
+ configurable: true,
504
+ writable: true,
505
+ value: 0
506
+ });
507
+ Object.defineProperty(this, "devicesPending", {
508
+ enumerable: true,
509
+ configurable: true,
510
+ writable: true,
511
+ value: new Map()
512
+ });
392
513
  Object.defineProperty(this, "imported", {
393
514
  enumerable: true,
394
515
  configurable: true,
@@ -403,10 +524,86 @@ export class SidecarHost {
403
524
  });
404
525
  this.opts = opts;
405
526
  this.ch = opts.channel;
527
+ this.device = opts.device;
406
528
  this.ch.onMessage((m) => this.onMessage(m));
407
529
  }
530
+ /** The hello's credential: the device if this client has one, else the
531
+ * enrollment code. Exactly one is sent, and no secret. Sets up what the
532
+ * proof exchange will be bound to. Without either (or without a key) there
533
+ * is nothing to pair with and `credentialError` is set. */
534
+ credentialFields() {
535
+ const key = this.opts.deviceKey;
536
+ this.clientNonce = randomToken();
537
+ if (this.device && key) {
538
+ this.mode = 'device';
539
+ this.pinnedJkt = this.device.sidecarJkt;
540
+ this.proofSubject = this.device.id;
541
+ this.expectProof = true;
542
+ return { clientNonce: this.clientNonce, device: { id: this.device.id } };
543
+ }
544
+ if (this.opts.enroll) {
545
+ const parsed = parseEnrollmentCode(this.opts.enroll);
546
+ if (!parsed || !key) {
547
+ this.credentialError = new Error('that is not a pairing code this app can use');
548
+ return {};
549
+ }
550
+ this.mode = 'enroll';
551
+ this.pinnedJkt = parsed.sidecarJkt;
552
+ this.codeSecret = parsed.secret;
553
+ this.proofSubject = key.jkt;
554
+ this.expectProof = true;
555
+ // The public key only: the code is proven later, never sent.
556
+ return { clientNonce: this.clientNonce, enroll: { publicKey: key.publicJwk } };
557
+ }
558
+ this.mode = 'none';
559
+ this.credentialError = new Error(`${SIDECAR_NOT_PAIRED}: pair it with a code from the sidecar`);
560
+ return {};
561
+ }
562
+ /** This client's device on this daemon (given, or just recorded), or
563
+ * undefined when it is still pairing by code. */
564
+ deviceCredential() { return this.device; }
565
+ /** The sidecar key this connection verified, or null before it has. */
566
+ sidecarKeyJkt() { return this.verifiedSidecarJkt; }
567
+ /** The daemon's `server-proof`: check it is the PINNED key and that it
568
+ * signed THIS exchange, then prove this client's key. On any
569
+ * failure nothing more is sent: the connection is dropped and `connect()`
570
+ * rejects with the key-mismatch error. */
571
+ async answerServerProof(m) {
572
+ const key = this.opts.deviceKey;
573
+ if (!this.expectProof || !key || this.verifiedSidecarJkt !== null)
574
+ return;
575
+ this.expectProof = false; // one exchange per connection
576
+ const sidecarKey = asEcPublicJwk(m.publicKey);
577
+ const sidecarJkt = sidecarKey ? await jwkThumbprint(sidecarKey) : null;
578
+ const ok = !!sidecarKey && !!sidecarJkt
579
+ && this.pinnedJkt !== null && sidecarJkt === this.pinnedJkt
580
+ && typeof m.serverNonce === 'string' && typeof m.sidecarId === 'string'
581
+ && await verifySignature(sidecarKey, serverProofPayload({
582
+ clientNonce: this.clientNonce, serverNonce: m.serverNonce, sidecarId: m.sidecarId, sidecarJkt, subject: this.proofSubject,
583
+ }), m.sig);
584
+ if (!ok || !sidecarJkt) {
585
+ this.failKeyMismatch();
586
+ return;
587
+ }
588
+ this.verifiedSidecarJkt = sidecarJkt;
589
+ this.provenSidecarId = m.sidecarId;
590
+ const payload = clientProofPayload({
591
+ serverNonce: m.serverNonce, clientNonce: this.clientNonce, sidecarId: m.sidecarId, sidecarJkt, subject: this.proofSubject,
592
+ });
593
+ const sig = bytesToB64url(await key.sign(payload));
594
+ // Knowledge of the code, bound to this exchange, only now that the daemon
595
+ // proved the key the code named.
596
+ const proofOfCode = this.mode === 'enroll' && this.codeSecret ? await codeProof(await codeHash(this.codeSecret), payload) : undefined;
597
+ this.ch.send(proofOfCode ? { t: 'client-proof', sig, codeProof: proofOfCode } : { t: 'client-proof', sig });
598
+ }
599
+ failKeyMismatch() {
600
+ const pending = this.welcome;
601
+ this.welcome = null;
602
+ pending?.reject(sidecarKeyMismatchError());
603
+ this.disconnect(SIDECAR_KEY_MISMATCH);
604
+ }
408
605
  /** Authenticate, then import the sidecar's tools. Resolves with their names,
409
- * or rejects if the sidecar denies the token / protocol.
606
+ * or rejects if the sidecar refuses the credential or protocol, or proves a key other than the pinned one.
410
607
  *
411
608
  * `timeoutMs`, when given, rejects the connection if no `welcome`/`denied`
412
609
  * ever arrives -- a handshake that silently never completes (a peer that
@@ -430,29 +627,42 @@ export class SidecarHost {
430
627
  pending?.reject(new Error(`sidecar handshake timed out after ${timeoutMs}ms waiting for welcome`));
431
628
  }, timeoutMs);
432
629
  }
630
+ const credential = this.credentialFields();
631
+ if (this.credentialError) {
632
+ const pending = this.welcome;
633
+ this.welcome = null;
634
+ pending?.reject(this.credentialError);
635
+ return;
636
+ }
433
637
  this.ch.send({
434
- t: 'hello', protocol: SIDECAR_PROTOCOL, token: this.opts.token,
638
+ t: 'hello', protocol: SIDECAR_PROTOCOL, ...credential,
435
639
  ...(this.opts.stateDir ? { stateDir: this.opts.stateDir } : {}),
436
640
  ...(this.opts.clientId ? { clientId: this.opts.clientId } : {}),
437
641
  ...(this.opts.clientName ? { clientName: this.opts.clientName } : {}),
438
642
  ...(this.opts.accounts?.length ? { accounts: this.opts.accounts } : {}),
439
- ...(this.opts.wantsIdentity ? { wantsIdentity: true } : {}),
440
643
  // What THIS client understands, so a daemon can tell an older app
441
644
  // (whose agents sync but never fire until deployed) from this one.
442
- capabilities: [CREW_DEPLOY_FEATURE],
645
+ // DEVICES_FEATURE only when the caller has somewhere to KEEP an issued
646
+ // device credential: a daemon issues one only to a client that says
647
+ // so, since one thrown away is a ghost device on the daemon for ever.
648
+ capabilities: [CREW_DEPLOY_FEATURE, ...(this.opts.onDeviceIssued && this.opts.deviceKey ? [DEVICES_FEATURE] : [])],
443
649
  });
444
650
  });
445
651
  }
446
652
  /** Answers the daemon's `identity-challenge`. `onIdentityChallenge` resolving
447
653
  * a string sends it as the assertion; resolving `null`, having no handler
448
654
  * at all, or THROWING all send `identity-assert` with no assertion -- every
449
- * one of those is a considered "no identity to offer", never a reason to
450
- * fail the handshake. The Clerk token itself never reaches this class: only
451
- * whatever assertion the callback hands back does. */
655
+ * one of those is a considered "no identity to offer", which the daemon
656
+ * refuses at once rather than waiting out its bound. The Clerk token itself
657
+ * never reaches this class: only whatever assertion the callback hands back
658
+ * does. A malformed `orgId` is not forwarded. */
452
659
  async answerIdentityChallenge(m) {
453
660
  let assertion = null;
454
661
  try {
455
- assertion = (await this.opts.onIdentityChallenge?.({ nonce: m.nonce, sidecarId: m.sidecarId })) ?? null;
662
+ const proven = this.verifiedSidecarJkt !== null && this.provenSidecarId !== null && m.sidecarId === this.provenSidecarId;
663
+ const jkt = proven ? this.opts.deviceKey?.jkt : undefined;
664
+ const orgId = isClerkOrgId(m.orgId) ? m.orgId : undefined;
665
+ assertion = jkt ? (await this.opts.onIdentityChallenge?.({ nonce: m.nonce, sidecarId: m.sidecarId, jkt, ...(orgId ? { orgId } : {}) })) ?? null : null;
456
666
  }
457
667
  catch {
458
668
  assertion = null;
@@ -572,6 +782,9 @@ export class SidecarHost {
572
782
  for (const p of this.pushPending.values())
573
783
  p.reject(new Error(reason));
574
784
  this.pushPending.clear();
785
+ for (const p of this.devicesPending.values())
786
+ p.reject(new Error(reason));
787
+ this.devicesPending.clear();
575
788
  /* The rejections above already drop each login's waiter via the wrapper
576
789
  * mcpLogin installs. Cleared here too so that stays true if sendMcpAdmin
577
790
  * is ever the one carrying a login -- a stale waiter would fire a consent
@@ -583,7 +796,18 @@ export class SidecarHost {
583
796
  }
584
797
  onMessage(m) {
585
798
  switch (m.t) {
799
+ case 'server-proof': {
800
+ void this.answerServerProof(m);
801
+ return;
802
+ }
586
803
  case 'welcome': {
804
+ /* Welcomed ONLY after the daemon proved the pinned key. A welcome
805
+ * before that is an impostor (or a daemon too old for keys) skipping
806
+ * the proof: refuse it. */
807
+ if (this.verifiedSidecarJkt === null) {
808
+ this.failKeyMismatch();
809
+ return;
810
+ }
587
811
  this.features = m.features ?? [];
588
812
  // Runtime-validated: an untrusted/older daemon's `vapidPublicKey` is
589
813
  // just a JSON-parsed `string`, and a malformed one must never be
@@ -599,6 +823,21 @@ export class SidecarHost {
599
823
  this.identity = m.identity && typeof m.identity === 'object' && typeof m.identity.verified === 'boolean'
600
824
  ? m.identity : undefined;
601
825
  this.info = m.info ?? {};
826
+ /* A recorded device: accepted only as the answer to an enrollment or
827
+ * an upgrade (a device hello is never re-issued), only after this
828
+ * connection verified the daemon's key -- that key is what gets
829
+ * pinned -- and only in the shape the daemon mints. Anything else is
830
+ * ignored, so this client keeps pairing the way it just did. */
831
+ const issued = m.device;
832
+ const issuedId = typeof issued === 'object' && issued !== null ? issued.id : undefined;
833
+ if (this.mode !== 'device' && this.verifiedSidecarJkt !== null
834
+ && typeof issuedId === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(issuedId)) {
835
+ this.device = { id: issuedId, sidecarJkt: this.verifiedSidecarJkt };
836
+ try {
837
+ this.opts.onDeviceIssued?.({ ...this.device });
838
+ }
839
+ catch { /* the connection is up either way */ }
840
+ }
602
841
  for (const decl of m.tools) {
603
842
  const tool = { ...decl, run: (args) => this.invoke(decl.name, args) };
604
843
  this.opts.registerTool(tool);
@@ -609,11 +848,18 @@ export class SidecarHost {
609
848
  return;
610
849
  }
611
850
  case 'denied': {
612
- this.welcome?.reject(new Error(`sidecar denied connection: ${m.reason}`));
851
+ this.welcome?.reject(new Error(this.challengedInHandshake
852
+ ? `${SIDECAR_NOT_ADMITTED}: ${m.reason}`
853
+ : `sidecar denied connection: ${m.reason}`));
613
854
  this.welcome = null;
614
855
  return;
615
856
  }
616
857
  case 'identity-challenge': {
858
+ // Before the welcome: the device proof has passed (see
859
+ // SIDECAR_NOT_ADMITTED). Only a daemon that proved the pinned key
860
+ // counts; anything earlier is ignored here as it is below.
861
+ if (this.welcome && this.verifiedSidecarJkt !== null)
862
+ this.challengedInHandshake = true;
617
863
  void this.answerIdentityChallenge(m);
618
864
  return;
619
865
  }
@@ -1044,6 +1290,16 @@ export class SidecarHost {
1044
1290
  p.resolve(m.ok ? { ok: true, room: m.room } : { ok: false, problems: m.problems });
1045
1291
  return;
1046
1292
  }
1293
+ case 'devices-list-result':
1294
+ case 'devices-revoke-result': {
1295
+ // One map for both: each carries the COMPLETE list afterwards.
1296
+ const p = this.devicesPending.get(m.id);
1297
+ if (!p)
1298
+ return;
1299
+ this.devicesPending.delete(m.id);
1300
+ p.resolve(m.ok ? { ok: true, devices: m.devices } : { ok: false, error: m.error });
1301
+ return;
1302
+ }
1047
1303
  case 'hosts-list-result':
1048
1304
  case 'hosts-forget-result': {
1049
1305
  // One pending map for both, for the same reason as the pause results
@@ -1363,11 +1619,15 @@ export class SidecarHost {
1363
1619
  * An empty list means no record, which a caller renders rather than treats
1364
1620
  * as an error — a daemon that keeps none is a normal daemon.
1365
1621
  */
1366
- runHistory(agent, limit) {
1622
+ runHistory(agent, limit, crew) {
1367
1623
  return this.sessionCall((id) => ({
1368
- t: 'run-history', id, agent, ...(limit === undefined ? {} : { limit }),
1624
+ t: 'run-history', id, agent, ...(limit === undefined ? {} : { limit }), ...(crew ? { crew } : {}),
1369
1625
  }));
1370
1626
  }
1627
+ /** True when `runHistory` can be asked about one crew (global vs a
1628
+ * workspace). An older daemon answers every question from the global crew,
1629
+ * so a caller must know before it trusts a workspace answer. */
1630
+ supportsRunHistoryScope() { return this.features.includes('run-history-scope'); }
1371
1631
  /**
1372
1632
  * One run's kept events, as the daemon stored them.
1373
1633
  *
@@ -1654,6 +1914,13 @@ export class SidecarHost {
1654
1914
  configWrite(path, content) {
1655
1915
  return this.configCall({ t: 'config-write', id: ++this.configSeq, path, content }).then(() => { });
1656
1916
  }
1917
+ /** True when this daemon can append to a config file without a rewrite. */
1918
+ supportsConfigAppend() { return this.features.includes('config-append'); }
1919
+ /** Append to one config file (O_APPEND daemon-side). Only call when
1920
+ * supportsConfigAppend(); an older daemon would refuse the message. */
1921
+ configAppend(path, content) {
1922
+ return this.configCall({ t: 'config-append', id: ++this.configSeq, path, content }).then(() => { });
1923
+ }
1657
1924
  /** Delete one config file or subtree (recursive, idempotent). */
1658
1925
  configDelete(path) {
1659
1926
  return this.configCall({ t: 'config-delete', id: ++this.configSeq, path }).then(() => { });
@@ -1683,6 +1950,28 @@ export class SidecarHost {
1683
1950
  * directories. Feature-detected so a client offers a destructive control only
1684
1951
  * where the daemon would actually honour it. */
1685
1952
  supportsHostsAdmin() { return this.features.includes('hosts-admin'); }
1953
+ /** Whether the connected daemon keeps paired devices (enrollment codes,
1954
+ * device credentials, `devices-list`/`devices-revoke`). */
1955
+ supportsDevices() { return this.features.includes(DEVICES_FEATURE); }
1956
+ /** Every device paired with this daemon, with `self` marking this one. */
1957
+ listDevices() {
1958
+ const id = ++this.devicesSeq;
1959
+ return new Promise((resolve, reject) => {
1960
+ this.devicesPending.set(id, { resolve, reject });
1961
+ this.ch.send({ t: 'devices-list', id });
1962
+ });
1963
+ }
1964
+ /** Revoke one device: it can never connect again, and its live connections
1965
+ * close. `ok: false` is the daemon's considered refusal (this client's own
1966
+ * device, an unknown one), so it resolves; only a dropped connection
1967
+ * rejects. Resolves with the COMPLETE list afterwards. */
1968
+ revokeDevice(deviceId) {
1969
+ const id = ++this.devicesSeq;
1970
+ return new Promise((resolve, reject) => {
1971
+ this.devicesPending.set(id, { resolve, reject });
1972
+ this.ch.send({ t: 'devices-revoke', id, deviceId });
1973
+ });
1974
+ }
1686
1975
  /** Whether the connected daemon serves an MCP surface at all. `mcp-list`
1687
1976
  * answers an empty list either way (see sidecar.ts), so this is advisory --
1688
1977
  * a caller uses it to decide whether to bother asking, not as a gate. */
@@ -1890,13 +2179,14 @@ export class SidecarHost {
1890
2179
  }
1891
2180
  /** True when the daemon answers run-plan-get. */
1892
2181
  supportsRunPlan() { return this.features.includes(RUN_PLAN_FEATURE); }
1893
- /** One run's plan from its record. `ok: false` is the daemon's answer; only
1894
- * a dropped connection rejects. Ask only when supportsRunPlan() is true. */
1895
- runPlanGet(runId) {
2182
+ /** One run's plan from its record; `output: true` adds what the run
2183
+ * produced. `ok: false` is the daemon's answer; only a dropped connection
2184
+ * rejects. Ask only when supportsRunPlan() is true. */
2185
+ runPlanGet(runId, opts = {}) {
1896
2186
  const id = ++this.runPlanSeq;
1897
2187
  return new Promise((resolve, reject) => {
1898
2188
  this.runPlanPending.set(id, { resolve, reject });
1899
- this.ch.send({ t: 'run-plan-get', id, runId });
2189
+ this.ch.send({ t: 'run-plan-get', id, runId, ...(opts.output ? { output: true } : {}) });
1900
2190
  });
1901
2191
  }
1902
2192
  /** Ask the daemon to open a room. `ok: false` here is the daemon's CONSIDERED