@corenel/harness 0.23.0 → 0.24.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 (69) hide show
  1. package/dist/approvals/service.d.ts +3 -0
  2. package/dist/approvals/service.d.ts.map +1 -1
  3. package/dist/approvals/service.js +13 -0
  4. package/dist/approvals/service.js.map +1 -1
  5. package/dist/approvals/suggest.js +1 -1
  6. package/dist/approvals/suggest.js.map +1 -1
  7. package/dist/chat/engine.d.ts.map +1 -1
  8. package/dist/chat/engine.js +5 -3
  9. package/dist/chat/engine.js.map +1 -1
  10. package/dist/chat/types.d.ts +1 -0
  11. package/dist/chat/types.d.ts.map +1 -1
  12. package/dist/core/loop.d.ts.map +1 -1
  13. package/dist/core/loop.js +5 -4
  14. package/dist/core/loop.js.map +1 -1
  15. package/dist/core/spawn.d.ts +3 -1
  16. package/dist/core/spawn.d.ts.map +1 -1
  17. package/dist/core/spawn.js +16 -1
  18. package/dist/core/spawn.js.map +1 -1
  19. package/dist/dag/executeNode.js +1 -1
  20. package/dist/dag/executeNode.js.map +1 -1
  21. package/dist/guardrail/builtins.d.ts.map +1 -1
  22. package/dist/guardrail/builtins.js +2 -0
  23. package/dist/guardrail/builtins.js.map +1 -1
  24. package/dist/guardrail/permission-service.d.ts +27 -0
  25. package/dist/guardrail/permission-service.d.ts.map +1 -1
  26. package/dist/guardrail/permission-service.js +47 -5
  27. package/dist/guardrail/permission-service.js.map +1 -1
  28. package/dist/prompts/library/defaults.generated.d.ts.map +1 -1
  29. package/dist/prompts/library/defaults.generated.js +15 -14
  30. package/dist/prompts/library/defaults.generated.js.map +1 -1
  31. package/dist/sessions/fileSessionStore.d.ts.map +1 -1
  32. package/dist/sessions/fileSessionStore.js +18 -4
  33. package/dist/sessions/fileSessionStore.js.map +1 -1
  34. package/dist/sessions/jsonl.d.ts +1 -0
  35. package/dist/sessions/jsonl.d.ts.map +1 -1
  36. package/dist/sessions/jsonl.js +10 -0
  37. package/dist/sessions/jsonl.js.map +1 -1
  38. package/dist/sidecar/accessTestRig.d.ts +9 -2
  39. package/dist/sidecar/accessTestRig.d.ts.map +1 -1
  40. package/dist/sidecar/accessTestRig.js +13 -6
  41. package/dist/sidecar/accessTestRig.js.map +1 -1
  42. package/dist/sidecar/host.d.ts +47 -0
  43. package/dist/sidecar/host.d.ts.map +1 -1
  44. package/dist/sidecar/host.js +135 -1
  45. package/dist/sidecar/host.js.map +1 -1
  46. package/dist/sidecar/memberGate.d.ts.map +1 -1
  47. package/dist/sidecar/memberGate.js +8 -0
  48. package/dist/sidecar/memberGate.js.map +1 -1
  49. package/dist/sidecar/server.d.ts +88 -0
  50. package/dist/sidecar/server.d.ts.map +1 -1
  51. package/dist/sidecar/server.js +448 -49
  52. package/dist/sidecar/server.js.map +1 -1
  53. package/dist/templateGuard.d.ts +21 -0
  54. package/dist/templateGuard.d.ts.map +1 -0
  55. package/dist/templateGuard.js +150 -0
  56. package/dist/templateGuard.js.map +1 -0
  57. package/dist/tools/builtins.d.ts +1 -0
  58. package/dist/tools/builtins.d.ts.map +1 -1
  59. package/dist/tools/builtins.js +26 -3
  60. package/dist/tools/builtins.js.map +1 -1
  61. package/dist/tools/namespaces.d.ts +1 -1
  62. package/dist/tools/namespaces.d.ts.map +1 -1
  63. package/dist/tools/namespaces.js +6 -2
  64. package/dist/tools/namespaces.js.map +1 -1
  65. package/dist/tools/skillTool.d.ts +29 -0
  66. package/dist/tools/skillTool.d.ts.map +1 -1
  67. package/dist/tools/skillTool.js +73 -10
  68. package/dist/tools/skillTool.js.map +1 -1
  69. package/package.json +2 -2
