@corenel/harness 0.21.0 → 0.23.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 (92) hide show
  1. package/README.md +48 -0
  2. package/dist/chat/engine.d.ts +9 -0
  3. package/dist/chat/engine.d.ts.map +1 -1
  4. package/dist/chat/engine.js +90 -6
  5. package/dist/chat/engine.js.map +1 -1
  6. package/dist/chat/persist.d.ts +8 -0
  7. package/dist/chat/persist.d.ts.map +1 -1
  8. package/dist/chat/persist.js +11 -0
  9. package/dist/chat/persist.js.map +1 -1
  10. package/dist/chat/runWatchdog.d.ts +1 -1
  11. package/dist/chat/runWatchdog.d.ts.map +1 -1
  12. package/dist/chat/runWatchdog.js +1 -1
  13. package/dist/chat/runWatchdog.js.map +1 -1
  14. package/dist/core/loop.d.ts.map +1 -1
  15. package/dist/core/loop.js +134 -78
  16. package/dist/core/loop.js.map +1 -1
  17. package/dist/dag/executeNode.js +3 -3
  18. package/dist/guardrail/guardrail.d.ts +9 -0
  19. package/dist/guardrail/guardrail.d.ts.map +1 -1
  20. package/dist/guardrail/guardrail.js +13 -0
  21. package/dist/guardrail/guardrail.js.map +1 -1
  22. package/dist/guardrail/index.d.ts +3 -3
  23. package/dist/guardrail/index.d.ts.map +1 -1
  24. package/dist/guardrail/index.js +2 -2
  25. package/dist/guardrail/index.js.map +1 -1
  26. package/dist/guardrail/permission-service.d.ts +17 -3
  27. package/dist/guardrail/permission-service.d.ts.map +1 -1
  28. package/dist/guardrail/permission-service.js +11 -6
  29. package/dist/guardrail/permission-service.js.map +1 -1
  30. package/dist/guardrail/store.d.ts +4 -0
  31. package/dist/guardrail/store.d.ts.map +1 -1
  32. package/dist/guardrail/store.js +6 -0
  33. package/dist/guardrail/store.js.map +1 -1
  34. package/dist/prompts/config.d.ts +6 -2
  35. package/dist/prompts/config.d.ts.map +1 -1
  36. package/dist/prompts/config.js +6 -3
  37. package/dist/prompts/config.js.map +1 -1
  38. package/dist/prompts/host.d.ts +3 -2
  39. package/dist/prompts/host.d.ts.map +1 -1
  40. package/dist/prompts/host.js.map +1 -1
  41. package/dist/prompts/index.d.ts +8 -1
  42. package/dist/prompts/index.d.ts.map +1 -1
  43. package/dist/prompts/index.js +9 -7
  44. package/dist/prompts/index.js.map +1 -1
  45. package/dist/prompts/library/defaults.generated.js +2 -2
  46. package/dist/prompts/library/defaults.generated.js.map +1 -1
  47. package/dist/prompts/systemPrompts.d.ts +3 -0
  48. package/dist/prompts/systemPrompts.d.ts.map +1 -1
  49. package/dist/prompts/systemPrompts.js +1 -1
  50. package/dist/prompts/systemPrompts.js.map +1 -1
  51. package/dist/sessions/fileSessionStore.d.ts.map +1 -1
  52. package/dist/sessions/fileSessionStore.js.map +1 -1
  53. package/dist/sidecar/fsChanges.d.ts +23 -0
  54. package/dist/sidecar/fsChanges.d.ts.map +1 -0
  55. package/dist/sidecar/fsChanges.js +77 -0
  56. package/dist/sidecar/fsChanges.js.map +1 -0
  57. package/dist/sidecar/host.d.ts +62 -9
  58. package/dist/sidecar/host.d.ts.map +1 -1
  59. package/dist/sidecar/host.js +243 -6
  60. package/dist/sidecar/host.js.map +1 -1
  61. package/dist/sidecar/memberGate.d.ts.map +1 -1
  62. package/dist/sidecar/memberGate.js +9 -0
  63. package/dist/sidecar/memberGate.js.map +1 -1
  64. package/dist/sidecar/server.d.ts +98 -6
  65. package/dist/sidecar/server.d.ts.map +1 -1
  66. package/dist/sidecar/server.js +408 -19
  67. package/dist/sidecar/server.js.map +1 -1
  68. package/dist/skills.d.ts +9 -1
  69. package/dist/skills.d.ts.map +1 -1
  70. package/dist/skills.js +22 -8
  71. package/dist/skills.js.map +1 -1
  72. package/dist/tools/builtins.d.ts +6 -1
  73. package/dist/tools/builtins.d.ts.map +1 -1
  74. package/dist/tools/builtins.js +13 -2
  75. package/dist/tools/builtins.js.map +1 -1
  76. package/dist/tools/callAgent.d.ts +6 -2
  77. package/dist/tools/callAgent.d.ts.map +1 -1
  78. package/dist/tools/callAgent.js +7 -3
  79. package/dist/tools/callAgent.js.map +1 -1
  80. package/dist/tools/files.d.ts.map +1 -1
  81. package/dist/tools/files.js +4 -0
  82. package/dist/tools/files.js.map +1 -1
  83. package/dist/tools/sessions.d.ts.map +1 -1
  84. package/dist/tools/sessions.js +1 -0
  85. package/dist/tools/sessions.js.map +1 -1
  86. package/dist/tools/web.d.ts.map +1 -1
  87. package/dist/tools/web.js +19 -5
  88. package/dist/tools/web.js.map +1 -1
  89. package/dist/tools/workflowTool.d.ts +2 -18
  90. package/dist/tools/workflowTool.d.ts.map +1 -1
  91. package/dist/tools/workflowTool.js.map +1 -1
  92. package/package.json +3 -3
