@corenel/harness 0.22.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 (118) hide show
  1. package/README.md +48 -0
  2. package/dist/approvals/service.d.ts +3 -0
  3. package/dist/approvals/service.d.ts.map +1 -1
  4. package/dist/approvals/service.js +13 -0
  5. package/dist/approvals/service.js.map +1 -1
  6. package/dist/approvals/suggest.js +1 -1
  7. package/dist/approvals/suggest.js.map +1 -1
  8. package/dist/chat/engine.d.ts +3 -0
  9. package/dist/chat/engine.d.ts.map +1 -1
  10. package/dist/chat/engine.js +10 -6
  11. package/dist/chat/engine.js.map +1 -1
  12. package/dist/chat/types.d.ts +1 -0
  13. package/dist/chat/types.d.ts.map +1 -1
  14. package/dist/core/loop.d.ts.map +1 -1
  15. package/dist/core/loop.js +138 -81
  16. package/dist/core/loop.js.map +1 -1
  17. package/dist/core/spawn.d.ts +3 -1
  18. package/dist/core/spawn.d.ts.map +1 -1
  19. package/dist/core/spawn.js +16 -1
  20. package/dist/core/spawn.js.map +1 -1
  21. package/dist/dag/executeNode.js +4 -4
  22. package/dist/dag/executeNode.js.map +1 -1
  23. package/dist/guardrail/builtins.d.ts.map +1 -1
  24. package/dist/guardrail/builtins.js +2 -0
  25. package/dist/guardrail/builtins.js.map +1 -1
  26. package/dist/guardrail/guardrail.d.ts +9 -0
  27. package/dist/guardrail/guardrail.d.ts.map +1 -1
  28. package/dist/guardrail/guardrail.js +13 -0
  29. package/dist/guardrail/guardrail.js.map +1 -1
  30. package/dist/guardrail/index.d.ts +3 -3
  31. package/dist/guardrail/index.d.ts.map +1 -1
  32. package/dist/guardrail/index.js +2 -2
  33. package/dist/guardrail/index.js.map +1 -1
  34. package/dist/guardrail/permission-service.d.ts +44 -3
  35. package/dist/guardrail/permission-service.d.ts.map +1 -1
  36. package/dist/guardrail/permission-service.js +58 -11
  37. package/dist/guardrail/permission-service.js.map +1 -1
  38. package/dist/guardrail/store.d.ts +4 -0
  39. package/dist/guardrail/store.d.ts.map +1 -1
  40. package/dist/guardrail/store.js +6 -0
  41. package/dist/guardrail/store.js.map +1 -1
  42. package/dist/prompts/config.d.ts +6 -2
  43. package/dist/prompts/config.d.ts.map +1 -1
  44. package/dist/prompts/config.js +6 -3
  45. package/dist/prompts/config.js.map +1 -1
  46. package/dist/prompts/host.d.ts +3 -2
  47. package/dist/prompts/host.d.ts.map +1 -1
  48. package/dist/prompts/host.js.map +1 -1
  49. package/dist/prompts/index.d.ts +8 -1
  50. package/dist/prompts/index.d.ts.map +1 -1
  51. package/dist/prompts/index.js +9 -7
  52. package/dist/prompts/index.js.map +1 -1
  53. package/dist/prompts/library/defaults.generated.d.ts.map +1 -1
  54. package/dist/prompts/library/defaults.generated.js +15 -14
  55. package/dist/prompts/library/defaults.generated.js.map +1 -1
  56. package/dist/prompts/systemPrompts.d.ts +3 -0
  57. package/dist/prompts/systemPrompts.d.ts.map +1 -1
  58. package/dist/prompts/systemPrompts.js +1 -1
  59. package/dist/prompts/systemPrompts.js.map +1 -1
  60. package/dist/sessions/fileSessionStore.d.ts.map +1 -1
  61. package/dist/sessions/fileSessionStore.js +18 -4
  62. package/dist/sessions/fileSessionStore.js.map +1 -1
  63. package/dist/sessions/jsonl.d.ts +1 -0
  64. package/dist/sessions/jsonl.d.ts.map +1 -1
  65. package/dist/sessions/jsonl.js +10 -0
  66. package/dist/sessions/jsonl.js.map +1 -1
  67. package/dist/sidecar/accessTestRig.d.ts +9 -2
  68. package/dist/sidecar/accessTestRig.d.ts.map +1 -1
  69. package/dist/sidecar/accessTestRig.js +13 -6
  70. package/dist/sidecar/accessTestRig.js.map +1 -1
  71. package/dist/sidecar/host.d.ts +96 -10
  72. package/dist/sidecar/host.d.ts.map +1 -1
  73. package/dist/sidecar/host.js +328 -6
  74. package/dist/sidecar/host.js.map +1 -1
  75. package/dist/sidecar/memberGate.d.ts.map +1 -1
  76. package/dist/sidecar/memberGate.js +17 -0
  77. package/dist/sidecar/memberGate.js.map +1 -1
  78. package/dist/sidecar/server.d.ts +171 -6
  79. package/dist/sidecar/server.d.ts.map +1 -1
  80. package/dist/sidecar/server.js +793 -65
  81. package/dist/sidecar/server.js.map +1 -1
  82. package/dist/skills.d.ts +9 -1
  83. package/dist/skills.d.ts.map +1 -1
  84. package/dist/skills.js +22 -8
  85. package/dist/skills.js.map +1 -1
  86. package/dist/templateGuard.d.ts +21 -0
  87. package/dist/templateGuard.d.ts.map +1 -0
  88. package/dist/templateGuard.js +150 -0
  89. package/dist/templateGuard.js.map +1 -0
  90. package/dist/tools/builtins.d.ts +1 -0
  91. package/dist/tools/builtins.d.ts.map +1 -1
  92. package/dist/tools/builtins.js +33 -3
  93. package/dist/tools/builtins.js.map +1 -1
  94. package/dist/tools/callAgent.d.ts +6 -2
  95. package/dist/tools/callAgent.d.ts.map +1 -1
  96. package/dist/tools/callAgent.js +7 -3
  97. package/dist/tools/callAgent.js.map +1 -1
  98. package/dist/tools/files.d.ts.map +1 -1
  99. package/dist/tools/files.js +4 -0
  100. package/dist/tools/files.js.map +1 -1
  101. package/dist/tools/namespaces.d.ts +1 -1
  102. package/dist/tools/namespaces.d.ts.map +1 -1
  103. package/dist/tools/namespaces.js +6 -2
  104. package/dist/tools/namespaces.js.map +1 -1
  105. package/dist/tools/sessions.d.ts.map +1 -1
  106. package/dist/tools/sessions.js +1 -0
  107. package/dist/tools/sessions.js.map +1 -1
  108. package/dist/tools/skillTool.d.ts +29 -0
  109. package/dist/tools/skillTool.d.ts.map +1 -1
  110. package/dist/tools/skillTool.js +73 -10
  111. package/dist/tools/skillTool.js.map +1 -1
  112. package/dist/tools/web.d.ts.map +1 -1
  113. package/dist/tools/web.js +19 -5
  114. package/dist/tools/web.js.map +1 -1
  115. package/dist/tools/workflowTool.d.ts +2 -18
  116. package/dist/tools/workflowTool.d.ts.map +1 -1
  117. package/dist/tools/workflowTool.js.map +1 -1
  118. package/package.json +3 -3