@@ -1,4 +1,4 @@
1
- import { CHAT_FEATURE, CHAT_ID_RE, decodePcm16, FLEET_LOGINS_DENIED, SIGN_IN_PAUSED_DENIED, SPEECH_FEATURE, AGENT_CRED_ERROR_CODES, agentCredHeadersProblem, DEFAULT_STATE_DIR, PEER_DAEMON_FEATURE, TEAM_MEMBERS_FEATURE, RUN_TASK_FEATURE, RUN_PLAN_FEATURE, CREW_DEPLOY_FEATURE, FLEET_ITEMS_FEATURE, FLEET_USE_FEATURE, FLEET_USE_LIMITS, isWorkflowCallInputs, DEVICES_FEATURE, AGENT_ACCESS_FEATURE, AGENT_RUNS_DEFAULT_LIMIT, AGENT_RUNS_FEATURE, AGENT_RUNS_MAX_LIMIT, PEER_LINKS_FEATURE, CHILD_EVENTS_FEATURE, FS_CHANGED_FEATURE, ATTENDANCE_LEAVE_FEATURE, MCP_ALLOW_FEATURE, MCP_ADMIN_ERROR_CODES, CREW_DEFINITION_FILES, crewConfigTarget, denialNote, isRunOrigin, cleanPerson } from '@corenel/protocol';
1
+ import { CHAT_FEATURE, CHAT_ID_RE, decodePcm16, FLEET_LOGINS_DENIED, SIGN_IN_PAUSED_DENIED, SPEECH_FEATURE, AGENT_CRED_ERROR_CODES, agentCredHeadersProblem, DEFAULT_STATE_DIR, PEER_DAEMON_FEATURE, TEAM_MEMBERS_FEATURE, RUN_TASK_FEATURE, RUN_PLAN_FEATURE, CREW_DEPLOY_FEATURE, FLEET_ITEMS_FEATURE, FLEET_USE_FEATURE, KNOWLEDGE_FEATURE, KNOWLEDGE_OFF_FEATURE, KNOWLEDGE_NOT_KEPT, KNOWLEDGE_WRITE_ADMIN_ONLY, KNOWLEDGE_ADMIN_ONLY, KNOWLEDGE_UNREACHABLE, knowledgeConflictSentence, SIDECAR_KNOWLEDGE_TOOLS, SIDECAR_KNOWLEDGE_WRITE, FLEET_USE_LIMITS, isWorkflowCallInputs, DEVICES_FEATURE, AGENT_ACCESS_FEATURE, AGENT_RUNS_DEFAULT_LIMIT, AGENT_RUNS_FEATURE, AGENT_RUNS_MAX_LIMIT, PEER_LINKS_FEATURE, CHILD_EVENTS_FEATURE, FS_CHANGED_FEATURE, ATTENDANCE_LEAVE_FEATURE, ATTENDANCE_JOIN_FEATURE, MCP_ALLOW_FEATURE, MCP_ADMIN_ERROR_CODES, CREW_DEFINITION_FILES, crewConfigTarget, denialNote, isRunOrigin, cleanPerson } from '@corenel/protocol';
2
2
  import { SIDECAR_PROTOCOL, errMessage, isLoopbackHost, isLoopbackAddress, parseMcpToolAddress, isWebPushSubscription, MAX_ASSERTION_LEN, isClerkOrgId, MAX_WORKFLOW_PACKAGE_B64, MAX_WORKFLOW_REPLY_ERROR, WORKFLOW_RUNNER_FEATURE, } from '@corenel/protocol';
3
3
  import { activityForViewer, askForViewer, withoutViewerClaims } from './askView.js';
4
4
  import { capabilityMissingMessage } from './hostCapability.js';
@@ -12,6 +12,50 @@ import { fsChangesFor } from './fsChanges.js';
12
12
  function clipReplyError(error) {
13
13
  return error.length > MAX_WORKFLOW_REPLY_ERROR ? error.slice(0, MAX_WORKFLOW_REPLY_ERROR) : error;
14
14
  }
15
+ /** A folder as a caller who does not manage the folders sees it: no path, and
16
+ * an error that is a fixed sentence naming the folder by its label only. */
17
+ function knowledgeViewForMember(f) {
18
+ const view = { id: f.id, label: f.label, writable: f.writable, state: f.state, origin: f.origin };
19
+ if (f.state === 'conflict') {
20
+ if (f.conflict)
21
+ view.conflict = f.conflict;
22
+ view.error = knowledgeConflictSentence(f.label, f.conflict);
23
+ }
24
+ else if (f.state === 'unreachable') {
25
+ view.error = KNOWLEDGE_UNREACHABLE;
26
+ }
27
+ return view;
28
+ }
29
+ /** Only the wire fields of a folder, so nothing else a port holds is sent. */
30
+ function knowledgeViewForManager(f) {
31
+ return {
32
+ id: f.id, label: f.label, writable: f.writable, state: f.state, origin: f.origin,
33
+ ...(f.conflict ? { conflict: f.conflict } : {}),
34
+ ...(f.error !== undefined ? { error: f.error } : {}),
35
+ ...(f.path !== undefined ? { path: f.path } : {}),
36
+ };
37
+ }
38
+ /** A `knowledge-set` `add` as sent, checked field by field; null when its shape is wrong. */
39
+ function knowledgeAddOf(v) {
40
+ if (typeof v !== 'object' || v === null || Array.isArray(v))
41
+ return null;
42
+ const o = v;
43
+ if (typeof o.path !== 'string')
44
+ return null;
45
+ if (o.writable !== undefined && typeof o.writable !== 'boolean')
46
+ return null;
47
+ if (o.label !== undefined && typeof o.label !== 'string')
48
+ return null;
49
+ return {
50
+ path: o.path,
51
+ ...(o.writable === true ? { writable: true } : {}),
52
+ ...(typeof o.label === 'string' ? { label: o.label } : {}),
53
+ };
54
+ }
55
+ /** What a knowledge-set with neither or both of add and remove, or a malformed one, answers. */
56
+ export const KNOWLEDGE_SET_SHAPE = 'Give one folder to add or the id of one folder to remove.';
57
+ /** What a knowledge message answers a caller who does not manage the folders when the port throws. */
58
+ export const KNOWLEDGE_FAILED = 'The sidecar could not answer that knowledge request. Its admin can find the reason in its log.';
15
59
  /** The refusal for workflow inputs that fail isWorkflowCallInputs. */
16
60
  export const FLEET_WORKFLOW_INPUTS_ERROR = `workflow inputs must be an object of at most ${FLEET_USE_LIMITS.inputsBytes / 1024} KiB`;
17
61
  /** At most this many fleet workflow calls open at once on one connection (each loads and verifies its archive). */
@@ -80,6 +124,11 @@ export const AGENT_CRED_FAILED = 'Agent credentials could not be checked or chan
80
124
  export const AGENT_CRED_LOGIN_FAILED = 'The sign-in did not finish. The daemon log has the reason.';