@@ -1,16 +1,24 @@
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, 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, 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';
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';
5
5
  import { asEcPublicJwk, jwkThumbprint, verifySignature, randomToken, serverProofPayload, clientProofPayload, bytesToB64url, codeProof, sameB64, B64URL_32, } from './deviceKeys.js';
6
6
  import { MEMBER_GATE_ALLOWED, pendingRefusal } from './memberGate.js';
7
7
  import { AskUserBroker } from './askUserBroker.js';
8
+ import { fsChangesFor } from './fsChanges.js';
8
9
  /** A workflow reply's refusal, cut to the length a host accepts
9
10
  * (MAX_WORKFLOW_REPLY_ERROR): a longer one is refused whole by the host's
10
11
  * reply check, and the person would lose the reason. */
11
12
  function clipReplyError(error) {
12
13
  return error.length > MAX_WORKFLOW_REPLY_ERROR ? error.slice(0, MAX_WORKFLOW_REPLY_ERROR) : error;
13
14
  }
15
+ /** The refusal for workflow inputs that fail isWorkflowCallInputs. */
16
+ export const FLEET_WORKFLOW_INPUTS_ERROR = `workflow inputs must be an object of at most ${FLEET_USE_LIMITS.inputsBytes / 1024} KiB`;
17
+ /** At most this many fleet workflow calls open at once on one connection (each loads and verifies its archive). */
18
+ export const FLEET_CALLS_OPEN_MAX = 16;
19
+ export const FLEET_CALLS_TOO_MANY = 'too many fleet workflow calls open';
20
+ /** A team connection whose host folder the daemon has not resolved (yet): it cannot be named. */
21
+ export const FLEET_CALLER_NO_FOLDER = 'this connection has no host folder yet; reconnect and try again';
14
22
  /** A deliberate refusal from the approvals port, written for the person who
15
23
  * asked: its message goes on the wire as is. Recognized by `name`, not by
16
24
  * `instanceof`, so a second copy of this module (a test alias, a bundled
@@ -33,6 +41,13 @@ export function isApprovalsRefusal(e) {
33
41
  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';
34
42
  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';
35
43
  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';
44
+ /* Team mode, everything a connection's host folder speaks for besides workflow-*: its runs (the folder is the
45
+ * owner root and the `via` a daemon-crew run is recorded under), chat turns and reads, run history, transcripts,
46
+ * crew memory, fleet workflow calls and host-sync writes into the folder. The same rule as the workflow-* messages (hostFolderStanding): the folder
47
+ * comes from the hello's clientId, which is not secret, so it speaks only for the verified user it is bound to. */
48
+ 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';
49
+ 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';
50
+ 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';
36
51
  export const APPROVALS_FAILED = 'Standing approvals could not be read or changed. The daemon log has the reason.';
37
52
  export const APPROVALS_NO_USER = 'In team mode standing approvals need a verified user on this connection.';
38
53
  export const APPROVALS_NAME_AGENT = 'Name the agent: in team mode standing approvals belong to one agent, and only its owner can revoke them.';