@@ -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, 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, 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,57 @@ 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.';
59
+ /** The refusal for workflow inputs that fail isWorkflowCallInputs. */
60
+ export const FLEET_WORKFLOW_INPUTS_ERROR = `workflow inputs must be an object of at most ${FLEET_USE_LIMITS.inputsBytes / 1024} KiB`;
61
+ /** At most this many fleet workflow calls open at once on one connection (each loads and verifies its archive). */
62
+ export const FLEET_CALLS_OPEN_MAX = 16;
63
+ export const FLEET_CALLS_TOO_MANY = 'too many fleet workflow calls open';
64
+ /** A team connection whose host folder the daemon has not resolved (yet): it cannot be named. */
65
+ export const FLEET_CALLER_NO_FOLDER = 'this connection has no host folder yet; reconnect and try again';
15
66
  /** A deliberate refusal from the approvals port, written for the person who
16
67
  * asked: its message goes on the wire as is. Recognized by `name`, not by
17
68
  * `instanceof`, so a second copy of this module (a test alias, a bundled
@@ -34,6 +85,13 @@ export function isApprovalsRefusal(e) {
34
85
  export const WORKFLOWS_VERIFY_FIRST = 'in team mode this machine verifies who you are before it takes workflow packages or hands back results; sign in and reconnect';
35
86
  export const WORKFLOWS_FOLDER_UNBOUND = 'in team mode a synced folder belongs to the first verified user that connected as it, and none has yet; its workflow packages and results stay closed until one does';
36
87
  export const WORKFLOWS_NOT_FOLDER_OWNER = 'in team mode this synced folder belongs to a different verified user; only that user can send it workflow packages or take its results';
88
+ /* Team mode, everything a connection's host folder speaks for besides workflow-*: its runs (the folder is the
89
+ * owner root and the `via` a daemon-crew run is recorded under), chat turns and reads, run history, transcripts,
90
+ * crew memory, fleet workflow calls and host-sync writes into the folder. The same rule as the workflow-* messages (hostFolderStanding): the folder
91
+ * comes from the hello's clientId, which is not secret, so it speaks only for the verified user it is bound to. */
92
+ export const HOST_FOLDER_VERIFY_FIRST = 'in team mode this machine verifies who you are before it runs agents for this app or shows its runs, chats or memory; sign in and reconnect';
93
+ export const HOST_FOLDER_UNBOUND = 'in team mode a synced folder belongs to the first verified user that connected as it, and none has yet; its runs, chats and memory stay closed until one does';
94
+ export const HOST_FOLDER_NOT_OWNER = 'in team mode this synced folder belongs to a different verified user; only that user can run agents as it or read its runs, chats and memory';
37
95
  export const APPROVALS_FAILED = 'Standing approvals could not be read or changed. The daemon log has the reason.';
38
96
  export const APPROVALS_NO_USER = 'In team mode standing approvals need a verified user on this connection.';
39
97
  export const APPROVALS_NAME_AGENT = 'Name the agent: in team mode standing approvals belong to one agent, and only its owner can revoke them.';
@@ -66,6 +124,11 @@ export const AGENT_CRED_FAILED = 'Agent credentials could not be checked or chan
66
124
  export const AGENT_CRED_LOGIN_FAILED = 'The sign-in did not finish. The daemon log has the reason.';
67
125
  export const AGENT_CRED_SOLO = 'This daemon does not keep agent access.';
68
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']);
69
132
  export const SESSION_REFUSED = 'In team mode a session is reachable only by the member who created it, or the daemon admin.';
70
133
  export const SNAPSHOT_REFUSED = 'In team mode you can restore only from a snapshot of a session you created.';
71
134
  export const SNAPSHOT_NAME_REFUSED = 'In team mode a snapshot name is chosen by the daemon; only the daemon admin can name one.';