81
125
  export const AGENT_CRED_SOLO = 'This daemon does not keep agent access.';
82
126
  export const MCP_ADMIN_ONLY = "In team mode only the daemon admin can use or change this machine's MCP servers directly.";
127
+ /** A command runs as the daemon's OS user, outside every root and every
128
+ * per-member check, so on a team daemon it is the admin's alone. */
129
+ export const COMMANDS_ADMIN_ONLY = 'In team mode only the daemon admin can run commands on this machine directly.';
130
+ /** The tools a connected client could use to run a command or reach a running one. */
131
+ const COMMAND_TOOLS = new Set(['sidecar_shell_exec', 'proc_start', 'proc_stop', 'proc_list']);
83
132
  export const SESSION_REFUSED = 'In team mode a session is reachable only by the member who created it, or the daemon admin.';
84
133
  export const SNAPSHOT_REFUSED = 'In team mode you can restore only from a snapshot of a session you created.';
85
134
  export const SNAPSHOT_NAME_REFUSED = 'In team mode a snapshot name is chosen by the daemon; only the daemon admin can name one.';
@@ -814,6 +863,15 @@ export class SidecarServer {
814
863
  writable: true,
815
864
  value: false
816
865
  });
866
+ /** The welcome was sent: the hello is finished. An `attendance-join` is
867
+ * read only after it, so it cannot attach in the middle of the hello (whose
868
+ * own attach, or a leave that arrived meanwhile, decides that). */
869
+ Object.defineProperty(this, "welcomed", {
870
+ enumerable: true,
871
+ configurable: true,
872
+ writable: true,
873
+ value: false
874
+ });
817
875
  /** This connection's seat for chat ask_user (verified connections only),
818
876
  * and its leave function, called once on close. */
