@corenel/harness 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/fileChanges.d.ts +18 -0
- package/dist/core/fileChanges.d.ts.map +1 -0
- package/dist/core/fileChanges.js +85 -0
- package/dist/core/fileChanges.js.map +1 -0
- package/dist/core/loop.d.ts +10 -0
- package/dist/core/loop.d.ts.map +1 -1
- package/dist/core/loop.js +32 -2
- package/dist/core/loop.js.map +1 -1
- package/dist/core/spawn.d.ts +4 -0
- package/dist/core/spawn.d.ts.map +1 -1
- package/dist/core/spawn.js +11 -4
- package/dist/core/spawn.js.map +1 -1
- package/dist/dag/executeNode.d.ts +5 -0
- package/dist/dag/executeNode.d.ts.map +1 -1
- package/dist/dag/executeNode.js +5 -1
- package/dist/dag/executeNode.js.map +1 -1
- package/dist/guardrail/permission-service.d.ts +4 -0
- package/dist/guardrail/permission-service.d.ts.map +1 -1
- package/dist/guardrail/permission-service.js +10 -3
- package/dist/guardrail/permission-service.js.map +1 -1
- package/dist/guardrail/serialize.d.ts.map +1 -1
- package/dist/guardrail/serialize.js +4 -0
- package/dist/guardrail/serialize.js.map +1 -1
- package/dist/guardrail/validate.d.ts.map +1 -1
- package/dist/guardrail/validate.js +3 -1
- package/dist/guardrail/validate.js.map +1 -1
- package/dist/orchestrator/agentSearch.d.ts.map +1 -1
- package/dist/orchestrator/agentSearch.js +10 -0
- package/dist/orchestrator/agentSearch.js.map +1 -1
- package/dist/orchestrator/planDoc.d.ts +26 -0
- package/dist/orchestrator/planDoc.d.ts.map +1 -1
- package/dist/orchestrator/planDoc.js +132 -10
- package/dist/orchestrator/planDoc.js.map +1 -1
- package/dist/orchestrator/record.d.ts +100 -4
- package/dist/orchestrator/record.d.ts.map +1 -1
- package/dist/orchestrator/record.js +238 -4
- package/dist/orchestrator/record.js.map +1 -1
- package/dist/orchestrator/sidecarTools.d.ts +5 -0
- package/dist/orchestrator/sidecarTools.d.ts.map +1 -1
- package/dist/orchestrator/sidecarTools.js +53 -15
- package/dist/orchestrator/sidecarTools.js.map +1 -1
- package/dist/prompts/init.d.ts +12 -0
- package/dist/prompts/init.d.ts.map +1 -0
- package/dist/prompts/init.js +48 -0
- package/dist/prompts/init.js.map +1 -0
- package/dist/prompts/library/defaults.generated.js +2 -2
- package/dist/prompts/library/defaults.generated.js.map +1 -1
- package/dist/sidecar/deviceKeys.d.ts +75 -0
- package/dist/sidecar/deviceKeys.d.ts.map +1 -0
- package/dist/sidecar/deviceKeys.js +142 -0
- package/dist/sidecar/deviceKeys.js.map +1 -0
- package/dist/sidecar/host.d.ts +157 -23
- package/dist/sidecar/host.d.ts.map +1 -1
- package/dist/sidecar/host.js +340 -15
- package/dist/sidecar/host.js.map +1 -1
- package/dist/sidecar/identity.d.ts +41 -17
- package/dist/sidecar/identity.d.ts.map +1 -1
- package/dist/sidecar/identity.js +10 -21
- package/dist/sidecar/identity.js.map +1 -1
- package/dist/sidecar/server.d.ts +202 -30
- package/dist/sidecar/server.d.ts.map +1 -1
- package/dist/sidecar/server.js +722 -109
- package/dist/sidecar/server.js.map +1 -1
- package/dist/sidecar/testing.d.ts +97 -0
- package/dist/sidecar/testing.d.ts.map +1 -0
- package/dist/sidecar/testing.js +259 -0
- package/dist/sidecar/testing.js.map +1 -0
- package/dist/sidecar/wsChannel.d.ts +8 -1
- package/dist/sidecar/wsChannel.d.ts.map +1 -1
- package/dist/sidecar/wsChannel.js +17 -1
- package/dist/sidecar/wsChannel.js.map +1 -1
- package/dist/tools/callAgent.d.ts.map +1 -1
- package/dist/tools/callAgent.js +33 -13
- package/dist/tools/callAgent.js.map +1 -1
- package/dist/tools/orchestration.js +1 -1
- package/dist/tools/orchestration.js.map +1 -1
- package/package.json +2 -2
package/dist/sidecar/host.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
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", {
|
|
@@ -216,6 +245,18 @@ export class SidecarHost {
|
|
|
216
245
|
writable: true,
|
|
217
246
|
value: new Map()
|
|
218
247
|
});
|
|
248
|
+
Object.defineProperty(this, "runPlanSeq", {
|
|
249
|
+
enumerable: true,
|
|
250
|
+
configurable: true,
|
|
251
|
+
writable: true,
|
|
252
|
+
value: 0
|
|
253
|
+
});
|
|
254
|
+
Object.defineProperty(this, "runPlanPending", {
|
|
255
|
+
enumerable: true,
|
|
256
|
+
configurable: true,
|
|
257
|
+
writable: true,
|
|
258
|
+
value: new Map()
|
|
259
|
+
});
|
|
219
260
|
Object.defineProperty(this, "hostsSeq", {
|
|
220
261
|
enumerable: true,
|
|
221
262
|
configurable: true,
|
|
@@ -377,6 +418,98 @@ export class SidecarHost {
|
|
|
377
418
|
writable: true,
|
|
378
419
|
value: {}
|
|
379
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
|
+
});
|
|
380
513
|
Object.defineProperty(this, "imported", {
|
|
381
514
|
enumerable: true,
|
|
382
515
|
configurable: true,
|
|
@@ -391,10 +524,86 @@ export class SidecarHost {
|
|
|
391
524
|
});
|
|
392
525
|
this.opts = opts;
|
|
393
526
|
this.ch = opts.channel;
|
|
527
|
+
this.device = opts.device;
|
|
394
528
|
this.ch.onMessage((m) => this.onMessage(m));
|
|
395
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
|
+
}
|
|
396
605
|
/** Authenticate, then import the sidecar's tools. Resolves with their names,
|
|
397
|
-
* or rejects if the sidecar
|
|
606
|
+
* or rejects if the sidecar refuses the credential or protocol, or proves a key other than the pinned one.
|
|
398
607
|
*
|
|
399
608
|
* `timeoutMs`, when given, rejects the connection if no `welcome`/`denied`
|
|
400
609
|
* ever arrives -- a handshake that silently never completes (a peer that
|
|
@@ -418,29 +627,42 @@ export class SidecarHost {
|
|
|
418
627
|
pending?.reject(new Error(`sidecar handshake timed out after ${timeoutMs}ms waiting for welcome`));
|
|
419
628
|
}, timeoutMs);
|
|
420
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
|
+
}
|
|
421
637
|
this.ch.send({
|
|
422
|
-
t: 'hello', protocol: SIDECAR_PROTOCOL,
|
|
638
|
+
t: 'hello', protocol: SIDECAR_PROTOCOL, ...credential,
|
|
423
639
|
...(this.opts.stateDir ? { stateDir: this.opts.stateDir } : {}),
|
|
424
640
|
...(this.opts.clientId ? { clientId: this.opts.clientId } : {}),
|
|
425
641
|
...(this.opts.clientName ? { clientName: this.opts.clientName } : {}),
|
|
426
642
|
...(this.opts.accounts?.length ? { accounts: this.opts.accounts } : {}),
|
|
427
|
-
...(this.opts.wantsIdentity ? { wantsIdentity: true } : {}),
|
|
428
643
|
// What THIS client understands, so a daemon can tell an older app
|
|
429
644
|
// (whose agents sync but never fire until deployed) from this one.
|
|
430
|
-
|
|
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] : [])],
|
|
431
649
|
});
|
|
432
650
|
});
|
|
433
651
|
}
|
|
434
652
|
/** Answers the daemon's `identity-challenge`. `onIdentityChallenge` resolving
|
|
435
653
|
* a string sends it as the assertion; resolving `null`, having no handler
|
|
436
654
|
* at all, or THROWING all send `identity-assert` with no assertion -- every
|
|
437
|
-
* one of those is a considered "no identity to offer",
|
|
438
|
-
*
|
|
439
|
-
* whatever assertion the callback hands back
|
|
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. */
|
|
440
659
|
async answerIdentityChallenge(m) {
|
|
441
660
|
let assertion = null;
|
|
442
661
|
try {
|
|
443
|
-
|
|
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;
|
|
444
666
|
}
|
|
445
667
|
catch {
|
|
446
668
|
assertion = null;
|
|
@@ -517,6 +739,9 @@ export class SidecarHost {
|
|
|
517
739
|
for (const p of this.releasePending.values())
|
|
518
740
|
p.reject(new Error(reason));
|
|
519
741
|
this.releasePending.clear();
|
|
742
|
+
for (const p of this.runPlanPending.values())
|
|
743
|
+
p.reject(new Error(reason));
|
|
744
|
+
this.runPlanPending.clear();
|
|
520
745
|
for (const p of this.hostsPending.values())
|
|
521
746
|
p.reject(new Error(reason));
|
|
522
747
|
this.hostsPending.clear();
|
|
@@ -557,6 +782,9 @@ export class SidecarHost {
|
|
|
557
782
|
for (const p of this.pushPending.values())
|
|
558
783
|
p.reject(new Error(reason));
|
|
559
784
|
this.pushPending.clear();
|
|
785
|
+
for (const p of this.devicesPending.values())
|
|
786
|
+
p.reject(new Error(reason));
|
|
787
|
+
this.devicesPending.clear();
|
|
560
788
|
/* The rejections above already drop each login's waiter via the wrapper
|
|
561
789
|
* mcpLogin installs. Cleared here too so that stays true if sendMcpAdmin
|
|
562
790
|
* is ever the one carrying a login -- a stale waiter would fire a consent
|
|
@@ -568,7 +796,18 @@ export class SidecarHost {
|
|
|
568
796
|
}
|
|
569
797
|
onMessage(m) {
|
|
570
798
|
switch (m.t) {
|
|
799
|
+
case 'server-proof': {
|
|
800
|
+
void this.answerServerProof(m);
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
571
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
|
+
}
|
|
572
811
|
this.features = m.features ?? [];
|
|
573
812
|
// Runtime-validated: an untrusted/older daemon's `vapidPublicKey` is
|
|
574
813
|
// just a JSON-parsed `string`, and a malformed one must never be
|
|
@@ -584,6 +823,21 @@ export class SidecarHost {
|
|
|
584
823
|
this.identity = m.identity && typeof m.identity === 'object' && typeof m.identity.verified === 'boolean'
|
|
585
824
|
? m.identity : undefined;
|
|
586
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
|
+
}
|
|
587
841
|
for (const decl of m.tools) {
|
|
588
842
|
const tool = { ...decl, run: (args) => this.invoke(decl.name, args) };
|
|
589
843
|
this.opts.registerTool(tool);
|
|
@@ -594,11 +848,18 @@ export class SidecarHost {
|
|
|
594
848
|
return;
|
|
595
849
|
}
|
|
596
850
|
case 'denied': {
|
|
597
|
-
this.welcome?.reject(new Error(
|
|
851
|
+
this.welcome?.reject(new Error(this.challengedInHandshake
|
|
852
|
+
? `${SIDECAR_NOT_ADMITTED}: ${m.reason}`
|
|
853
|
+
: `sidecar denied connection: ${m.reason}`));
|
|
598
854
|
this.welcome = null;
|
|
599
855
|
return;
|
|
600
856
|
}
|
|
601
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;
|
|
602
863
|
void this.answerIdentityChallenge(m);
|
|
603
864
|
return;
|
|
604
865
|
}
|
|
@@ -1029,6 +1290,16 @@ export class SidecarHost {
|
|
|
1029
1290
|
p.resolve(m.ok ? { ok: true, room: m.room } : { ok: false, problems: m.problems });
|
|
1030
1291
|
return;
|
|
1031
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
|
+
}
|
|
1032
1303
|
case 'hosts-list-result':
|
|
1033
1304
|
case 'hosts-forget-result': {
|
|
1034
1305
|
// One pending map for both, for the same reason as the pause results
|
|
@@ -1080,6 +1351,14 @@ export class SidecarHost {
|
|
|
1080
1351
|
p.resolve(m.ok ? { ok: true, released: m.released } : { ok: false, error: m.error });
|
|
1081
1352
|
return;
|
|
1082
1353
|
}
|
|
1354
|
+
case 'run-plan-get-result': {
|
|
1355
|
+
const p = this.runPlanPending.get(m.id);
|
|
1356
|
+
if (!p)
|
|
1357
|
+
return;
|
|
1358
|
+
this.runPlanPending.delete(m.id);
|
|
1359
|
+
p.resolve(m.ok ? { ok: true, plan: m.plan } : { ok: false, error: m.error });
|
|
1360
|
+
return;
|
|
1361
|
+
}
|
|
1083
1362
|
case 'room-list-result': {
|
|
1084
1363
|
const p = this.roomListPending.get(m.id);
|
|
1085
1364
|
if (!p)
|
|
@@ -1340,11 +1619,15 @@ export class SidecarHost {
|
|
|
1340
1619
|
* An empty list means no record, which a caller renders rather than treats
|
|
1341
1620
|
* as an error — a daemon that keeps none is a normal daemon.
|
|
1342
1621
|
*/
|
|
1343
|
-
runHistory(agent, limit) {
|
|
1622
|
+
runHistory(agent, limit, crew) {
|
|
1344
1623
|
return this.sessionCall((id) => ({
|
|
1345
|
-
t: 'run-history', id, agent, ...(limit === undefined ? {} : { limit }),
|
|
1624
|
+
t: 'run-history', id, agent, ...(limit === undefined ? {} : { limit }), ...(crew ? { crew } : {}),
|
|
1346
1625
|
}));
|
|
1347
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'); }
|
|
1348
1631
|
/**
|
|
1349
1632
|
* One run's kept events, as the daemon stored them.
|
|
1350
1633
|
*
|
|
@@ -1561,9 +1844,10 @@ export class SidecarHost {
|
|
|
1561
1844
|
}
|
|
1562
1845
|
/** Answer an outstanding permission ask. Fire-and-forget over the wire — the
|
|
1563
1846
|
* actual drop of the prompt is driven by the server's `permission-resolved`
|
|
1564
|
-
* broadcast (`onPermissionResolved`), not by this call returning.
|
|
1565
|
-
|
|
1566
|
-
|
|
1847
|
+
* broadcast (`onPermissionResolved`), not by this call returning. `note` is
|
|
1848
|
+
* the person's reason for a deny; the daemon trims and caps it. */
|
|
1849
|
+
answerPermission(id, decision, note) {
|
|
1850
|
+
this.ch.send({ t: 'permission-answer', id, decision, ...(note ? { note } : {}) });
|
|
1567
1851
|
}
|
|
1568
1852
|
/** Push one definition file's content to the daemon, to be persisted under
|
|
1569
1853
|
* this client's own `<stateDir>/hosts/<dirName>/` (Slice 2 host sync).
|
|
@@ -1630,6 +1914,13 @@ export class SidecarHost {
|
|
|
1630
1914
|
configWrite(path, content) {
|
|
1631
1915
|
return this.configCall({ t: 'config-write', id: ++this.configSeq, path, content }).then(() => { });
|
|
1632
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
|
+
}
|
|
1633
1924
|
/** Delete one config file or subtree (recursive, idempotent). */
|
|
1634
1925
|
configDelete(path) {
|
|
1635
1926
|
return this.configCall({ t: 'config-delete', id: ++this.configSeq, path }).then(() => { });
|
|
@@ -1659,6 +1950,28 @@ export class SidecarHost {
|
|
|
1659
1950
|
* directories. Feature-detected so a client offers a destructive control only
|
|
1660
1951
|
* where the daemon would actually honour it. */
|
|
1661
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
|
+
}
|
|
1662
1975
|
/** Whether the connected daemon serves an MCP surface at all. `mcp-list`
|
|
1663
1976
|
* answers an empty list either way (see sidecar.ts), so this is advisory --
|
|
1664
1977
|
* a caller uses it to decide whether to bother asking, not as a gate. */
|
|
@@ -1864,6 +2177,18 @@ export class SidecarHost {
|
|
|
1864
2177
|
this.ch.send({ t: 'workspace-release', id, root: ref.root, stateDir: ref.stateDir, runId: ref.runId });
|
|
1865
2178
|
});
|
|
1866
2179
|
}
|
|
2180
|
+
/** True when the daemon answers run-plan-get. */
|
|
2181
|
+
supportsRunPlan() { return this.features.includes(RUN_PLAN_FEATURE); }
|
|
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 = {}) {
|
|
2186
|
+
const id = ++this.runPlanSeq;
|
|
2187
|
+
return new Promise((resolve, reject) => {
|
|
2188
|
+
this.runPlanPending.set(id, { resolve, reject });
|
|
2189
|
+
this.ch.send({ t: 'run-plan-get', id, runId, ...(opts.output ? { output: true } : {}) });
|
|
2190
|
+
});
|
|
2191
|
+
}
|
|
1867
2192
|
/** Ask the daemon to open a room. `ok: false` here is the daemon's CONSIDERED
|
|
1868
2193
|
* refusal (validation problems, or an unreachable participant) — a real answer,
|
|
1869
2194
|
* not a transport failure, so it resolves rather than rejects. Only a dropped
|