@@ -115,6 +178,11 @@ const AGENT_CRED_RESERVED_SERVERS = new Set(['__proto__', 'constructor', 'protot
115
178
  function logSafe(s) {
116
179
  return s.replace(/[\x00-\x1f\x7f-\x9f]/g, '');
117
180
  }
181
+ /** The MCP admin code a thrown refusal carries, when it is one a reply may name. */
182
+ function mcpAdminCodeOf(err) {
183
+ const code = typeof err === 'object' && err !== null ? err.code : undefined;
184
+ return typeof code === 'string' && MCP_ADMIN_ERROR_CODES.includes(code) ? code : null;
185
+ }
118
186
  /** The code a thrown refusal carries, when it is one a reply may name. */
119
187
  function agentCredCodeOf(err) {
120
188
  const code = typeof err === 'object' && err !== null ? err.code : undefined;
@@ -787,6 +855,23 @@ export class SidecarServer {
787
855
  writable: true,
788
856
  value: void 0
789
857
  });
858
+ /** This connection sent `attendance-leave`: it is detached for good and
859
+ * never attached again, and an answer it sends is dropped. */
860
+ Object.defineProperty(this, "leftAttendance", {
861
+ enumerable: true,
862
+ configurable: true,
863
+ writable: true,
864
+ value: false
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
+ });
790
875
  /** This connection's seat for chat ask_user (verified connections only),
791
876
  * and its leave function, called once on close. */
792
877
  Object.defineProperty(this, "askSeat", {
@@ -876,6 +961,14 @@ export class SidecarServer {
876
961
  writable: true,
877
962
  value: new Set()
878
963
  });
964
+ /** This connection's open fleet workflow calls by request id: aborted by `fleet-workflow-cancel` and,
965
+ * all of them, when the connection closes. */
966
+ Object.defineProperty(this, "fleetCalls", {
967
+ enumerable: true,
968
+ configurable: true,
969
+ writable: true,
970
+ value: new Map()
971
+ });
879
972
  /** Processes THIS connection is watching. A shared process table means two
880
973
  * browsers can be paired to one daemon, and a client that never asked about a
881
974
  * process should not be streamed its output - so output is fanned out per
@@ -1142,6 +1235,10 @@ export class SidecarServer {
1142
1235
  for (const detach of this.runDetachers)
1143
1236
  detach();
1144
1237
  this.runDetachers.clear();
1238
+ // A fleet workflow call has no one left to answer: stop every open one.
1239
+ for (const call of this.fleetCalls.values())
1240
+ call.abort();
1241
+ this.fleetCalls.clear();
1145
1242
  offRoots?.();
1146
1243
  offRooms?.();
1147
1244
  offFsChanges?.();
@@ -1265,7 +1362,7 @@ export class SidecarServer {
1265
1362
  }
1266
1363
  // A chat turn (Harness Chat Plan 2) takes its own path: the daemon's chat port, never runner.run.
1267
1364
  if (m.chat !== undefined) {
1268
- this.onChatRun(m);
1365
+ void this.onChatRun(m);
1269
1366
  return;
1270
1367
  }
1271
1368
  void this.onRun(m);
@@ -1327,6 +1424,10 @@ export class SidecarServer {
1327
1424
  void this.onAcpStart(m);
1328
1425
  return;
1329
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
+ }
1330
1431
  void this.onAcpSetMode(m);
1331
1432
  return;
1332
1433
  case 'acp-prompt':
@@ -1348,10 +1449,45 @@ export class SidecarServer {
1348
1449
  // answer is dropped silently rather than forwarded into the registry.
1349
1450
  if (!this.authed)
1350
1451
  return;
1452
+ // A connection that left attendance is shown no asks, so answers none.
1453
+ if (this.leftAttendance)
1454
+ return;
1351
1455
  // The note is the client's text: cleaned HERE, at the daemon's edge,
1352
1456
  // and only a deny carries one.
1353
1457
  this.opts.attendance?.answer(m.id, m.decision, this.attended, m.decision === 'deny' ? denialNote(m.note) : undefined);
1354
1458
  return;
1459
+ case 'attendance-leave':
1460
+ /* A client keeping this connection open only for its own calls (a
1461
+ * browser that selected another machine but still has work running
1462
+ * on this one). Detached now, so an ask it would never show falls to
1463
+ * the unattended policy as if it had disconnected, instead of
1464
+ * waiting on it. Auth-gated like permission-answer; no reply. */
1465
+ if (!this.authed)
1466
+ return;
1467
+ this.leftAttendance = true;
1468
+ this.attendedDetach?.();
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);
1490
+ return;
1355
1491
  case 'roots-browse':
1356
1492
  // Auth-gated like every other request: directory enumeration must never
1357
1493
  // precede authentication. Failures answer ok:false rather than throwing
@@ -1381,6 +1517,10 @@ export class SidecarServer {
1381
1517
  this.ch.send({ t: 'proc-error', id: m.id, error: 'not authenticated' });
1382
1518
  return;
1383
1519
  }
1520
+ if (this.commandRefusal()) {
1521
+ this.ch.send({ t: 'proc-error', id: m.id, error: COMMANDS_ADMIN_ONLY });
1522
+ return;
1523
+ }
1384
1524
  void this.onProcSpawn(m);
1385
1525
  return;
1386
1526
  case 'proc-attach':
@@ -1388,6 +1528,10 @@ export class SidecarServer {
1388
1528
  this.ch.send({ t: 'proc-attach-result', id: m.id, ok: false, error: 'not authenticated' });
1389
1529
  return;
1390
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
+ }
1391
1535
  this.onProcAttach(m);
1392
1536
  return;
1393
1537
  case 'proc-input':
@@ -1395,6 +1539,10 @@ export class SidecarServer {
1395
1539
  this.ch.send({ t: 'proc-input-result', id: m.id, ok: false, error: 'not authenticated' });
1396
1540
  return;
1397
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
+ }
1398
1546
  this.onProcSimple(m.id, 'proc-input-result', () => this.requireProcs().write(m.procId, m.text));
1399
1547
  return;
1400
1548
  case 'proc-kill':
@@ -1402,6 +1550,10 @@ export class SidecarServer {
1402
1550
  this.ch.send({ t: 'proc-kill-result', id: m.id, ok: false, error: 'not authenticated' });
1403
1551
  return;
1404
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
+ }
1405
1557
  this.onProcSimple(m.id, 'proc-kill-result', () => this.requireProcs().kill(m.procId));
1406
1558
  return;
1407
1559
  case 'stt-start': {
@@ -1470,7 +1622,8 @@ export class SidecarServer {
1470
1622
  // An empty list is the honest answer for a daemon without the capability:
1471
1623
  // it is running nothing, and an error here would make the common "what is
1472
1624
  // running?" probe noisy on every sidecar that never allowed shell.
1473
- if (!this.authed) {
1625
+ // A team member is shown none: the table holds other people's commands.
1626
+ if (!this.authed || this.commandRefusal()) {
1474
1627
  this.ch.send({ t: 'proc-list-result', id: m.id, procs: [] });
1475
1628
  return;
1476
1629
  }
@@ -1570,6 +1723,74 @@ export class SidecarServer {
1570
1723
  }
1571
1724
  void this.onCrewDeploy(m);
1572
1725
  return;
1726
+ case 'fleet-items':
1727
+ if (!this.authed) {
1728
+ this.ch.send({ t: 'fleet-items-result', id: m.id, reply: { ok: false, error: 'not authenticated' } });
1729
+ return;
1730
+ }
1731
+ void this.onFleetItems(m);
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;
1768
+ case 'fleet-skill-catalog':
1769
+ if (!this.authed) {
1770
+ this.ch.send({ t: 'fleet-skill-catalog-result', id: m.id, reply: { ok: false, error: 'not authenticated' } });
1771
+ return;
1772
+ }
1773
+ void this.onFleetSkillCatalog(m);
1774
+ return;
1775
+ case 'fleet-workflow-catalog':
1776
+ if (!this.authed) {
1777
+ this.ch.send({ t: 'fleet-workflow-catalog-result', id: m.id, reply: { ok: false, error: 'not authenticated' } });
1778
+ return;
1779
+ }
1780
+ void this.onFleetWorkflowCatalog(m);
1781
+ return;
1782
+ case 'fleet-workflow-call':
1783
+ if (!this.authed) {
1784
+ this.ch.send({ t: 'fleet-workflow-call-result', id: m.id, reply: { ok: false, error: 'not authenticated' } });
1785
+ return;
1786
+ }
1787
+ void this.onFleetWorkflowCall(m);
1788
+ return;
1789
+ case 'fleet-workflow-cancel':
1790
+ // No reply exists; an id this connection has no open call under is ignored.
1791
+ if (typeof m.id === 'number')
1792
+ this.fleetCalls.get(m.id)?.abort();
1793
+ return;
1573
1794
  case 'workflow-package-put':
1574
1795
  case 'workflow-results-list':
1575
1796
  case 'workflow-results-take':
@@ -1766,6 +1987,7 @@ export class SidecarServer {
1766
1987
  case 'mcp-remove':
1767
1988
  case 'mcp-set-credential':
1768
1989
  case 'mcp-login':
1990
+ case 'mcp-allow-set':
1769
1991
  if (!this.authed) {
1770
1992
  this.ch.send({ t: 'mcp-admin-error', id: m.id, error: 'not authenticated' });
1771
1993
  return;
@@ -2495,48 +2717,13 @@ export class SidecarServer {
2495
2717
  this.ch.send({ t: 'ask-user-resolved', id, by }); },
2496
2718
  };
2497
2719
  this.askSeat = seat;
2498
- this.askSeatLeave = this.askUsers().join(seat);
2499
- }
2500
- if (this.opts.attendance && !isPeerDaemon) {
2501
- /* A pending member (Part E) is never a recipient of anything: every
2502
- * send below checks the status at the moment it is made, so a member
2503
- * approved later starts receiving without reconnecting. */
2504
- const attended = {
2505
- // Shaped for THIS connection (E5.2): the requester's own copy names nobody.
2506
- send: (ask) => { if (!this.memberPending())
2507
- this.ch.send({ t: 'permission-ask', ...askForViewer(ask, this.verifiedUserId) }); },
2508
- resolved: (id, decision, by, byClientId) => {
2509
- if (!this.memberPending())
2510
- this.ch.send({ t: 'permission-resolved', id, decision, by, ...(byClientId ? { byClientId } : {}) });
2511
- },
2512
- // Team mode: filtered by role (Task 16b). The registry calls this for
2513
- // the live broadcast AND for the replay at attach, so both are covered.
2514
- // An active member still gets only what their role allows.
2515
- runActivity: (activity) => { if (!this.memberPending())
2516
- this.sendRunActivity(activity); },
2517
- registrations: (registrations) => { if (!this.memberPending())
2518
- this.ch.send({ t: 'registrations', registrations }); },
2519
- approvalSuggestion: (s) => { if (!this.memberPending())
2520
- this.ch.send({ t: 'approvals-suggest', ...s }); },
2521
- };
2522
- // Retained so `permission-answer` can name THIS connection as the
2523
- // answerer -- the registry identifies an answerer by the object it
2524
- // attached, not by anything the answer message claims.
2525
- this.attended = attended;
2526
- /* The identity is what routes an ask under `--mode team`. `clientId` is
2527
- * the key this connection was FILED under (mapped through `clientKey`
2528
- * where the daemon supplies one), i.e. the same value its RunIdentity
2529
- * carries -- which is what makes a run's owner and its ask's recipient
2530
- * the same connection. `accounts` are CLAIMED, carried for display, and
2531
- * widen nothing; only `verifiedUserId` (proven at pairing) widens a
2532
- * delivery to the same user's other devices. See the registry's
2533
- * `recipients`. */
2534
- this.attendedDetach = this.opts.attendance.attach(attended, {
2535
- clientId: this.clientId ?? '',
2536
- accounts,
2537
- ...(this.verifiedUserId ? { verifiedUserId: this.verifiedUserId } : {}),
2538
- });
2720
+ // Seated only while attending: a leave during the hello holds here too.
2721
+ if (!this.leftAttendance)
2722
+ this.askSeatLeave = this.askUsers().join(seat);
2539
2723
  }
2724
+ // A leave that arrived while this hello was still being checked holds.
2725
+ if (!isPeerDaemon && !this.leftAttendance)
2726
+ this.attachAttendance();
2540
2727
  /* The registry replays in-flight runs at attach, and the client relies on
2541
2728
  * that replay landing BEFORE `welcome` (it prunes the runs it still holds
2542
2729
  * as stale once welcomed; see pruneStaleDaemonRuns). Team mode filters the
@@ -2567,6 +2754,53 @@ export class SidecarServer {
2567
2754
  }
2568
2755
  this.sendWelcome(dirName);
2569
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
+ }
2570
2804
  /** Waits for this connection's `identity-assert`, with NO timeout of its
2571
2805
  * own -- the overall bound is `awaitAndVerifyIdentity`'s, covering this wait
2572
2806
  * AND the verify call together, so the whole leg is bounded at
@@ -2677,6 +2911,7 @@ export class SidecarServer {
2677
2911
  }
2678
2912
  }
2679
2913
  sendWelcome(dirName) {
2914
+ this.welcomed = true;
2680
2915
  /* Remembered BEFORE the welcome goes out, because `self` and the
2681
2916
  * forget-your-own-tree refusal are both answered from it. A daemon too old
2682
2917
  * to resolve one, or a client with no identity, leaves it undefined -- and
@@ -2742,6 +2977,8 @@ export class SidecarServer {
2742
2977
  ...(this.opts.runs ? [CHILD_EVENTS_FEATURE] : []),
2743
2978
  // Whether a write another client makes reaches this one as fs-changed.
2744
2979
  ...(this.opts.fsChanges ? [FS_CHANGED_FEATURE] : []),
2980
+ // A connection may stop attending while it stays open (attendance-leave).
2981
+ ...(this.opts.attendance ? [ATTENDANCE_LEAVE_FEATURE, ATTENDANCE_JOIN_FEATURE] : []),
2745
2982
  ...(this.opts.acp ? ['acp'] : []),
2746
2983
  ...(this.opts.roots?.areas.length ? ['add-roots'] : []),
2747
2984
  ...(this.opts.roots?.areas.length && this.opts.roots.create ? ['create-roots'] : []),
@@ -2771,6 +3008,14 @@ export class SidecarServer {
2771
3008
  // Undeploy only where the daemon would record them -- anywhere else a
2772
3009
  // client shows the old read-only view.
2773
3010
  ...(this.opts.crewDeploy ? [CREW_DEPLOY_FEATURE] : []),
3011
+ // Whether this sidecar lists its fleet items. Only with a real port.
3012
+ ...(this.opts.fleetItems ? [FLEET_ITEMS_FEATURE] : []),
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] : []),
2774
3019
  // Whether this daemon runs deployed workflows. Only with a real port, so
2775
3020
  // a client offers the Sidecar deploy target only where it would run.
2776
3021
  ...(this.opts.workflows ? [WORKFLOW_RUNNER_FEATURE] : []),
@@ -2803,6 +3048,7 @@ export class SidecarServer {
2803
3048
  // gives regardless of this flag.
2804
3049
  ...(this.opts.mcp ? ['mcp'] : []),
2805
3050
  ...(this.opts.mcpAdmin ? ['mcp-admin'] : []),
3051
+ ...(this.opts.mcpAdmin?.setAllow ? [MCP_ALLOW_FEATURE] : []),
2806
3052
  // Whether this daemon serves its own Harness Config. Advertised only with
2807
3053
  // a real (allowlisted) service behind it, so a client never shows a
2808
3054
  // daemon's definitions as an empty workspace when what it actually has is
@@ -3133,6 +3379,41 @@ export class SidecarServer {
3133
3379
  this.ch.send({ t: 'invoke-result', id: m.id, ok: false, error: `unknown tool ${m.name}` });
3134
3380
  return;
3135
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
+ }
3136
3417
  /* MUTATIONS ARE LOUD, READS ARE NOT.
3137
3418
  *
3138
3419
  * A single agent run issues hundreds of `fs_read_file` / `fs_list_dir`
@@ -3423,6 +3704,11 @@ export class SidecarServer {
3423
3704
  */
3424
3705
  async onRunHistory(m) {
3425
3706
  try {
3707
+ const denied = await this.hostFolderDenial();
3708
+ if (denied !== null) {
3709
+ this.ch.send({ t: 'run-history-result', id: m.id, runs: [], error: denied });
3710
+ return;
3711
+ }
3426
3712
  const history = this.opts.runner?.history?.bind(this.opts.runner);
3427
3713
  const crew = runHistoryCrewOf(m.crew);
3428
3714
  const identity = { clientId: this.clientId };
@@ -3445,6 +3731,11 @@ export class SidecarServer {
3445
3731
  * case, and a state the caller renders rather than treats as a failure. */
3446
3732
  async onRunTranscript(m) {
3447
3733
  try {
3734
+ const denied = await this.hostFolderDenial();
3735
+ if (denied !== null) {
3736
+ this.ch.send({ t: 'run-transcript-result', id: m.id, lines: [], error: denied });
3737
+ return;
3738
+ }
3448
3739
  const transcript = this.opts.runner?.transcript?.bind(this.opts.runner);
3449
3740
  const identity = { clientId: this.clientId };
3450
3741
  const lines = !transcript ? []
@@ -3463,6 +3754,11 @@ export class SidecarServer {
3463
3754
  * a path. */
3464
3755
  async onCrewMemory(m) {
3465
3756
  try {
3757
+ const denied = await this.hostFolderDenial();
3758
+ if (denied !== null) {
3759
+ this.ch.send({ t: 'crew-memory-result', id: m.id, memories: [], error: denied });
3760
+ return;
3761
+ }
3466
3762
  const runner = this.opts.runner;
3467
3763
  // The viewer is THIS connection's verified user (team agent access).
3468
3764
  const memories = !runner?.memories ? []
@@ -3490,6 +3786,13 @@ export class SidecarServer {
3490
3786
  return;
3491
3787
  }
3492
3788
  }
3789
+ // The connection's host folder is the run's owner root (and the `via` of a daemon-crew run): it speaks only
3790
+ // for its bound verified user (hostFolderDenial).
3791
+ const folderDenied = await this.hostFolderDenial();
3792
+ if (folderDenied !== null) {
3793
+ this.ch.send({ t: 'run-error', id: m.id, error: folderDenied });
3794
+ return;
3795
+ }
3493
3796
  /* WHERE the run happens, decided once, up front.
3494
3797
  *
3495
3798
  * A `sessionId` is a confinement request. A daemon that cannot honour it
@@ -3603,7 +3906,7 @@ export class SidecarServer {
3603
3906
  * verified user and never a message field; with none it is refused. The
3604
3907
  * events, `run-accepted` (with the conversation id), `run-done` and the hub
3605
3908
  * publishing follow `onRun`. A refused or failed turn sends `run-error`. */
3606
- onChatRun(m) {
3909
+ async onChatRun(m) {
3607
3910
  const refuse = (error) => { this.ch.send({ t: 'run-error', id: m.id, error }); };
3608
3911
  const port = this.opts.runner?.chat;
3609
3912
  if (!port) {
@@ -3632,6 +3935,15 @@ export class SidecarServer {
3632
3935
  refuse(spec);
3633
3936
  return;
3634
3937
  }
3938
+ // Team mode only: the chat's identity is this connection's host folder (hostFolderDenial). Solo mode reaches
3939
+ // the port without waiting, as before.
3940
+ if (this.opts.teamMode) {
3941
+ const denied = await this.hostFolderDenial();
3942
+ if (denied !== null) {
3943
+ refuse(denied);
3944
+ return;
3945
+ }
3946
+ }
3635
3947
  const agent = m.agent;
3636
3948
  const hub = this.opts.runs;
3637
3949
  let runId;
@@ -3772,6 +4084,11 @@ export class SidecarServer {
3772
4084
  }
3773
4085
  const { port, ...rest } = q;
3774
4086
  try {
4087
+ const denied = await this.hostFolderDenial();
4088
+ if (denied !== null) {
4089
+ this.ch.send({ t: 'chat-list-result', id: m.id, ok: false, error: denied });
4090
+ return;
4091
+ }
3775
4092
  const chats = await port.list({ ...rest, identity: { clientId: this.clientId } });
3776
4093
  this.ch.send({ t: 'chat-list-result', id: m.id, ok: true, chats: chats.map(chatSummaryOf) });
3777
4094
  }
@@ -3795,6 +4112,11 @@ export class SidecarServer {
3795
4112
  }
3796
4113
  const { port, ...rest } = q;
3797
4114
  try {
4115
+ const denied = await this.hostFolderDenial();
4116
+ if (denied !== null) {
4117
+ refuse(denied);
4118
+ return;
4119
+ }
3798
4120
  const conversation = await port.get({ ...rest, identity: { clientId: this.clientId }, conversationId: m.conversationId });
3799
4121
  this.ch.send({ t: 'chat-get-result', id: m.id, ok: true, conversation: storedChatOf(conversation) });
3800
4122
  }
@@ -4104,6 +4426,287 @@ export class SidecarServer {
4104
4426
  ...(this.verifiedUserId !== undefined ? { verifiedUserId: this.verifiedUserId } : {}),
4105
4427
  };
4106
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
+ }
4589
+ async onFleetItems(m) {
4590
+ if (typeof m.id !== 'number' || !Number.isSafeInteger(m.id))
4591
+ return;
4592
+ const answer = (reply) => this.ch.send({ t: 'fleet-items-result', id: m.id, reply });
4593
+ const port = this.opts.fleetItems;
4594
+ if (!port) {
4595
+ answer({ ok: false, error: 'this sidecar is not in a fleet' });
4596
+ return;
4597
+ }
4598
+ try {
4599
+ answer(await port.list(this.verifiedUserId ?? undefined));
4600
+ }
4601
+ catch (err) {
4602
+ answer({ ok: false, error: errMessage(err) });
4603
+ }
4604
+ }
4605
+ /** The caller a fleet workflow call runs as, from THIS connection only, or the refusal. Never the verified user
4606
+ * id (it would reach the workflow's prompts).
4607
+ *
4608
+ * Team mode: `host:<dirName>` only when that folder is bound to this connection's verified user (hostFolderDenial,
4609
+ * the rule the workflow-* messages use): the folder comes from the hello's clientId, which is not secret, so an
4610
+ * unbound name is refused with that check's denial. Solo mode: `host:<dirName>` for a
4611
+ * verified user whose folder was resolved, else `host:owner`. */
4612
+ async fleetCaller() {
4613
+ if (this.opts.teamMode) {
4614
+ if (this.verifiedUserId !== undefined && this.dirName === undefined)
4615
+ return { error: FLEET_CALLER_NO_FOLDER };
4616
+ const denied = await this.hostFolderDenial();
4617
+ if (denied !== null || this.dirName === undefined)
4618
+ return { error: denied ?? FLEET_CALLER_NO_FOLDER };
4619
+ return { caller: { name: `host:${this.dirName}`, trigger: 'host' } };
4620
+ }
4621
+ if (this.verifiedUserId !== undefined && this.dirName !== undefined)
4622
+ return { caller: { name: `host:${this.dirName}`, trigger: 'host' } };
4623
+ return { caller: { name: 'host:owner', trigger: 'host' } };
4624
+ }
4625
+ async onFleetSkillCatalog(m) {
4626
+ if (typeof m.id !== 'number' || !Number.isSafeInteger(m.id))
4627
+ return;
4628
+ const answer = (reply) => this.ch.send({ t: 'fleet-skill-catalog-result', id: m.id, reply });
4629
+ const port = this.opts.fleetUse;
4630
+ if (!port) {
4631
+ answer({ ok: false, error: 'this sidecar is not in a fleet' });
4632
+ return;
4633
+ }
4634
+ try {
4635
+ answer(await port.skills());
4636
+ }
4637
+ catch (err) {
4638
+ answer({ ok: false, error: errMessage(err) });
4639
+ }
4640
+ }
4641
+ async onFleetWorkflowCatalog(m) {
4642
+ if (typeof m.id !== 'number' || !Number.isSafeInteger(m.id))
4643
+ return;
4644
+ const answer = (reply) => this.ch.send({ t: 'fleet-workflow-catalog-result', id: m.id, reply });
4645
+ const port = this.opts.fleetUse;
4646
+ if (!port) {
4647
+ answer({ ok: false, error: 'this sidecar is not in a fleet' });
4648
+ return;
4649
+ }
4650
+ try {
4651
+ answer(await port.workflows());
4652
+ }
4653
+ catch (err) {
4654
+ answer({ ok: false, error: errMessage(err) });
4655
+ }
4656
+ }
4657
+ async onFleetWorkflowCall(m) {
4658
+ // A frame with no usable correlation id is dropped before the port: nothing runs that its sender could
4659
+ // never match to an answer or cancel.
4660
+ if (typeof m.id !== 'number' || !Number.isSafeInteger(m.id))
4661
+ return;
4662
+ const id = m.id;
4663
+ const answer = (reply) => {
4664
+ if (!this.closed)
4665
+ this.ch.send({ t: 'fleet-workflow-call-result', id, reply });
4666
+ };
4667
+ const port = this.opts.fleetUse;
4668
+ if (!port) {
4669
+ answer({ ok: false, error: 'this sidecar is not in a fleet' });
4670
+ return;
4671
+ }
4672
+ // Runtime-typed: the message arrived via JSON.parse(...) as SidecarMsg.
4673
+ const name = m.name;
4674
+ if (typeof name !== 'string' || name.length === 0) {
4675
+ answer({ ok: false, error: 'a fleet workflow call needs a workflow name' });
4676
+ return;
4677
+ }
4678
+ const inputs = m.inputs;
4679
+ if (!isWorkflowCallInputs(inputs)) {
4680
+ answer({ ok: false, error: FLEET_WORKFLOW_INPUTS_ERROR });
4681
+ return;
4682
+ }
4683
+ if (this.fleetCalls.has(id)) {
4684
+ answer({ ok: false, error: 'a fleet workflow call with that id is already open' });
4685
+ return;
4686
+ }
4687
+ if (this.fleetCalls.size >= FLEET_CALLS_OPEN_MAX) {
4688
+ answer({ ok: false, error: FLEET_CALLS_TOO_MANY });
4689
+ return;
4690
+ }
4691
+ // The id is held before the first await, so a second call under it, or a cancel, sees it.
4692
+ const control = new AbortController();
4693
+ this.fleetCalls.set(id, control);
4694
+ try {
4695
+ const named = await this.fleetCaller();
4696
+ if ('error' in named) {
4697
+ answer({ ok: false, error: named.error });
4698
+ return;
4699
+ }
4700
+ answer(await port.call(named.caller, name, inputs, control.signal));
4701
+ }
4702
+ catch (err) {
4703
+ answer({ ok: false, error: errMessage(err) });
4704
+ }
4705
+ finally {
4706
+ if (this.fleetCalls.get(id) === control)
4707
+ this.fleetCalls.delete(id);
4708
+ }
4709
+ }
4107
4710
  async onCrewDeploy(m) {
4108
4711
  // A frame with no usable correlation id is dropped before the port: no
4109
4712
  // change is made that its sender could never match to an answer.
@@ -4145,25 +4748,49 @@ export class SidecarServer {
4145
4748
  answer({ ok: false, error: errMessage(err) });
4146
4749
  }
4147
4750
  }
4148
- /** Team mode: why this connection may NOT use its synced folder's workflow
4149
- * packages and results, or null when it may (the caller's verified id equals
4150
- * the folder's bound owner). A lookup that throws refuses. */
4151
- async workflowsTeamDenial() {
4751
+ /** THE ONE RULE for whether this connection's synced folder speaks for it: 'bound' only when the folder's
4752
+ * bound owner (the deploy binding, `workflowsFolderOwner`) is this connection's verified user. A folder the
4753
+ * daemon did not resolve, no lookup, no binding, or a lookup that throws is 'unbound'. Every team-mode check of
4754
+ * a host folder (workflowsTeamDenial, hostFolderDenial) reads this and only words it differently. */
4755
+ async hostFolderStanding() {
4152
4756
  if (this.verifiedUserId === undefined)
4153
- return WORKFLOWS_VERIFY_FIRST;
4757
+ return 'unverified';
4154
4758
  const lookup = this.opts.workflowsFolderOwner;
4155
4759
  if (this.dirName === undefined || !lookup)
4156
- return WORKFLOWS_FOLDER_UNBOUND;
4760
+ return 'unbound';
4157
4761
  let bound;
4158
4762
  try {
4159
4763
  bound = await lookup(this.dirName);
4160
4764
  }
4161
4765
  catch {
4162
- return WORKFLOWS_FOLDER_UNBOUND;
4766
+ return 'unbound';
4163
4767
  }
4164
4768
  if (bound === undefined)
4165
- return WORKFLOWS_FOLDER_UNBOUND;
4166
- return bound === this.verifiedUserId ? null : WORKFLOWS_NOT_FOLDER_OWNER;
4769
+ return 'unbound';
4770
+ return bound === this.verifiedUserId ? 'bound' : 'not-owner';
4771
+ }
4772
+ /** Team mode: why this connection may NOT use its synced folder's workflow
4773
+ * packages and results, or null when it may (hostFolderStanding). */
4774
+ async workflowsTeamDenial() {
4775
+ switch (await this.hostFolderStanding()) {
4776
+ case 'bound': return null;
4777
+ case 'unverified': return WORKFLOWS_VERIFY_FIRST;
4778
+ case 'unbound': return WORKFLOWS_FOLDER_UNBOUND;
4779
+ case 'not-owner': return WORKFLOWS_NOT_FOLDER_OWNER;
4780
+ }
4781
+ }
4782
+ /** Why this connection's host folder may NOT speak for it in a run, a chat, a history, transcript or memory
4783
+ * read, a fleet workflow call, or a host-sync write, or null when it may. Solo mode: always null (unchanged). Team mode:
4784
+ * hostFolderStanding, the rule the workflow-* messages use. */
4785
+ async hostFolderDenial() {
4786
+ if (!this.opts.teamMode)
4787
+ return null;
4788
+ switch (await this.hostFolderStanding()) {
4789
+ case 'bound': return null;
4790
+ case 'unverified': return HOST_FOLDER_VERIFY_FIRST;
4791
+ case 'unbound': return HOST_FOLDER_UNBOUND;
4792
+ case 'not-owner': return HOST_FOLDER_NOT_OWNER;
4793
+ }
4167
4794
  }
4168
4795
  async onWorkflows(m) {
4169
4796
  // A frame with no usable correlation id is dropped before the port: no
@@ -4933,7 +5560,7 @@ export class SidecarServer {
4933
5560
  if (m.t !== 'mcp-servers-list') {
4934
5561
  const refusal = this.mcpRefusal();
4935
5562
  if (refusal) {
4936
- this.ch.send({ t: 'mcp-admin-error', id: m.id, error: refusal });
5563
+ this.ch.send({ t: 'mcp-admin-error', id: m.id, error: refusal, code: 'MCP_ADMIN_ONLY' });
4937
5564
  return;
4938
5565
  }
4939
5566
  }
@@ -4954,8 +5581,9 @@ export class SidecarServer {
4954
5581
  * branch that is not part of it runs setCredential too, with whatever
4955
5582
  * `m.header`/`m.value` happen to be (undefined, for every other message).
4956
5583
  * That is exactly what a stray `if` here did before this comment existed. */
5584
+ let applied;
4957
5585
  if (m.t === 'mcp-add')
4958
- await admin.addServer(m.name, m.url, m.hosted === true, m.authUrl);
5586
+ applied = await admin.addServer(m.name, m.url, m.hosted === true, m.authUrl);
4959
5587
  else if (m.t === 'mcp-login') {
4960
5588
  /* The consent URL is pushed the moment it exists, BEFORE the flow
4961
5589
  * blocks waiting for a human. Holding it until the end would deliver it
@@ -4965,15 +5593,48 @@ export class SidecarServer {
4965
5593
  this.ch.send({ t: 'mcp-admin-error', id: m.id, error: out.message, reason: out.reason });
4966
5594
  return;
4967
5595
  }
5596
+ applied = out.reloadError !== undefined ? { reloadError: out.reloadError } : undefined;
4968
5597
  }
4969
5598
  else if (m.t === 'mcp-remove')
4970
- await admin.removeServer(m.name);
5599
+ applied = await admin.removeServer(m.name);
5600
+ else if (m.t === 'mcp-allow-set') {
5601
+ if (!admin.setAllow) {
5602
+ this.ch.send({ t: 'mcp-admin-error', id: m.id, error: 'this daemon cannot switch MCP tools -- update it and reconnect' });
5603
+ return;
5604
+ }
5605
+ // The shape is checked here, before the port: the message is a
5606
+ // browser's, and a non-string entry must never reach daemon.json.
5607
+ if (typeof m.name !== 'string' || !m.name || !Array.isArray(m.tools) || !m.tools.every((t) => typeof t === 'string')) {
5608
+ this.ch.send({ t: 'mcp-admin-error', id: m.id, code: 'BAD_REQUEST', error: 'mcp-allow-set needs a server name and a list of tool names.' });
5609
+ return;
5610
+ }
5611
+ applied = await admin.setAllow(m.name, m.tools);
5612
+ }
4971
5613
  else
4972
- await admin.setCredential(m.name, m.header, m.value);
4973
- this.ch.send({ t: 'mcp-admin-ok', id: m.id });
5614
+ applied = await admin.setCredential(m.name, m.header, m.value);
5615
+ const reloadError = applied ? applied.reloadError : undefined;
5616
+ /* A switch answers with the rows as they now stand, so the browser shows
5617
+ * what the daemon allows (a glob may keep a tool on), not what it asked
5618
+ * for. Only the admin reaches here (mcpRefusal above), so the full rows.
5619
+ * Best effort: the write is saved either way. */
5620
+ let servers;
5621
+ if (m.t === 'mcp-allow-set') {
5622
+ try {
5623
+ servers = await admin.listServers();
5624
+ }
5625
+ catch {
5626
+ servers = undefined;
5627
+ }
5628
+ }
5629
+ this.ch.send({
5630
+ t: 'mcp-admin-ok', id: m.id,
5631
+ ...(typeof reloadError === 'string' ? { reloadError } : {}),
5632
+ ...(servers ? { servers } : {}),
5633
+ });
4974
5634
  }
4975
5635
  catch (e) {
4976
- this.ch.send({ t: 'mcp-admin-error', id: m.id, error: e instanceof Error ? e.message : String(e) });
5636
+ const code = mcpAdminCodeOf(e);
5637
+ this.ch.send({ t: 'mcp-admin-error', id: m.id, error: e instanceof Error ? e.message : String(e), ...(code ? { code } : {}) });
4977
5638
  }
4978
5639
  }
4979
5640
  /** Part D credential messages. The caller is this connection's VERIFIED
@@ -5801,6 +6462,41 @@ export class SidecarServer {
5801
6462
  return MCP_ADMIN_ONLY;
5802
6463
  }
5803
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
+ }
5804
6500
  async onRoomFindings(m) {
5805
6501
  if (!this.opts.rooms) {
5806
6502
  // A daemon without the capability hosts no rooms, so it holds no
@@ -5853,8 +6549,12 @@ export class SidecarServer {
5853
6549
  this.ch.send({ t: 'config-result', id: m.id, ok: false, error: gate.refusal });
5854
6550
  return;
5855
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();
5856
6556
  if (m.t === 'config-list') {
5857
- const entries = await svc.list(m.path, m.depth);
6557
+ const entries = await svc.list(m.path, m.depth, scope);
5858
6558
  const shown = await this.visibleConfigEntries(svc, m.path, entries);
5859
6559
  this.ch.send(shown
5860
6560
  ? { t: 'config-result', id: m.id, ok: true, entries: shown }
@@ -5862,18 +6562,18 @@ export class SidecarServer {
5862
6562
  return;
5863
6563
  }
5864
6564
  if (m.t === 'config-read') {
5865
- 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) });
5866
6566
  return;
5867
6567
  }
5868
6568
  if (m.t === 'config-write')
5869
- await svc.write(m.path, m.content);
6569
+ await svc.write(m.path, m.content, scope);
5870
6570
  else if (m.t === 'config-append') {
5871
6571
  if (!svc.append)
5872
6572
  throw new Error('this sidecar cannot append to its harness config');
5873
- await svc.append(m.path, m.content);
6573
+ await svc.append(m.path, m.content, scope);
5874
6574
  }
5875
6575
  else
5876
- await svc.remove(m.path);
6576
+ await svc.remove(m.path, scope);
5877
6577
  await this.afterConfigChange(gate);
5878
6578
  this.ch.send({ t: 'config-result', id: m.id, ok: true });
5879
6579
  }
@@ -5881,6 +6581,20 @@ export class SidecarServer {
5881
6581
  this.ch.send({ t: 'config-result', id: m.id, ok: false, error: errMessage(e) });
5882
6582
  }
5883
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
+ }
5884
6598
  /** The config path as the filesystem would reach it, for classification:
5885
6599
  * separators, `.`, empty and `..` segments resolved; the `crew` segment, the
5886
6600
  * agent folder (to its on-disk name, read from the service's own listing of
@@ -5969,6 +6683,13 @@ export class SidecarServer {
5969
6683
  this.ch.send({ t: 'host-sync-result', id: m.id, ok: false, error: 'this sidecar cannot sync host config' });
5970
6684
  return;
5971
6685
  }
6686
+ // Team mode: the clientId names the folder written to, and it is not secret, so it writes only for the
6687
+ // verified user that folder is bound to (hostFolderDenial). Solo mode is unchanged.
6688
+ const denied = await this.hostFolderDenial();
6689
+ if (denied !== null) {
6690
+ this.ch.send({ t: 'host-sync-result', id: m.id, ok: false, error: denied });
6691
+ return;
6692
+ }
5972
6693
  try {
5973
6694
  await this.opts.hostSync.put(identity.clientId, m.path, m.content);
5974
6695
  this.ch.send({ t: 'host-sync-result', id: m.id, ok: true });
@@ -5987,6 +6708,13 @@ export class SidecarServer {
5987
6708
  this.ch.send({ t: 'host-sync-result', id: m.id, ok: false, error: 'this sidecar cannot sync host config' });
5988
6709
  return;
5989
6710
  }
6711
+ // Team mode: the clientId names the folder written to, and it is not secret, so it writes only for the
6712
+ // verified user that folder is bound to (hostFolderDenial). Solo mode is unchanged.
6713
+ const denied = await this.hostFolderDenial();
6714
+ if (denied !== null) {
6715
+ this.ch.send({ t: 'host-sync-result', id: m.id, ok: false, error: denied });
6716
+ return;
6717
+ }
5990
6718
  try {
5991
6719
  await this.opts.hostSync.remove(identity.clientId, m.path);
5992
6720
  this.ch.send({ t: 'host-sync-result', id: m.id, ok: true });