819
877
  Object.defineProperty(this, "askSeat", {
@@ -1366,6 +1424,10 @@ export class SidecarServer {
1366
1424
  void this.onAcpStart(m);
1367
1425
  return;
1368
1426
  case 'acp-set-mode':
1427
+ if (!this.authed) {
1428
+ this.ch.send({ t: 'acp-error', id: m.id, error: 'not authenticated' });
1429
+ return;
1430
+ }
1369
1431
  void this.onAcpSetMode(m);
1370
1432
  return;
1371
1433
  case 'acp-prompt':
@@ -1405,6 +1467,26 @@ export class SidecarServer {
1405
1467
  this.leftAttendance = true;
1406
1468
  this.attendedDetach?.();
1407
1469
  this.attendedDetach = undefined;
1470
+ /* Its ask_user seat goes too: a question shown on a connection that
1471
+ * is not attending would wait on nobody. The seat object stays, for
1472
+ * a join, and an ask still open is shown to it again then. */
1473
+ this.askSeatLeave?.();
1474
+ this.askSeatLeave = undefined;
1475
+ return;
1476
+ case 'attendance-join':
1477
+ /* The client takes this connection back for the person (a browser
1478
+ * that switched back to this machine): it attends again, and the
1479
+ * attach replays what is waiting, as at hello. Only after a leave on
1480
+ * THIS connection, so an attendance is never held twice, and only
1481
+ * once the hello is finished (welcomed). A peer daemon never gets
1482
+ * here (its messages are filtered above) and is never attached
1483
+ * (attachAttendance). No reply. */
1484
+ if (!this.authed || !this.welcomed || this.closed || !this.leftAttendance)
1485
+ return;
1486
+ this.leftAttendance = false;
1487
+ this.attachAttendance();
1488
+ if (this.askSeat && !this.askSeatLeave)
1489
+ this.askSeatLeave = this.askUsers().join(this.askSeat);
1408
1490
  return;
1409
1491
  case 'roots-browse':
1410
1492
  // Auth-gated like every other request: directory enumeration must never
@@ -1435,6 +1517,10 @@ export class SidecarServer {
1435
1517
  this.ch.send({ t: 'proc-error', id: m.id, error: 'not authenticated' });
1436
1518
  return;
1437
1519
  }
1520
+ if (this.commandRefusal()) {
1521
+ this.ch.send({ t: 'proc-error', id: m.id, error: COMMANDS_ADMIN_ONLY });
1522
+ return;
1523
+ }
1438
1524
  void this.onProcSpawn(m);
1439
1525
  return;
1440
1526
  case 'proc-attach':
@@ -1442,6 +1528,10 @@ export class SidecarServer {
1442
1528
  this.ch.send({ t: 'proc-attach-result', id: m.id, ok: false, error: 'not authenticated' });
1443
1529
  return;
1444
1530
  }
1531
+ if (this.commandRefusal()) {
1532
+ this.ch.send({ t: 'proc-attach-result', id: m.id, ok: false, error: COMMANDS_ADMIN_ONLY });
1533
+ return;
1534
+ }
1445
1535
  this.onProcAttach(m);
1446
1536
  return;
1447
1537
  case 'proc-input':
@@ -1449,6 +1539,10 @@ export class SidecarServer {
1449
1539
  this.ch.send({ t: 'proc-input-result', id: m.id, ok: false, error: 'not authenticated' });
1450
1540
  return;
1451
1541
  }
1542
+ if (this.commandRefusal()) {
1543
+ this.ch.send({ t: 'proc-input-result', id: m.id, ok: false, error: COMMANDS_ADMIN_ONLY });
1544
+ return;
1545
+ }
1452
1546
  this.onProcSimple(m.id, 'proc-input-result', () => this.requireProcs().write(m.procId, m.text));
1453
1547
  return;
1454
1548
  case 'proc-kill':
@@ -1456,6 +1550,10 @@ export class SidecarServer {
1456
1550
  this.ch.send({ t: 'proc-kill-result', id: m.id, ok: false, error: 'not authenticated' });
1457
1551
  return;
1458
1552
  }
1553
+ if (this.commandRefusal()) {
1554
+ this.ch.send({ t: 'proc-kill-result', id: m.id, ok: false, error: COMMANDS_ADMIN_ONLY });
1555
+ return;
1556
+ }
1459
1557
  this.onProcSimple(m.id, 'proc-kill-result', () => this.requireProcs().kill(m.procId));
1460
1558
  return;
1461
1559
  case 'stt-start': {
@@ -1524,7 +1622,8 @@ export class SidecarServer {
1524
1622
  // An empty list is the honest answer for a daemon without the capability:
1525
1623
  // it is running nothing, and an error here would make the common "what is
1526
1624
  // running?" probe noisy on every sidecar that never allowed shell.
1527
- if (!this.authed) {
1625
+ // A team member is shown none: the table holds other people's commands.
1626
+ if (!this.authed || this.commandRefusal()) {
1528
1627
  this.ch.send({ t: 'proc-list-result', id: m.id, procs: [] });
1529
1628
  return;
1530
1629
  }
@@ -1631,6 +1730,41 @@ export class SidecarServer {
1631
1730
  }
1632
1731
  void this.onFleetItems(m);
1633
1732
  return;
1733
+ case 'knowledge-list':
1734
+ if (!this.authed) {
1735
+ this.ch.send({ t: 'knowledge-list-result', id: m.id, reply: { ok: false, error: 'not authenticated' } });
1736
+ return;
1737
+ }
1738
+ this.onKnowledgeList(m);
1739
+ return;
1740
+ case 'knowledge-set':
1741
+ if (!this.authed) {
1742
+ this.ch.send({ t: 'knowledge-set-result', id: m.id, reply: { ok: false, error: 'not authenticated' } });
1743
+ return;
1744
+ }
1745
+ void this.onKnowledgeSet(m);
1746
+ return;
1747
+ case 'knowledge-files':
1748
+ if (!this.authed) {
1749
+ this.ch.send({ t: 'knowledge-files-result', id: m.id, reply: { ok: false, error: 'not authenticated' } });
1750
+ return;
1751
+ }
1752
+ void this.onKnowledgeFiles(m);
1753
+ return;
1754
+ case 'knowledge-file':
1755
+ if (!this.authed) {
1756
+ this.ch.send({ t: 'knowledge-file-result', id: m.id, reply: { ok: false, error: 'not authenticated' } });
1757
+ return;
1758
+ }
1759
+ void this.onKnowledgeFile(m);
1760
+ return;
1761
+ case 'knowledge-changes':
1762
+ if (!this.authed) {
1763
+ this.ch.send({ t: 'knowledge-changes-result', id: m.id, reply: { ok: false, error: 'not authenticated' } });
1764
+ return;
1765
+ }
1766
+ void this.onKnowledgeChanges(m);
1767
+ return;
1634
1768
  case 'fleet-skill-catalog':
1635
1769
  if (!this.authed) {
1636
1770
  this.ch.send({ t: 'fleet-skill-catalog-result', id: m.id, reply: { ok: false, error: 'not authenticated' } });
@@ -2583,49 +2717,13 @@ export class SidecarServer {
2583
2717
  this.ch.send({ t: 'ask-user-resolved', id, by }); },
2584
2718
  };
2585
2719
  this.askSeat = seat;
2586
- this.askSeatLeave = this.askUsers().join(seat);
2720
+ // Seated only while attending: a leave during the hello holds here too.
2721
+ if (!this.leftAttendance)
2722
+ this.askSeatLeave = this.askUsers().join(seat);
2587
2723
  }
2588
2724
  // A leave that arrived while this hello was still being checked holds.
2589
- if (this.opts.attendance && !isPeerDaemon && !this.leftAttendance) {
2590
- /* A pending member (Part E) is never a recipient of anything: every
2591
- * send below checks the status at the moment it is made, so a member
2592
- * approved later starts receiving without reconnecting. */
2593
- const attended = {
2594
- // Shaped for THIS connection (E5.2): the requester's own copy names nobody.
2595
- send: (ask) => { if (!this.memberPending())
2596
- this.ch.send({ t: 'permission-ask', ...askForViewer(ask, this.verifiedUserId) }); },
2597
- resolved: (id, decision, by, byClientId) => {
2598
- if (!this.memberPending())
2599
- this.ch.send({ t: 'permission-resolved', id, decision, by, ...(byClientId ? { byClientId } : {}) });
2600
- },
2601
- // Team mode: filtered by role (Task 16b). The registry calls this for
2602
- // the live broadcast AND for the replay at attach, so both are covered.
2603
- // An active member still gets only what their role allows.
2604
- runActivity: (activity) => { if (!this.memberPending())
2605
- this.sendRunActivity(activity); },
2606
- registrations: (registrations) => { if (!this.memberPending())
2607
- this.ch.send({ t: 'registrations', registrations }); },
2608
- approvalSuggestion: (s) => { if (!this.memberPending())
2609
- this.ch.send({ t: 'approvals-suggest', ...s }); },
2610
- };
2611
- // Retained so `permission-answer` can name THIS connection as the
2612
- // answerer -- the registry identifies an answerer by the object it
2613
- // attached, not by anything the answer message claims.
2614
- this.attended = attended;
2615
- /* The identity is what routes an ask under `--mode team`. `clientId` is
2616
- * the key this connection was FILED under (mapped through `clientKey`
2617
- * where the daemon supplies one), i.e. the same value its RunIdentity
2618
- * carries -- which is what makes a run's owner and its ask's recipient
2619
- * the same connection. `accounts` are CLAIMED, carried for display, and
2620
- * widen nothing; only `verifiedUserId` (proven at pairing) widens a
2621
- * delivery to the same user's other devices. See the registry's
2622
- * `recipients`. */
2623
- this.attendedDetach = this.opts.attendance.attach(attended, {
2624
- clientId: this.clientId ?? '',
2625
- accounts,
2626
- ...(this.verifiedUserId ? { verifiedUserId: this.verifiedUserId } : {}),
2627
- });
2628
- }
2725
+ if (!isPeerDaemon && !this.leftAttendance)
2726
+ this.attachAttendance();
2629
2727
  /* The registry replays in-flight runs at attach, and the client relies on
2630
2728
  * that replay landing BEFORE `welcome` (it prunes the runs it still holds
2631
2729
  * as stale once welcomed; see pruneStaleDaemonRuns). Team mode filters the
@@ -2656,6 +2754,53 @@ export class SidecarServer {
2656
2754
  }
2657
2755
  this.sendWelcome(dirName);
2658
2756
  }
2757
+ /** Attaches THIS connection to the attendance registry, which replays to it
2758
+ * every outstanding ask it may see, the registrations, the live runs and the
2759
+ * task runs that settled recently. Called from `hello`, and again by an
2760
+ * `attendance-join` after an `attendance-leave`. Never twice at once: an
2761
+ * attachment already held is kept as it is. */
2762
+ attachAttendance() {
2763
+ if (!this.opts.attendance || this.peerDaemon || this.attendedDetach)
2764
+ return;
2765
+ /* A pending member (Part E) is never a recipient of anything: every
2766
+ * send below checks the status at the moment it is made, so a member
2767
+ * approved later starts receiving without reconnecting. */
2768
+ const attended = {
2769
+ // Shaped for THIS connection (E5.2): the requester's own copy names nobody.
2770
+ send: (ask) => { if (!this.memberPending())
2771
+ this.ch.send({ t: 'permission-ask', ...askForViewer(ask, this.verifiedUserId) }); },
2772
+ resolved: (id, decision, by, byClientId) => {
2773
+ if (!this.memberPending())
2774
+ this.ch.send({ t: 'permission-resolved', id, decision, by, ...(byClientId ? { byClientId } : {}) });
2775
+ },
2776
+ // Team mode: filtered by role (Task 16b). The registry calls this for
2777
+ // the live broadcast AND for the replay at attach, so both are covered.
2778
+ // An active member still gets only what their role allows.
2779
+ runActivity: (activity) => { if (!this.memberPending())
2780
+ this.sendRunActivity(activity); },
2781
+ registrations: (registrations) => { if (!this.memberPending())
2782
+ this.ch.send({ t: 'registrations', registrations }); },
2783
+ approvalSuggestion: (s) => { if (!this.memberPending())
2784
+ this.ch.send({ t: 'approvals-suggest', ...s }); },
2785
+ };
2786
+ // Retained so `permission-answer` can name THIS connection as the
2787
+ // answerer -- the registry identifies an answerer by the object it
2788
+ // attached, not by anything the answer message claims.
2789
+ this.attended = attended;
2790
+ /* The identity is what routes an ask under `--mode team`. `clientId` is
2791
+ * the key this connection was FILED under (mapped through `clientKey`
2792
+ * where the daemon supplies one), i.e. the same value its RunIdentity
2793
+ * carries -- which is what makes a run's owner and its ask's recipient
2794
+ * the same connection. `accounts` are CLAIMED, carried for display, and
2795
+ * widen nothing; only `verifiedUserId` (proven at pairing) widens a
2796
+ * delivery to the same user's other devices. See the registry's
2797
+ * `recipients`. */
2798
+ this.attendedDetach = this.opts.attendance.attach(attended, {
2799
+ clientId: this.clientId ?? '',
2800
+ accounts: this.accounts,
2801
+ ...(this.verifiedUserId ? { verifiedUserId: this.verifiedUserId } : {}),
2802
+ });
2803
+ }
2659
2804
  /** Waits for this connection's `identity-assert`, with NO timeout of its
2660
2805
  * own -- the overall bound is `awaitAndVerifyIdentity`'s, covering this wait
2661
2806
  * AND the verify call together, so the whole leg is bounded at
@@ -2766,6 +2911,7 @@ export class SidecarServer {
2766
2911
  }
2767
2912
  }
2768
2913
  sendWelcome(dirName) {
2914
+ this.welcomed = true;
2769
2915
  /* Remembered BEFORE the welcome goes out, because `self` and the
2770
2916
  * forget-your-own-tree refusal are both answered from it. A daemon too old
2771
2917
  * to resolve one, or a client with no identity, leaves it undefined -- and
@@ -2832,7 +2978,7 @@ export class SidecarServer {
2832
2978
  // Whether a write another client makes reaches this one as fs-changed.
2833
2979
  ...(this.opts.fsChanges ? [FS_CHANGED_FEATURE] : []),
2834
2980
  // A connection may stop attending while it stays open (attendance-leave).
2835
- ...(this.opts.attendance ? [ATTENDANCE_LEAVE_FEATURE] : []),
2981
+ ...(this.opts.attendance ? [ATTENDANCE_LEAVE_FEATURE, ATTENDANCE_JOIN_FEATURE] : []),
2836
2982
  ...(this.opts.acp ? ['acp'] : []),
2837
2983
  ...(this.opts.roots?.areas.length ? ['add-roots'] : []),
2838
2984
  ...(this.opts.roots?.areas.length && this.opts.roots.create ? ['create-roots'] : []),
@@ -2865,6 +3011,11 @@ export class SidecarServer {
2865
3011
  // Whether this sidecar lists its fleet items. Only with a real port.
2866
3012
  ...(this.opts.fleetItems ? [FLEET_ITEMS_FEATURE] : []),
2867
3013
  ...(this.opts.fleetUse ? [FLEET_USE_FEATURE] : []),
3014
+ // Whether this sidecar answers the knowledge tools and the knowledge-*
3015
+ // messages over its own folders. Only with a real port, which cli.ts sets.
3016
+ // While the fleet has turned knowledge off (knowledgeOffNow) it says
3017
+ // KNOWLEDGE_OFF_FEATURE instead, so a client does not read it as an old sidecar.
3018
+ ...(this.opts.knowledge ? [this.knowledgeOffNow(this.opts.knowledge) === null ? KNOWLEDGE_FEATURE : KNOWLEDGE_OFF_FEATURE] : []),
2868
3019
  // Whether this daemon runs deployed workflows. Only with a real port, so
2869
3020
  // a client offers the Sidecar deploy target only where it would run.
2870
3021
  ...(this.opts.workflows ? [WORKFLOW_RUNNER_FEATURE] : []),
@@ -3228,6 +3379,41 @@ export class SidecarServer {
3228
3379
  this.ch.send({ t: 'invoke-result', id: m.id, ok: false, error: `unknown tool ${m.name}` });
3229
3380
  return;
3230
3381
  }
3382
+ /* TEAM MODE: COMMANDS ARE THE ADMIN'S (security review 2026-11-12). The
3383
+ * agent-access gate below decides writes into agent folders only; a shell
3384
+ * command is not a path, so it never saw one, and any admitted member could
3385
+ * run one as the daemon's OS user and read the state dir with it. */
3386
+ if (COMMAND_TOOLS.has(m.name)) {
3387
+ const refusal = this.commandRefusal();
3388
+ if (refusal) {
3389
+ this.opts.log?.(`invoke ${m.name} refused: ${refusal}`);
3390
+ this.ch.send({ t: 'invoke-result', id: m.id, ok: false, error: refusal });
3391
+ return;
3392
+ }
3393
+ }
3394
+ /* TEAM MODE: THIS SIDECAR'S KNOWLEDGE IS THE ADMIN'S TO CHANGE (host
3395
+ * knowledge spec, section 3). Refused here, whatever the browser offered,
3396
+ * and shown to the model as the tool result, as the tool's own refusals are. */
3397
+ /* KNOWLEDGE THE FLEET TURNED OFF is refused on every connection, one
3398
+ * opened before the change included (its welcome still lists the feature),
3399
+ * and shown to the model as the tool result. */
3400
+ const knowledgeTools = SIDECAR_KNOWLEDGE_TOOLS;
3401
+ if (knowledgeTools.includes(m.name) && this.opts.knowledge) {
3402
+ const off = this.knowledgeOffNow(this.opts.knowledge);
3403
+ if (off !== null) {
3404
+ this.opts.log?.(`invoke ${m.name} refused: ${off}`);
3405
+ this.ch.send({ t: 'invoke-result', id: m.id, ok: true, result: off });
3406
+ return;
3407
+ }
3408
+ }
3409
+ if (m.name === SIDECAR_KNOWLEDGE_WRITE) {
3410
+ const refusal = this.knowledgeWriteRefusal();
3411
+ if (refusal) {
3412
+ this.opts.log?.(`invoke ${m.name} refused: ${refusal}`);
3413
+ this.ch.send({ t: 'invoke-result', id: m.id, ok: true, result: refusal });
3414
+ return;
3415
+ }
3416
+ }
3231
3417
  /* MUTATIONS ARE LOUD, READS ARE NOT.
3232
3418
  *
3233
3419
  * A single agent run issues hundreds of `fs_read_file` / `fs_list_dir`
@@ -4240,6 +4426,166 @@ export class SidecarServer {
4240
4426
  ...(this.verifiedUserId !== undefined ? { verifiedUserId: this.verifiedUserId } : {}),
4241
4427
  };
4242
4428
  }
4429
+ /** What a knowledge message answers when the port throws. The text of an
4430
+ * unexpected throw may name a path on this machine, so only a caller who
4431
+ * manages the folders (and so sees their paths) gets it; anyone else gets
4432
+ * KNOWLEDGE_FAILED, and the real text goes to the log. */
4433
+ knowledgeThrowText(kind, err) {
4434
+ const text = errMessage(err);
4435
+ if (this.knowledgeCanManage())
4436
+ return text;
4437
+ this.opts.log?.(`${kind} failed: ${text}`);
4438
+ return KNOWLEDGE_FAILED;
4439
+ }
4440
+ /** Why this sidecar's knowledge is off now, or null. A port whose answer
4441
+ * throws is treated as off, with a fixed sentence: fail closed. */
4442
+ knowledgeOffNow(port) {
4443
+ try {
4444
+ return port.off?.() ?? null;
4445
+ }
4446
+ catch (err) {
4447
+ this.opts.log?.(`knowledge: could not tell whether knowledge is on (${err instanceof Error ? err.message : String(err)})`);
4448
+ return KNOWLEDGE_NOT_KEPT;
4449
+ }
4450
+ }
4451
+ knowledgeListReply(port) {
4452
+ const canManage = this.knowledgeCanManage();
4453
+ const folders = port.folders().map((f) => (canManage ? knowledgeViewForManager(f) : knowledgeViewForMember(f)));
4454
+ return { ok: true, folders, canManage };
4455
+ }
4456
+ onKnowledgeList(m) {
4457
+ if (typeof m.id !== 'number' || !Number.isSafeInteger(m.id))
4458
+ return;
4459
+ const answer = (reply) => this.ch.send({ t: 'knowledge-list-result', id: m.id, reply });
4460
+ const port = this.opts.knowledge;
4461
+ if (!port) {
4462
+ answer({ ok: false, error: KNOWLEDGE_NOT_KEPT });
4463
+ return;
4464
+ }
4465
+ const off = this.knowledgeOffNow(port);
4466
+ if (off !== null) {
4467
+ answer({ ok: false, error: off });
4468
+ return;
4469
+ }
4470
+ try {
4471
+ answer(this.knowledgeListReply(port));
4472
+ }
4473
+ catch (err) {
4474
+ answer({ ok: false, error: this.knowledgeThrowText('knowledge-list', err) });
4475
+ }
4476
+ }
4477
+ /** Add or remove one knowledge folder. Refused unless this connection
4478
+ * manages the folders, before anything else in the message is looked at. */
4479
+ async onKnowledgeSet(m) {
4480
+ if (typeof m.id !== 'number' || !Number.isSafeInteger(m.id))
4481
+ return;
4482
+ const answer = (reply) => this.ch.send({ t: 'knowledge-set-result', id: m.id, reply });
4483
+ const port = this.opts.knowledge;
4484
+ if (!port) {
4485
+ answer({ ok: false, error: KNOWLEDGE_NOT_KEPT });
4486
+ return;
4487
+ }
4488
+ const off = this.knowledgeOffNow(port);
4489
+ if (off !== null) {
4490
+ answer({ ok: false, error: off });
4491
+ return;
4492
+ }
4493
+ if (!this.knowledgeCanManage()) {
4494
+ this.opts.log?.(`knowledge-set refused: ${KNOWLEDGE_ADMIN_ONLY}`);
4495
+ answer({ ok: false, error: KNOWLEDGE_ADMIN_ONLY });
4496
+ return;
4497
+ }
4498
+ const add = m.add !== undefined ? knowledgeAddOf(m.add) : undefined;
4499
+ const remove = typeof m.remove === 'string' ? m.remove : undefined;
4500
+ const oneOf = (m.add !== undefined) !== (m.remove !== undefined);
4501
+ if (!oneOf || add === null || (m.remove !== undefined && remove === undefined)) {
4502
+ answer({ ok: false, error: KNOWLEDGE_SET_SHAPE });
4503
+ return;
4504
+ }
4505
+ try {
4506
+ const done = await port.set(add ? { add } : { remove });
4507
+ if (!done.ok) {
4508
+ answer(done);
4509
+ return;
4510
+ }
4511
+ this.opts.log?.(add ? `knowledge folder added: ${add.path}` : `knowledge folder removed: ${remove ?? ''}`);
4512
+ answer(this.knowledgeListReply(port));
4513
+ }
4514
+ catch (err) {
4515
+ answer({ ok: false, error: this.knowledgeThrowText('knowledge-set', err) });
4516
+ }
4517
+ }
4518
+ async onKnowledgeFiles(m) {
4519
+ if (typeof m.id !== 'number' || !Number.isSafeInteger(m.id))
4520
+ return;
4521
+ const answer = (reply) => this.ch.send({ t: 'knowledge-files-result', id: m.id, reply });
4522
+ const port = this.opts.knowledge;
4523
+ if (!port) {
4524
+ answer({ ok: false, error: KNOWLEDGE_NOT_KEPT });
4525
+ return;
4526
+ }
4527
+ const off = this.knowledgeOffNow(port);
4528
+ if (off !== null) {
4529
+ answer({ ok: false, error: off });
4530
+ return;
4531
+ }
4532
+ if (typeof m.folder !== 'string') {
4533
+ answer({ ok: false, error: 'Name a knowledge folder by its id.' });
4534
+ return;
4535
+ }
4536
+ try {
4537
+ answer(await port.files(m.folder));
4538
+ }
4539
+ catch (err) {
4540
+ answer({ ok: false, error: this.knowledgeThrowText('knowledge-files', err) });
4541
+ }
4542
+ }
4543
+ async onKnowledgeFile(m) {
4544
+ if (typeof m.id !== 'number' || !Number.isSafeInteger(m.id))
4545
+ return;
4546
+ const answer = (reply) => this.ch.send({ t: 'knowledge-file-result', id: m.id, reply });
4547
+ const port = this.opts.knowledge;
4548
+ if (!port) {
4549
+ answer({ ok: false, error: KNOWLEDGE_NOT_KEPT });
4550
+ return;
4551
+ }
4552
+ const off = this.knowledgeOffNow(port);
4553
+ if (off !== null) {
4554
+ answer({ ok: false, error: off });
4555
+ return;
4556
+ }
4557
+ if (typeof m.folder !== 'string' || typeof m.path !== 'string' || (m.stat !== undefined && typeof m.stat !== 'boolean')) {
4558
+ answer({ ok: false, error: 'Name a knowledge folder by its id and a file by its path in it.' });
4559
+ return;
4560
+ }
4561
+ try {
4562
+ answer(await port.file(m.folder, m.path, m.stat === true));
4563
+ }
4564
+ catch (err) {
4565
+ answer({ ok: false, error: this.knowledgeThrowText('knowledge-file', err) });
4566
+ }
4567
+ }
4568
+ async onKnowledgeChanges(m) {
4569
+ if (typeof m.id !== 'number' || !Number.isSafeInteger(m.id))
4570
+ return;
4571
+ const answer = (reply) => this.ch.send({ t: 'knowledge-changes-result', id: m.id, reply });
4572
+ const port = this.opts.knowledge;
4573
+ if (!port) {
4574
+ answer({ ok: false, error: KNOWLEDGE_NOT_KEPT });
4575
+ return;
4576
+ }
4577
+ const off = this.knowledgeOffNow(port);
4578
+ if (off !== null) {
4579
+ answer({ ok: false, error: off });
4580
+ return;
4581
+ }
4582
+ try {
4583
+ answer(await port.changes());
4584
+ }
4585
+ catch (err) {
4586
+ answer({ ok: false, error: this.knowledgeThrowText('knowledge-changes', err) });
4587
+ }
4588
+ }
4243
4589
  async onFleetItems(m) {
4244
4590
  if (typeof m.id !== 'number' || !Number.isSafeInteger(m.id))
4245
4591
  return;
@@ -6116,6 +6462,41 @@ export class SidecarServer {
6116
6462
  return MCP_ADMIN_ONLY;
6117
6463
  }
6118
6464
  }
6465
+ /** Team mode: running a command on the daemon's machine, or reaching one
6466
+ * that is running, is the admin's. Null when THIS connection may. Unknown
6467
+ * admin and any throw while deciding are refusals. Solo mode: always null. */
6468
+ commandRefusal() {
6469
+ try {
6470
+ const access = this.teamAccess();
6471
+ if (!access)
6472
+ return null;
6473
+ return sameAdmin(this.verifiedUserId, access.adminId()) ? null : COMMANDS_ADMIN_ONLY;
6474
+ }
6475
+ catch {
6476
+ return COMMANDS_ADMIN_ONLY;
6477
+ }
6478
+ }
6479
+ /** Team mode: writing into this sidecar's knowledge folders is the admin's.
6480
+ * Null when THIS connection may. Unknown admin and any throw while deciding
6481
+ * are refusals. Solo mode: always null. */
6482
+ knowledgeWriteRefusal() {
6483
+ return this.knowledgeCanManage() ? null : KNOWLEDGE_WRITE_ADMIN_ONLY;
6484
+ }
6485
+ /** Whether THIS connection manages this sidecar's knowledge folders (sees
6486
+ * their paths, adds and removes them, writes into the writable one): always
6487
+ * on a solo daemon; on a team daemon only its admin. Unknown admin and any
6488
+ * throw while deciding are a no. */
6489
+ knowledgeCanManage() {
6490
+ try {
6491
+ const access = this.teamAccess();
6492
+ if (!access)
6493
+ return true;
6494
+ return sameAdmin(this.verifiedUserId, access.adminId());
6495
+ }
6496
+ catch {
6497
+ return false;
6498
+ }
6499
+ }
6119
6500
  async onRoomFindings(m) {
6120
6501
  if (!this.opts.rooms) {
6121
6502
  // A daemon without the capability hosts no rooms, so it holds no
@@ -6168,8 +6549,12 @@ export class SidecarServer {
6168
6549
  this.ch.send({ t: 'config-result', id: m.id, ok: false, error: gate.refusal });
6169
6550
  return;
6170
6551
  }
6552
+ /* WHOSE global memory (`memory/`) this call reaches: the service maps
6553
+ * it, after its own allowlist check, from this scope. Every call passes
6554
+ * it, so no route reaches `memory/` without the decision. */
6555
+ const scope = this.configMemoryScope();
6171
6556
  if (m.t === 'config-list') {
6172
- const entries = await svc.list(m.path, m.depth);
6557
+ const entries = await svc.list(m.path, m.depth, scope);
6173
6558
  const shown = await this.visibleConfigEntries(svc, m.path, entries);
6174
6559
  this.ch.send(shown
6175
6560
  ? { t: 'config-result', id: m.id, ok: true, entries: shown }
@@ -6177,18 +6562,18 @@ export class SidecarServer {
6177
6562
  return;
6178
6563
  }
6179
6564
  if (m.t === 'config-read') {
6180
- this.ch.send({ t: 'config-result', id: m.id, ok: true, content: await svc.read(m.path) });
6565
+ this.ch.send({ t: 'config-result', id: m.id, ok: true, content: await svc.read(m.path, scope) });
6181
6566
  return;
6182
6567
  }
6183
6568
  if (m.t === 'config-write')
6184
- await svc.write(m.path, m.content);
6569
+ await svc.write(m.path, m.content, scope);
6185
6570
  else if (m.t === 'config-append') {
6186
6571
  if (!svc.append)
6187
6572
  throw new Error('this sidecar cannot append to its harness config');
6188
- await svc.append(m.path, m.content);
6573
+ await svc.append(m.path, m.content, scope);
6189
6574
  }
6190
6575
  else
6191
- await svc.remove(m.path);
6576
+ await svc.remove(m.path, scope);
6192
6577
  await this.afterConfigChange(gate);
6193
6578
  this.ch.send({ t: 'config-result', id: m.id, ok: true });
6194
6579
  }
@@ -6196,6 +6581,20 @@ export class SidecarServer {
6196
6581
  this.ch.send({ t: 'config-result', id: m.id, ok: false, error: errMessage(e) });
6197
6582
  }
6198
6583
  }
6584
+ /** Whose global memory a config call on THIS connection reaches. A
6585
+ * personal daemon, and a team daemon's admin: the shared `memory/` itself
6586
+ * (undefined). Any other team connection: its verified user's own partition,
6587
+ * or none at all (null) when no user is verified. Throws only when the
6588
+ * access port throws; the caller then refuses the call. */
6589
+ configMemoryScope() {
6590
+ const access = this.teamAccess();
6591
+ if (!access)
6592
+ return undefined;
6593
+ const user = this.verifiedUserId;
6594
+ if (sameAdmin(user, access.adminId()))
6595
+ return undefined;
6596
+ return { memoryUser: typeof user === 'string' && user !== '' ? user : null };
6597
+ }
6199
6598
  /** The config path as the filesystem would reach it, for classification:
6200
6599
  * separators, `.`, empty and `..` segments resolved; the `crew` segment, the
6201
6600
  * agent folder (to its on-disk name, read from the service's own listing of