@@ -99,6 +114,13 @@ function providedMcpUrls(v) {
99
114
  const PEER_DAEMON_MESSAGES = new Set([
100
115
  'hello', 'client-proof', 'identity-assert', 'host-call-result',
101
116
  ]);
117
+ /** The file tools whose success is news for the daemon's other clients (the
118
+ * `fs-changed` broadcast): every tool that writes or removes a file under a
119
+ * root. The fs tools have no rename or mkdir; a write makes its parents. */
120
+ const FS_CHANGE_TOOLS = new Set(['sidecar_fs_write_file', 'sidecar_fs_write_bytes', 'sidecar_fs_delete']);
121
+ /** Gives each connection the id it publishes file changes under, so the bus
122
+ * can tell the writer apart from everyone else. Per process, like the bus. */
123
+ let fsConnectionSeq = 0;
102
124
  /** Server names a credential message may never carry: auth.json is keyed by
103
125
  * server name, and these are Object.prototype's own. */
104
126
  const AGENT_CRED_RESERVED_SERVERS = new Set(['__proto__', 'constructor', 'prototype']);
@@ -107,6 +129,11 @@ const AGENT_CRED_RESERVED_SERVERS = new Set(['__proto__', 'constructor', 'protot
107
129
  function logSafe(s) {
108
130
  return s.replace(/[\x00-\x1f\x7f-\x9f]/g, '');
109
131
  }
132
+ /** The MCP admin code a thrown refusal carries, when it is one a reply may name. */
133
+ function mcpAdminCodeOf(err) {
134
+ const code = typeof err === 'object' && err !== null ? err.code : undefined;
135
+ return typeof code === 'string' && MCP_ADMIN_ERROR_CODES.includes(code) ? code : null;
136
+ }
110
137
  /** The code a thrown refusal carries, when it is one a reply may name. */
111
138
  function agentCredCodeOf(err) {
112
139
  const code = typeof err === 'object' && err !== null ? err.code : undefined;
@@ -779,6 +806,14 @@ export class SidecarServer {
779
806
  writable: true,
780
807
  value: void 0
781
808
  });
809
+ /** This connection sent `attendance-leave`: it is detached for good and
810
+ * never attached again, and an answer it sends is dropped. */
811
+ Object.defineProperty(this, "leftAttendance", {
812
+ enumerable: true,
813
+ configurable: true,
814
+ writable: true,
815
+ value: false
816
+ });
782
817
  /** This connection's seat for chat ask_user (verified connections only),
783
818
  * and its leave function, called once on close. */
784
819
  Object.defineProperty(this, "askSeat", {
@@ -868,6 +903,14 @@ export class SidecarServer {
868
903
  writable: true,
869
904
  value: new Set()
870
905
  });
906
+ /** This connection's open fleet workflow calls by request id: aborted by `fleet-workflow-cancel` and,
907
+ * all of them, when the connection closes. */
908
+ Object.defineProperty(this, "fleetCalls", {
909
+ enumerable: true,
910
+ configurable: true,
911
+ writable: true,
912
+ value: new Map()
913
+ });
871
914
  /** Processes THIS connection is watching. A shared process table means two
872
915
  * browsers can be paired to one daemon, and a client that never asked about a
873
916
  * process should not be streamed its output - so output is fanned out per
@@ -885,6 +928,14 @@ export class SidecarServer {
885
928
  writable: true,
886
929
  value: void 0
887
930
  });
931
+ /** What this connection publishes its file changes under on the shared bus,
932
+ * and how it recognises its own changes there (never sent back to it). */
933
+ Object.defineProperty(this, "fsConnectionId", {
934
+ enumerable: true,
935
+ configurable: true,
936
+ writable: true,
937
+ value: `c${++fsConnectionSeq}`
938
+ });
888
939
  /* ---- sessions ---- */
889
940
  /** Per-session stream stoppers for THIS connection.
890
941
  *
@@ -1031,6 +1082,19 @@ export class SidecarServer {
1031
1082
  return;
1032
1083
  this.ch.send({ t: 'room-progress', room });
1033
1084
  });
1085
+ /* Same gating as roots and rooms, plus: never the writer itself (it knows
1086
+ * what it wrote, and its own listeners already fired). Only the root and
1087
+ * the path cross, never content. No team rule narrows fs READS for a
1088
+ * member (onInvoke gates writes only, and every active member is served
1089
+ * the same file tools over the same roots), so any connection this would
1090
+ * reach could list the same path itself. */
1091
+ const offFsChanges = opts.fsChanges?.subscribe((c) => {
1092
+ if (c.by === this.fsConnectionId)
1093
+ return;
1094
+ if (!this.authed || this.closed || this.peerDaemon || this.memberPending())
1095
+ return;
1096
+ this.ch.send({ t: 'fs-changed', root: c.root, path: c.path });
1097
+ });
1034
1098
  /* A member approved while connected is told so at once (Part E), so the
1035
1099
  * waiting screen can move on; the gate itself re-reads the status on the
1036
1100
  * next message. Only THIS connection's verified user is ever told.
@@ -1113,8 +1177,13 @@ export class SidecarServer {
1113
1177
  for (const detach of this.runDetachers)
1114
1178
  detach();
1115
1179
  this.runDetachers.clear();
1180
+ // A fleet workflow call has no one left to answer: stop every open one.
1181
+ for (const call of this.fleetCalls.values())
1182
+ call.abort();
1183
+ this.fleetCalls.clear();
1116
1184
  offRoots?.();
1117
1185
  offRooms?.();
1186
+ offFsChanges?.();
1118
1187
  offMembers?.();
1119
1188
  offAdmission?.();
1120
1189
  // Stop streaming process output into a dead socket. The PROCESSES keep
@@ -1235,7 +1304,7 @@ export class SidecarServer {
1235
1304
  }
1236
1305
  // A chat turn (Harness Chat Plan 2) takes its own path: the daemon's chat port, never runner.run.
1237
1306
  if (m.chat !== undefined) {
1238
- this.onChatRun(m);
1307
+ void this.onChatRun(m);
1239
1308
  return;
1240
1309
  }
1241
1310
  void this.onRun(m);
@@ -1318,10 +1387,25 @@ export class SidecarServer {
1318
1387
  // answer is dropped silently rather than forwarded into the registry.
1319
1388
  if (!this.authed)
1320
1389
  return;
1390
+ // A connection that left attendance is shown no asks, so answers none.
1391
+ if (this.leftAttendance)
1392
+ return;
1321
1393
  // The note is the client's text: cleaned HERE, at the daemon's edge,
1322
1394
  // and only a deny carries one.
1323
1395
  this.opts.attendance?.answer(m.id, m.decision, this.attended, m.decision === 'deny' ? denialNote(m.note) : undefined);
1324
1396
  return;
1397
+ case 'attendance-leave':
1398
+ /* A client keeping this connection open only for its own calls (a
1399
+ * browser that selected another machine but still has work running
1400
+ * on this one). Detached now, so an ask it would never show falls to
1401
+ * the unattended policy as if it had disconnected, instead of
1402
+ * waiting on it. Auth-gated like permission-answer; no reply. */
1403
+ if (!this.authed)
1404
+ return;
1405
+ this.leftAttendance = true;
1406
+ this.attendedDetach?.();
1407
+ this.attendedDetach = undefined;
1408
+ return;
1325
1409
  case 'roots-browse':
1326
1410
  // Auth-gated like every other request: directory enumeration must never
1327
1411
  // precede authentication. Failures answer ok:false rather than throwing
@@ -1540,6 +1624,39 @@ export class SidecarServer {
1540
1624
  }
1541
1625
  void this.onCrewDeploy(m);
1542
1626
  return;
1627
+ case 'fleet-items':
1628
+ if (!this.authed) {
1629
+ this.ch.send({ t: 'fleet-items-result', id: m.id, reply: { ok: false, error: 'not authenticated' } });
1630
+ return;
1631
+ }
1632
+ void this.onFleetItems(m);
1633
+ return;
1634
+ case 'fleet-skill-catalog':
1635
+ if (!this.authed) {
1636
+ this.ch.send({ t: 'fleet-skill-catalog-result', id: m.id, reply: { ok: false, error: 'not authenticated' } });
1637
+ return;
1638
+ }
1639
+ void this.onFleetSkillCatalog(m);
1640
+ return;
1641
+ case 'fleet-workflow-catalog':
1642
+ if (!this.authed) {
1643
+ this.ch.send({ t: 'fleet-workflow-catalog-result', id: m.id, reply: { ok: false, error: 'not authenticated' } });
1644
+ return;
1645
+ }
1646
+ void this.onFleetWorkflowCatalog(m);
1647
+ return;
1648
+ case 'fleet-workflow-call':
1649
+ if (!this.authed) {
1650
+ this.ch.send({ t: 'fleet-workflow-call-result', id: m.id, reply: { ok: false, error: 'not authenticated' } });
1651
+ return;
1652
+ }
1653
+ void this.onFleetWorkflowCall(m);
1654
+ return;
1655
+ case 'fleet-workflow-cancel':
1656
+ // No reply exists; an id this connection has no open call under is ignored.
1657
+ if (typeof m.id === 'number')
1658
+ this.fleetCalls.get(m.id)?.abort();
1659
+ return;
1543
1660
  case 'workflow-package-put':
1544
1661
  case 'workflow-results-list':
1545
1662
  case 'workflow-results-take':
@@ -1736,6 +1853,7 @@ export class SidecarServer {
1736
1853
  case 'mcp-remove':
1737
1854
  case 'mcp-set-credential':
1738
1855
  case 'mcp-login':
1856
+ case 'mcp-allow-set':
1739
1857
  if (!this.authed) {
1740
1858
  this.ch.send({ t: 'mcp-admin-error', id: m.id, error: 'not authenticated' });
1741
1859
  return;
@@ -2467,7 +2585,8 @@ export class SidecarServer {
2467
2585
  this.askSeat = seat;
2468
2586
  this.askSeatLeave = this.askUsers().join(seat);
2469
2587
  }
2470
- if (this.opts.attendance && !isPeerDaemon) {
2588
+ // A leave that arrived while this hello was still being checked holds.
2589
+ if (this.opts.attendance && !isPeerDaemon && !this.leftAttendance) {
2471
2590
  /* A pending member (Part E) is never a recipient of anything: every
2472
2591
  * send below checks the status at the moment it is made, so a member
2473
2592
  * approved later starts receiving without reconnecting. */
@@ -2710,6 +2829,10 @@ export class SidecarServer {
2710
2829
  // watchers of the parent run (the running-pills card). Only with a live
2711
2830
  // hub behind it -- there is otherwise nothing to fan out from.
2712
2831
  ...(this.opts.runs ? [CHILD_EVENTS_FEATURE] : []),
2832
+ // Whether a write another client makes reaches this one as fs-changed.
2833
+ ...(this.opts.fsChanges ? [FS_CHANGED_FEATURE] : []),
2834
+ // A connection may stop attending while it stays open (attendance-leave).
2835
+ ...(this.opts.attendance ? [ATTENDANCE_LEAVE_FEATURE] : []),
2713
2836
  ...(this.opts.acp ? ['acp'] : []),
2714
2837
  ...(this.opts.roots?.areas.length ? ['add-roots'] : []),
2715
2838
  ...(this.opts.roots?.areas.length && this.opts.roots.create ? ['create-roots'] : []),
@@ -2739,6 +2862,9 @@ export class SidecarServer {
2739
2862
  // Undeploy only where the daemon would record them -- anywhere else a
2740
2863
  // client shows the old read-only view.
2741
2864
  ...(this.opts.crewDeploy ? [CREW_DEPLOY_FEATURE] : []),
2865
+ // Whether this sidecar lists its fleet items. Only with a real port.
2866
+ ...(this.opts.fleetItems ? [FLEET_ITEMS_FEATURE] : []),
2867
+ ...(this.opts.fleetUse ? [FLEET_USE_FEATURE] : []),
2742
2868
  // Whether this daemon runs deployed workflows. Only with a real port, so
2743
2869
  // a client offers the Sidecar deploy target only where it would run.
2744
2870
  ...(this.opts.workflows ? [WORKFLOW_RUNNER_FEATURE] : []),
@@ -2771,6 +2897,7 @@ export class SidecarServer {
2771
2897
  // gives regardless of this flag.
2772
2898
  ...(this.opts.mcp ? ['mcp'] : []),
2773
2899
  ...(this.opts.mcpAdmin ? ['mcp-admin'] : []),
2900
+ ...(this.opts.mcpAdmin?.setAllow ? [MCP_ALLOW_FEATURE] : []),
2774
2901
  // Whether this daemon serves its own Harness Config. Advertised only with
2775
2902
  // a real (allowlisted) service behind it, so a client never shows a
2776
2903
  // daemon's definitions as an empty workspace when what it actually has is
@@ -3165,6 +3292,9 @@ export class SidecarServer {
3165
3292
  }
3166
3293
  }
3167
3294
  this.ch.send({ t: 'invoke-result', id: m.id, ok: true, result });
3295
+ // After the answer, and only for a change that happened.
3296
+ if (FS_CHANGE_TOOLS.has(m.name))
3297
+ this.publishFsChange(m.args);
3168
3298
  }
3169
3299
  catch (e) {
3170
3300
  // A read that finds nothing there (ENOENT) answered the question -- the
@@ -3176,6 +3306,31 @@ export class SidecarServer {
3176
3306
  this.ch.send({ t: 'invoke-result', id: m.id, ok: false, error: errMessage(e) });
3177
3307
  }
3178
3308
  }
3309
+ /** Announce a file tool's change to the daemon's other clients. The root is
3310
+ * the one the tool resolved against, spelled as the daemon reports its
3311
+ * roots (that is what every client names it by), and the path is relative
3312
+ * to it, resolved as the tool confined it (fsChangesFor). Never throws:
3313
+ * the change already happened and its caller already has its answer. */
3314
+ publishFsChange(args) {
3315
+ const bus = this.opts.fsChanges;
3316
+ if (!bus)
3317
+ return;
3318
+ try {
3319
+ const path = typeof args.path === 'string' ? args.path : '';
3320
+ if (!path)
3321
+ return;
3322
+ const snap = this.opts.roots?.snapshot();
3323
+ const info = this.opts.info;
3324
+ const roots = snap?.roots ?? info?.roots ?? (info?.root ? [info.root] : []);
3325
+ const primary = snap?.root ?? info?.root ?? roots[0];
3326
+ // Where the tool actually wrote (a `../` or absolute path can land in another root).
3327
+ for (const change of fsChangesFor(roots, primary, args.root, path))
3328
+ bus.publish({ ...change, by: this.fsConnectionId });
3329
+ }
3330
+ catch (err) {
3331
+ this.opts.log?.(`fs change not announced: ${errMessage(err)}`);
3332
+ }
3333
+ }
3179
3334
  /** Runs a crew agent via the configured `runner` and streams its lifecycle back
3180
3335
  * to the host: `run-accepted` (with the daemon-assigned runId) once the runner
3181
3336
  * has started, one `event` per `onEvent` callback, then exactly one of
@@ -3363,6 +3518,11 @@ export class SidecarServer {
3363
3518
  */
3364
3519
  async onRunHistory(m) {
3365
3520
  try {
3521
+ const denied = await this.hostFolderDenial();
3522
+ if (denied !== null) {
3523
+ this.ch.send({ t: 'run-history-result', id: m.id, runs: [], error: denied });
3524
+ return;
3525
+ }
3366
3526
  const history = this.opts.runner?.history?.bind(this.opts.runner);
3367
3527
  const crew = runHistoryCrewOf(m.crew);
3368
3528
  const identity = { clientId: this.clientId };
@@ -3385,6 +3545,11 @@ export class SidecarServer {
3385
3545
  * case, and a state the caller renders rather than treats as a failure. */
3386
3546
  async onRunTranscript(m) {
3387
3547
  try {
3548
+ const denied = await this.hostFolderDenial();
3549
+ if (denied !== null) {
3550
+ this.ch.send({ t: 'run-transcript-result', id: m.id, lines: [], error: denied });
3551
+ return;
3552
+ }
3388
3553
  const transcript = this.opts.runner?.transcript?.bind(this.opts.runner);
3389
3554
  const identity = { clientId: this.clientId };
3390
3555
  const lines = !transcript ? []
@@ -3403,6 +3568,11 @@ export class SidecarServer {
3403
3568
  * a path. */
3404
3569
  async onCrewMemory(m) {
3405
3570
  try {
3571
+ const denied = await this.hostFolderDenial();
3572
+ if (denied !== null) {
3573
+ this.ch.send({ t: 'crew-memory-result', id: m.id, memories: [], error: denied });
3574
+ return;
3575
+ }
3406
3576
  const runner = this.opts.runner;
3407
3577
  // The viewer is THIS connection's verified user (team agent access).
3408
3578
  const memories = !runner?.memories ? []
@@ -3430,6 +3600,13 @@ export class SidecarServer {
3430
3600
  return;
3431
3601
  }
3432
3602
  }
3603
+ // The connection's host folder is the run's owner root (and the `via` of a daemon-crew run): it speaks only
3604
+ // for its bound verified user (hostFolderDenial).
3605
+ const folderDenied = await this.hostFolderDenial();
3606
+ if (folderDenied !== null) {
3607
+ this.ch.send({ t: 'run-error', id: m.id, error: folderDenied });
3608
+ return;
3609
+ }
3433
3610
  /* WHERE the run happens, decided once, up front.
3434
3611
  *
3435
3612
  * A `sessionId` is a confinement request. A daemon that cannot honour it
@@ -3543,7 +3720,7 @@ export class SidecarServer {
3543
3720
  * verified user and never a message field; with none it is refused. The
3544
3721
  * events, `run-accepted` (with the conversation id), `run-done` and the hub
3545
3722
  * publishing follow `onRun`. A refused or failed turn sends `run-error`. */
3546
- onChatRun(m) {
3723
+ async onChatRun(m) {
3547
3724
  const refuse = (error) => { this.ch.send({ t: 'run-error', id: m.id, error }); };
3548
3725
  const port = this.opts.runner?.chat;
3549
3726
  if (!port) {
@@ -3572,6 +3749,15 @@ export class SidecarServer {
3572
3749
  refuse(spec);
3573
3750
  return;
3574
3751
  }
3752
+ // Team mode only: the chat's identity is this connection's host folder (hostFolderDenial). Solo mode reaches
3753
+ // the port without waiting, as before.
3754
+ if (this.opts.teamMode) {
3755
+ const denied = await this.hostFolderDenial();
3756
+ if (denied !== null) {
3757
+ refuse(denied);
3758
+ return;
3759
+ }
3760
+ }
3575
3761
  const agent = m.agent;
3576
3762
  const hub = this.opts.runs;
3577
3763
  let runId;
@@ -3712,6 +3898,11 @@ export class SidecarServer {
3712
3898
  }
3713
3899
  const { port, ...rest } = q;
3714
3900
  try {
3901
+ const denied = await this.hostFolderDenial();
3902
+ if (denied !== null) {
3903
+ this.ch.send({ t: 'chat-list-result', id: m.id, ok: false, error: denied });
3904
+ return;
3905
+ }
3715
3906
  const chats = await port.list({ ...rest, identity: { clientId: this.clientId } });
3716
3907
  this.ch.send({ t: 'chat-list-result', id: m.id, ok: true, chats: chats.map(chatSummaryOf) });
3717
3908
  }
@@ -3735,6 +3926,11 @@ export class SidecarServer {
3735
3926
  }
3736
3927
  const { port, ...rest } = q;
3737
3928
  try {
3929
+ const denied = await this.hostFolderDenial();
3930
+ if (denied !== null) {
3931
+ refuse(denied);
3932
+ return;
3933
+ }
3738
3934
  const conversation = await port.get({ ...rest, identity: { clientId: this.clientId }, conversationId: m.conversationId });
3739
3935
  this.ch.send({ t: 'chat-get-result', id: m.id, ok: true, conversation: storedChatOf(conversation) });
3740
3936
  }
@@ -4044,6 +4240,127 @@ export class SidecarServer {
4044
4240
  ...(this.verifiedUserId !== undefined ? { verifiedUserId: this.verifiedUserId } : {}),
4045
4241
  };
4046
4242
  }
4243
+ async onFleetItems(m) {
4244
+ if (typeof m.id !== 'number' || !Number.isSafeInteger(m.id))
4245
+ return;
4246
+ const answer = (reply) => this.ch.send({ t: 'fleet-items-result', id: m.id, reply });
4247
+ const port = this.opts.fleetItems;
4248
+ if (!port) {
4249
+ answer({ ok: false, error: 'this sidecar is not in a fleet' });
4250
+ return;
4251
+ }
4252
+ try {
4253
+ answer(await port.list(this.verifiedUserId ?? undefined));
4254
+ }
4255
+ catch (err) {
4256
+ answer({ ok: false, error: errMessage(err) });
4257
+ }
4258
+ }
4259
+ /** The caller a fleet workflow call runs as, from THIS connection only, or the refusal. Never the verified user
4260
+ * id (it would reach the workflow's prompts).
4261
+ *
4262
+ * Team mode: `host:<dirName>` only when that folder is bound to this connection's verified user (hostFolderDenial,
4263
+ * the rule the workflow-* messages use): the folder comes from the hello's clientId, which is not secret, so an
4264
+ * unbound name is refused with that check's denial. Solo mode: `host:<dirName>` for a
4265
+ * verified user whose folder was resolved, else `host:owner`. */
4266
+ async fleetCaller() {
4267
+ if (this.opts.teamMode) {
4268
+ if (this.verifiedUserId !== undefined && this.dirName === undefined)
4269
+ return { error: FLEET_CALLER_NO_FOLDER };
4270
+ const denied = await this.hostFolderDenial();
4271
+ if (denied !== null || this.dirName === undefined)
4272
+ return { error: denied ?? FLEET_CALLER_NO_FOLDER };
4273
+ return { caller: { name: `host:${this.dirName}`, trigger: 'host' } };
4274
+ }
4275
+ if (this.verifiedUserId !== undefined && this.dirName !== undefined)
4276
+ return { caller: { name: `host:${this.dirName}`, trigger: 'host' } };
4277
+ return { caller: { name: 'host:owner', trigger: 'host' } };
4278
+ }
4279
+ async onFleetSkillCatalog(m) {
4280
+ if (typeof m.id !== 'number' || !Number.isSafeInteger(m.id))
4281
+ return;
4282
+ const answer = (reply) => this.ch.send({ t: 'fleet-skill-catalog-result', id: m.id, reply });
4283
+ const port = this.opts.fleetUse;
4284
+ if (!port) {
4285
+ answer({ ok: false, error: 'this sidecar is not in a fleet' });
4286
+ return;
4287
+ }
4288
+ try {
4289
+ answer(await port.skills());
4290
+ }
4291
+ catch (err) {
4292
+ answer({ ok: false, error: errMessage(err) });
4293
+ }
4294
+ }
4295
+ async onFleetWorkflowCatalog(m) {
4296
+ if (typeof m.id !== 'number' || !Number.isSafeInteger(m.id))
4297
+ return;
4298
+ const answer = (reply) => this.ch.send({ t: 'fleet-workflow-catalog-result', id: m.id, reply });
4299
+ const port = this.opts.fleetUse;
4300
+ if (!port) {
4301
+ answer({ ok: false, error: 'this sidecar is not in a fleet' });
4302
+ return;
4303
+ }
4304
+ try {
4305
+ answer(await port.workflows());
4306
+ }
4307
+ catch (err) {
4308
+ answer({ ok: false, error: errMessage(err) });
4309
+ }
4310
+ }
4311
+ async onFleetWorkflowCall(m) {
4312
+ // A frame with no usable correlation id is dropped before the port: nothing runs that its sender could
4313
+ // never match to an answer or cancel.
4314
+ if (typeof m.id !== 'number' || !Number.isSafeInteger(m.id))
4315
+ return;
4316
+ const id = m.id;
4317
+ const answer = (reply) => {
4318
+ if (!this.closed)
4319
+ this.ch.send({ t: 'fleet-workflow-call-result', id, reply });
4320
+ };
4321
+ const port = this.opts.fleetUse;
4322
+ if (!port) {
4323
+ answer({ ok: false, error: 'this sidecar is not in a fleet' });
4324
+ return;
4325
+ }
4326
+ // Runtime-typed: the message arrived via JSON.parse(...) as SidecarMsg.
4327
+ const name = m.name;
4328
+ if (typeof name !== 'string' || name.length === 0) {
4329
+ answer({ ok: false, error: 'a fleet workflow call needs a workflow name' });
4330
+ return;
4331
+ }
4332
+ const inputs = m.inputs;
4333
+ if (!isWorkflowCallInputs(inputs)) {
4334
+ answer({ ok: false, error: FLEET_WORKFLOW_INPUTS_ERROR });
4335
+ return;
4336
+ }
4337
+ if (this.fleetCalls.has(id)) {
4338
+ answer({ ok: false, error: 'a fleet workflow call with that id is already open' });
4339
+ return;
4340
+ }
4341
+ if (this.fleetCalls.size >= FLEET_CALLS_OPEN_MAX) {
4342
+ answer({ ok: false, error: FLEET_CALLS_TOO_MANY });
4343
+ return;
4344
+ }
4345
+ // The id is held before the first await, so a second call under it, or a cancel, sees it.
4346
+ const control = new AbortController();
4347
+ this.fleetCalls.set(id, control);
4348
+ try {
4349
+ const named = await this.fleetCaller();
4350
+ if ('error' in named) {
4351
+ answer({ ok: false, error: named.error });
4352
+ return;
4353
+ }
4354
+ answer(await port.call(named.caller, name, inputs, control.signal));
4355
+ }
4356
+ catch (err) {
4357
+ answer({ ok: false, error: errMessage(err) });
4358
+ }
4359
+ finally {
4360
+ if (this.fleetCalls.get(id) === control)
4361
+ this.fleetCalls.delete(id);
4362
+ }
4363
+ }
4047
4364
  async onCrewDeploy(m) {
4048
4365
  // A frame with no usable correlation id is dropped before the port: no
4049
4366
  // change is made that its sender could never match to an answer.
@@ -4085,25 +4402,49 @@ export class SidecarServer {
4085
4402
  answer({ ok: false, error: errMessage(err) });
4086
4403
  }
4087
4404
  }
4088
- /** Team mode: why this connection may NOT use its synced folder's workflow
4089
- * packages and results, or null when it may (the caller's verified id equals
4090
- * the folder's bound owner). A lookup that throws refuses. */
4091
- async workflowsTeamDenial() {
4405
+ /** THE ONE RULE for whether this connection's synced folder speaks for it: 'bound' only when the folder's
4406
+ * bound owner (the deploy binding, `workflowsFolderOwner`) is this connection's verified user. A folder the
4407
+ * daemon did not resolve, no lookup, no binding, or a lookup that throws is 'unbound'. Every team-mode check of
4408
+ * a host folder (workflowsTeamDenial, hostFolderDenial) reads this and only words it differently. */
4409
+ async hostFolderStanding() {
4092
4410
  if (this.verifiedUserId === undefined)
4093
- return WORKFLOWS_VERIFY_FIRST;
4411
+ return 'unverified';
4094
4412
  const lookup = this.opts.workflowsFolderOwner;
4095
4413
  if (this.dirName === undefined || !lookup)
4096
- return WORKFLOWS_FOLDER_UNBOUND;
4414
+ return 'unbound';
4097
4415
  let bound;
4098
4416
  try {
4099
4417
  bound = await lookup(this.dirName);
4100
4418
  }
4101
4419
  catch {
4102
- return WORKFLOWS_FOLDER_UNBOUND;
4420
+ return 'unbound';
4103
4421
  }
4104
4422
  if (bound === undefined)
4105
- return WORKFLOWS_FOLDER_UNBOUND;
4106
- return bound === this.verifiedUserId ? null : WORKFLOWS_NOT_FOLDER_OWNER;
4423
+ return 'unbound';
4424
+ return bound === this.verifiedUserId ? 'bound' : 'not-owner';
4425
+ }
4426
+ /** Team mode: why this connection may NOT use its synced folder's workflow
4427
+ * packages and results, or null when it may (hostFolderStanding). */
4428
+ async workflowsTeamDenial() {
4429
+ switch (await this.hostFolderStanding()) {
4430
+ case 'bound': return null;
4431
+ case 'unverified': return WORKFLOWS_VERIFY_FIRST;
4432
+ case 'unbound': return WORKFLOWS_FOLDER_UNBOUND;
4433
+ case 'not-owner': return WORKFLOWS_NOT_FOLDER_OWNER;
4434
+ }
4435
+ }
4436
+ /** Why this connection's host folder may NOT speak for it in a run, a chat, a history, transcript or memory
4437
+ * read, a fleet workflow call, or a host-sync write, or null when it may. Solo mode: always null (unchanged). Team mode:
4438
+ * hostFolderStanding, the rule the workflow-* messages use. */
4439
+ async hostFolderDenial() {
4440
+ if (!this.opts.teamMode)
4441
+ return null;
4442
+ switch (await this.hostFolderStanding()) {
4443
+ case 'bound': return null;
4444
+ case 'unverified': return HOST_FOLDER_VERIFY_FIRST;
4445
+ case 'unbound': return HOST_FOLDER_UNBOUND;
4446
+ case 'not-owner': return HOST_FOLDER_NOT_OWNER;
4447
+ }
4107
4448
  }
4108
4449
  async onWorkflows(m) {
4109
4450
  // A frame with no usable correlation id is dropped before the port: no
@@ -4873,7 +5214,7 @@ export class SidecarServer {
4873
5214
  if (m.t !== 'mcp-servers-list') {
4874
5215
  const refusal = this.mcpRefusal();
4875
5216
  if (refusal) {
4876
- this.ch.send({ t: 'mcp-admin-error', id: m.id, error: refusal });
5217
+ this.ch.send({ t: 'mcp-admin-error', id: m.id, error: refusal, code: 'MCP_ADMIN_ONLY' });
4877
5218
  return;
4878
5219
  }
4879
5220
  }
@@ -4894,8 +5235,9 @@ export class SidecarServer {
4894
5235
  * branch that is not part of it runs setCredential too, with whatever
4895
5236
  * `m.header`/`m.value` happen to be (undefined, for every other message).
4896
5237
  * That is exactly what a stray `if` here did before this comment existed. */
5238
+ let applied;
4897
5239
  if (m.t === 'mcp-add')
4898
- await admin.addServer(m.name, m.url, m.hosted === true, m.authUrl);
5240
+ applied = await admin.addServer(m.name, m.url, m.hosted === true, m.authUrl);
4899
5241
  else if (m.t === 'mcp-login') {
4900
5242
  /* The consent URL is pushed the moment it exists, BEFORE the flow
4901
5243
  * blocks waiting for a human. Holding it until the end would deliver it
@@ -4905,15 +5247,48 @@ export class SidecarServer {
4905
5247
  this.ch.send({ t: 'mcp-admin-error', id: m.id, error: out.message, reason: out.reason });
4906
5248
  return;
4907
5249
  }
5250
+ applied = out.reloadError !== undefined ? { reloadError: out.reloadError } : undefined;
4908
5251
  }
4909
5252
  else if (m.t === 'mcp-remove')
4910
- await admin.removeServer(m.name);
5253
+ applied = await admin.removeServer(m.name);
5254
+ else if (m.t === 'mcp-allow-set') {
5255
+ if (!admin.setAllow) {
5256
+ this.ch.send({ t: 'mcp-admin-error', id: m.id, error: 'this daemon cannot switch MCP tools -- update it and reconnect' });
5257
+ return;
5258
+ }
5259
+ // The shape is checked here, before the port: the message is a
5260
+ // browser's, and a non-string entry must never reach daemon.json.
5261
+ if (typeof m.name !== 'string' || !m.name || !Array.isArray(m.tools) || !m.tools.every((t) => typeof t === 'string')) {
5262
+ 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.' });
5263
+ return;
5264
+ }
5265
+ applied = await admin.setAllow(m.name, m.tools);
5266
+ }
4911
5267
  else
4912
- await admin.setCredential(m.name, m.header, m.value);
4913
- this.ch.send({ t: 'mcp-admin-ok', id: m.id });
5268
+ applied = await admin.setCredential(m.name, m.header, m.value);
5269
+ const reloadError = applied ? applied.reloadError : undefined;
5270
+ /* A switch answers with the rows as they now stand, so the browser shows
5271
+ * what the daemon allows (a glob may keep a tool on), not what it asked
5272
+ * for. Only the admin reaches here (mcpRefusal above), so the full rows.
5273
+ * Best effort: the write is saved either way. */
5274
+ let servers;
5275
+ if (m.t === 'mcp-allow-set') {
5276
+ try {
5277
+ servers = await admin.listServers();
5278
+ }
5279
+ catch {
5280
+ servers = undefined;
5281
+ }
5282
+ }
5283
+ this.ch.send({
5284
+ t: 'mcp-admin-ok', id: m.id,
5285
+ ...(typeof reloadError === 'string' ? { reloadError } : {}),
5286
+ ...(servers ? { servers } : {}),
5287
+ });
4914
5288
  }
4915
5289
  catch (e) {
4916
- this.ch.send({ t: 'mcp-admin-error', id: m.id, error: e instanceof Error ? e.message : String(e) });
5290
+ const code = mcpAdminCodeOf(e);
5291
+ this.ch.send({ t: 'mcp-admin-error', id: m.id, error: e instanceof Error ? e.message : String(e), ...(code ? { code } : {}) });
4917
5292
  }
4918
5293
  }
4919
5294
  /** Part D credential messages. The caller is this connection's VERIFIED
@@ -5909,6 +6284,13 @@ export class SidecarServer {
5909
6284
  this.ch.send({ t: 'host-sync-result', id: m.id, ok: false, error: 'this sidecar cannot sync host config' });
5910
6285
  return;
5911
6286
  }
6287
+ // Team mode: the clientId names the folder written to, and it is not secret, so it writes only for the
6288
+ // verified user that folder is bound to (hostFolderDenial). Solo mode is unchanged.
6289
+ const denied = await this.hostFolderDenial();
6290
+ if (denied !== null) {
6291
+ this.ch.send({ t: 'host-sync-result', id: m.id, ok: false, error: denied });
6292
+ return;
6293
+ }
5912
6294
  try {
5913
6295
  await this.opts.hostSync.put(identity.clientId, m.path, m.content);
5914
6296
  this.ch.send({ t: 'host-sync-result', id: m.id, ok: true });
@@ -5927,6 +6309,13 @@ export class SidecarServer {
5927
6309
  this.ch.send({ t: 'host-sync-result', id: m.id, ok: false, error: 'this sidecar cannot sync host config' });
5928
6310
  return;
5929
6311
  }
6312
+ // Team mode: the clientId names the folder written to, and it is not secret, so it writes only for the
6313
+ // verified user that folder is bound to (hostFolderDenial). Solo mode is unchanged.
6314
+ const denied = await this.hostFolderDenial();
6315
+ if (denied !== null) {
6316
+ this.ch.send({ t: 'host-sync-result', id: m.id, ok: false, error: denied });
6317
+ return;
6318
+ }
5930
6319
  try {
5931
6320
  await this.opts.hostSync.remove(identity.clientId, m.path);
5932
6321
  this.ch.send({ t: 'host-sync-result', id: m.id, ok: true });