@corenel/harness 0.22.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 (75) hide show
  1. package/README.md +48 -0
  2. package/dist/chat/engine.d.ts +3 -0
  3. package/dist/chat/engine.d.ts.map +1 -1
  4. package/dist/chat/engine.js +5 -3
  5. package/dist/chat/engine.js.map +1 -1
  6. package/dist/core/loop.d.ts.map +1 -1
  7. package/dist/core/loop.js +134 -78
  8. package/dist/core/loop.js.map +1 -1
  9. package/dist/dag/executeNode.js +3 -3
  10. package/dist/guardrail/guardrail.d.ts +9 -0
  11. package/dist/guardrail/guardrail.d.ts.map +1 -1
  12. package/dist/guardrail/guardrail.js +13 -0
  13. package/dist/guardrail/guardrail.js.map +1 -1
  14. package/dist/guardrail/index.d.ts +3 -3
  15. package/dist/guardrail/index.d.ts.map +1 -1
  16. package/dist/guardrail/index.js +2 -2
  17. package/dist/guardrail/index.js.map +1 -1
  18. package/dist/guardrail/permission-service.d.ts +17 -3
  19. package/dist/guardrail/permission-service.d.ts.map +1 -1
  20. package/dist/guardrail/permission-service.js +11 -6
  21. package/dist/guardrail/permission-service.js.map +1 -1
  22. package/dist/guardrail/store.d.ts +4 -0
  23. package/dist/guardrail/store.d.ts.map +1 -1
  24. package/dist/guardrail/store.js +6 -0
  25. package/dist/guardrail/store.js.map +1 -1
  26. package/dist/prompts/config.d.ts +6 -2
  27. package/dist/prompts/config.d.ts.map +1 -1
  28. package/dist/prompts/config.js +6 -3
  29. package/dist/prompts/config.js.map +1 -1
  30. package/dist/prompts/host.d.ts +3 -2
  31. package/dist/prompts/host.d.ts.map +1 -1
  32. package/dist/prompts/host.js.map +1 -1
  33. package/dist/prompts/index.d.ts +8 -1
  34. package/dist/prompts/index.d.ts.map +1 -1
  35. package/dist/prompts/index.js +9 -7
  36. package/dist/prompts/index.js.map +1 -1
  37. package/dist/prompts/systemPrompts.d.ts +3 -0
  38. package/dist/prompts/systemPrompts.d.ts.map +1 -1
  39. package/dist/prompts/systemPrompts.js +1 -1
  40. package/dist/prompts/systemPrompts.js.map +1 -1
  41. package/dist/sidecar/host.d.ts +49 -10
  42. package/dist/sidecar/host.d.ts.map +1 -1
  43. package/dist/sidecar/host.js +194 -6
  44. package/dist/sidecar/host.js.map +1 -1
  45. package/dist/sidecar/memberGate.d.ts.map +1 -1
  46. package/dist/sidecar/memberGate.js +9 -0
  47. package/dist/sidecar/memberGate.js.map +1 -1
  48. package/dist/sidecar/server.d.ts +83 -6
  49. package/dist/sidecar/server.d.ts.map +1 -1
  50. package/dist/sidecar/server.js +348 -19
  51. package/dist/sidecar/server.js.map +1 -1
  52. package/dist/skills.d.ts +9 -1
  53. package/dist/skills.d.ts.map +1 -1
  54. package/dist/skills.js +22 -8
  55. package/dist/skills.js.map +1 -1
  56. package/dist/tools/builtins.d.ts.map +1 -1
  57. package/dist/tools/builtins.js +7 -0
  58. package/dist/tools/builtins.js.map +1 -1
  59. package/dist/tools/callAgent.d.ts +6 -2
  60. package/dist/tools/callAgent.d.ts.map +1 -1
  61. package/dist/tools/callAgent.js +7 -3
  62. package/dist/tools/callAgent.js.map +1 -1
  63. package/dist/tools/files.d.ts.map +1 -1
  64. package/dist/tools/files.js +4 -0
  65. package/dist/tools/files.js.map +1 -1
  66. package/dist/tools/sessions.d.ts.map +1 -1
  67. package/dist/tools/sessions.js +1 -0
  68. package/dist/tools/sessions.js.map +1 -1
  69. package/dist/tools/web.d.ts.map +1 -1
  70. package/dist/tools/web.js +19 -5
  71. package/dist/tools/web.js.map +1 -1
  72. package/dist/tools/workflowTool.d.ts +2 -18
  73. package/dist/tools/workflowTool.d.ts.map +1 -1
  74. package/dist/tools/workflowTool.js.map +1 -1
  75. 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, 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';
@@ -12,6 +12,13 @@ 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
+ /** 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';
15
22
  /** A deliberate refusal from the approvals port, written for the person who
16
23
  * asked: its message goes on the wire as is. Recognized by `name`, not by
17
24
  * `instanceof`, so a second copy of this module (a test alias, a bundled
@@ -34,6 +41,13 @@ export function isApprovalsRefusal(e) {
34
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';
35
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';
36
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';
37
51
  export const APPROVALS_FAILED = 'Standing approvals could not be read or changed. The daemon log has the reason.';
38
52
  export const APPROVALS_NO_USER = 'In team mode standing approvals need a verified user on this connection.';
39
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.';
@@ -115,6 +129,11 @@ const AGENT_CRED_RESERVED_SERVERS = new Set(['__proto__', 'constructor', 'protot
115
129
  function logSafe(s) {
116
130
  return s.replace(/[\x00-\x1f\x7f-\x9f]/g, '');
117
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
+ }
118
137
  /** The code a thrown refusal carries, when it is one a reply may name. */
119
138
  function agentCredCodeOf(err) {
120
139
  const code = typeof err === 'object' && err !== null ? err.code : undefined;
@@ -787,6 +806,14 @@ export class SidecarServer {
787
806
  writable: true,
788
807
  value: void 0
789
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
+ });
790
817
  /** This connection's seat for chat ask_user (verified connections only),
791
818
  * and its leave function, called once on close. */
792
819
  Object.defineProperty(this, "askSeat", {
@@ -876,6 +903,14 @@ export class SidecarServer {
876
903
  writable: true,
877
904
  value: new Set()
878
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
+ });
879
914
  /** Processes THIS connection is watching. A shared process table means two
880
915
  * browsers can be paired to one daemon, and a client that never asked about a
881
916
  * process should not be streamed its output - so output is fanned out per
@@ -1142,6 +1177,10 @@ export class SidecarServer {
1142
1177
  for (const detach of this.runDetachers)
1143
1178
  detach();
1144
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();
1145
1184
  offRoots?.();
1146
1185
  offRooms?.();
1147
1186
  offFsChanges?.();
@@ -1265,7 +1304,7 @@ export class SidecarServer {
1265
1304
  }
1266
1305
  // A chat turn (Harness Chat Plan 2) takes its own path: the daemon's chat port, never runner.run.
1267
1306
  if (m.chat !== undefined) {
1268
- this.onChatRun(m);
1307
+ void this.onChatRun(m);
1269
1308
  return;
1270
1309
  }
1271
1310
  void this.onRun(m);
@@ -1348,10 +1387,25 @@ export class SidecarServer {
1348
1387
  // answer is dropped silently rather than forwarded into the registry.
1349
1388
  if (!this.authed)
1350
1389
  return;
1390
+ // A connection that left attendance is shown no asks, so answers none.
1391
+ if (this.leftAttendance)
1392
+ return;
1351
1393
  // The note is the client's text: cleaned HERE, at the daemon's edge,
1352
1394
  // and only a deny carries one.
1353
1395
  this.opts.attendance?.answer(m.id, m.decision, this.attended, m.decision === 'deny' ? denialNote(m.note) : undefined);
1354
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;
1355
1409
  case 'roots-browse':
1356
1410
  // Auth-gated like every other request: directory enumeration must never
1357
1411
  // precede authentication. Failures answer ok:false rather than throwing
@@ -1570,6 +1624,39 @@ export class SidecarServer {
1570
1624
  }
1571
1625
  void this.onCrewDeploy(m);
1572
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;
1573
1660
  case 'workflow-package-put':
1574
1661
  case 'workflow-results-list':
1575
1662
  case 'workflow-results-take':
@@ -1766,6 +1853,7 @@ export class SidecarServer {
1766
1853
  case 'mcp-remove':
1767
1854
  case 'mcp-set-credential':
1768
1855
  case 'mcp-login':
1856
+ case 'mcp-allow-set':
1769
1857
  if (!this.authed) {
1770
1858
  this.ch.send({ t: 'mcp-admin-error', id: m.id, error: 'not authenticated' });
1771
1859
  return;
@@ -2497,7 +2585,8 @@ export class SidecarServer {
2497
2585
  this.askSeat = seat;
2498
2586
  this.askSeatLeave = this.askUsers().join(seat);
2499
2587
  }
2500
- 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) {
2501
2590
  /* A pending member (Part E) is never a recipient of anything: every
2502
2591
  * send below checks the status at the moment it is made, so a member
2503
2592
  * approved later starts receiving without reconnecting. */
@@ -2742,6 +2831,8 @@ export class SidecarServer {
2742
2831
  ...(this.opts.runs ? [CHILD_EVENTS_FEATURE] : []),
2743
2832
  // Whether a write another client makes reaches this one as fs-changed.
2744
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] : []),
2745
2836
  ...(this.opts.acp ? ['acp'] : []),
2746
2837
  ...(this.opts.roots?.areas.length ? ['add-roots'] : []),
2747
2838
  ...(this.opts.roots?.areas.length && this.opts.roots.create ? ['create-roots'] : []),
@@ -2771,6 +2862,9 @@ export class SidecarServer {
2771
2862
  // Undeploy only where the daemon would record them -- anywhere else a
2772
2863
  // client shows the old read-only view.
2773
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] : []),
2774
2868
  // Whether this daemon runs deployed workflows. Only with a real port, so
2775
2869
  // a client offers the Sidecar deploy target only where it would run.
2776
2870
  ...(this.opts.workflows ? [WORKFLOW_RUNNER_FEATURE] : []),
@@ -2803,6 +2897,7 @@ export class SidecarServer {
2803
2897
  // gives regardless of this flag.
2804
2898
  ...(this.opts.mcp ? ['mcp'] : []),
2805
2899
  ...(this.opts.mcpAdmin ? ['mcp-admin'] : []),
2900
+ ...(this.opts.mcpAdmin?.setAllow ? [MCP_ALLOW_FEATURE] : []),
2806
2901
  // Whether this daemon serves its own Harness Config. Advertised only with
2807
2902
  // a real (allowlisted) service behind it, so a client never shows a
2808
2903
  // daemon's definitions as an empty workspace when what it actually has is
@@ -3423,6 +3518,11 @@ export class SidecarServer {
3423
3518
  */
3424
3519
  async onRunHistory(m) {
3425
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
+ }
3426
3526
  const history = this.opts.runner?.history?.bind(this.opts.runner);
3427
3527
  const crew = runHistoryCrewOf(m.crew);
3428
3528
  const identity = { clientId: this.clientId };
@@ -3445,6 +3545,11 @@ export class SidecarServer {
3445
3545
  * case, and a state the caller renders rather than treats as a failure. */
3446
3546
  async onRunTranscript(m) {
3447
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
+ }
3448
3553
  const transcript = this.opts.runner?.transcript?.bind(this.opts.runner);
3449
3554
  const identity = { clientId: this.clientId };
3450
3555
  const lines = !transcript ? []
@@ -3463,6 +3568,11 @@ export class SidecarServer {
3463
3568
  * a path. */
3464
3569
  async onCrewMemory(m) {
3465
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
+ }
3466
3576
  const runner = this.opts.runner;
3467
3577
  // The viewer is THIS connection's verified user (team agent access).
3468
3578
  const memories = !runner?.memories ? []
@@ -3490,6 +3600,13 @@ export class SidecarServer {
3490
3600
  return;
3491
3601
  }
3492
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
+ }
3493
3610
  /* WHERE the run happens, decided once, up front.
3494
3611
  *
3495
3612
  * A `sessionId` is a confinement request. A daemon that cannot honour it
@@ -3603,7 +3720,7 @@ export class SidecarServer {
3603
3720
  * verified user and never a message field; with none it is refused. The
3604
3721
  * events, `run-accepted` (with the conversation id), `run-done` and the hub
3605
3722
  * publishing follow `onRun`. A refused or failed turn sends `run-error`. */
3606
- onChatRun(m) {
3723
+ async onChatRun(m) {
3607
3724
  const refuse = (error) => { this.ch.send({ t: 'run-error', id: m.id, error }); };
3608
3725
  const port = this.opts.runner?.chat;
3609
3726
  if (!port) {
@@ -3632,6 +3749,15 @@ export class SidecarServer {
3632
3749
  refuse(spec);
3633
3750
  return;
3634
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
+ }
3635
3761
  const agent = m.agent;
3636
3762
  const hub = this.opts.runs;
3637
3763
  let runId;
@@ -3772,6 +3898,11 @@ export class SidecarServer {
3772
3898
  }
3773
3899
  const { port, ...rest } = q;
3774
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
+ }
3775
3906
  const chats = await port.list({ ...rest, identity: { clientId: this.clientId } });
3776
3907
  this.ch.send({ t: 'chat-list-result', id: m.id, ok: true, chats: chats.map(chatSummaryOf) });
3777
3908
  }
@@ -3795,6 +3926,11 @@ export class SidecarServer {
3795
3926
  }
3796
3927
  const { port, ...rest } = q;
3797
3928
  try {
3929
+ const denied = await this.hostFolderDenial();
3930
+ if (denied !== null) {
3931
+ refuse(denied);
3932
+ return;
3933
+ }
3798
3934
  const conversation = await port.get({ ...rest, identity: { clientId: this.clientId }, conversationId: m.conversationId });
3799
3935
  this.ch.send({ t: 'chat-get-result', id: m.id, ok: true, conversation: storedChatOf(conversation) });
3800
3936
  }
@@ -4104,6 +4240,127 @@ export class SidecarServer {
4104
4240
  ...(this.verifiedUserId !== undefined ? { verifiedUserId: this.verifiedUserId } : {}),
4105
4241
  };
4106
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
+ }
4107
4364
  async onCrewDeploy(m) {
4108
4365
  // A frame with no usable correlation id is dropped before the port: no
4109
4366
  // change is made that its sender could never match to an answer.
@@ -4145,25 +4402,49 @@ export class SidecarServer {
4145
4402
  answer({ ok: false, error: errMessage(err) });
4146
4403
  }
4147
4404
  }
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() {
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() {
4152
4410
  if (this.verifiedUserId === undefined)
4153
- return WORKFLOWS_VERIFY_FIRST;
4411
+ return 'unverified';
4154
4412
  const lookup = this.opts.workflowsFolderOwner;
4155
4413
  if (this.dirName === undefined || !lookup)
4156
- return WORKFLOWS_FOLDER_UNBOUND;
4414
+ return 'unbound';
4157
4415
  let bound;
4158
4416
  try {
4159
4417
  bound = await lookup(this.dirName);
4160
4418
  }
4161
4419
  catch {
4162
- return WORKFLOWS_FOLDER_UNBOUND;
4420
+ return 'unbound';
4163
4421
  }
4164
4422
  if (bound === undefined)
4165
- return WORKFLOWS_FOLDER_UNBOUND;
4166
- 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
+ }
4167
4448
  }
4168
4449
  async onWorkflows(m) {
4169
4450
  // A frame with no usable correlation id is dropped before the port: no
@@ -4933,7 +5214,7 @@ export class SidecarServer {
4933
5214
  if (m.t !== 'mcp-servers-list') {
4934
5215
  const refusal = this.mcpRefusal();
4935
5216
  if (refusal) {
4936
- 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' });
4937
5218
  return;
4938
5219
  }
4939
5220
  }
@@ -4954,8 +5235,9 @@ export class SidecarServer {
4954
5235
  * branch that is not part of it runs setCredential too, with whatever
4955
5236
  * `m.header`/`m.value` happen to be (undefined, for every other message).
4956
5237
  * That is exactly what a stray `if` here did before this comment existed. */
5238
+ let applied;
4957
5239
  if (m.t === 'mcp-add')
4958
- 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);
4959
5241
  else if (m.t === 'mcp-login') {
4960
5242
  /* The consent URL is pushed the moment it exists, BEFORE the flow
4961
5243
  * blocks waiting for a human. Holding it until the end would deliver it
@@ -4965,15 +5247,48 @@ export class SidecarServer {
4965
5247
  this.ch.send({ t: 'mcp-admin-error', id: m.id, error: out.message, reason: out.reason });
4966
5248
  return;
4967
5249
  }
5250
+ applied = out.reloadError !== undefined ? { reloadError: out.reloadError } : undefined;
4968
5251
  }
4969
5252
  else if (m.t === 'mcp-remove')
4970
- 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
+ }
4971
5267
  else
4972
- await admin.setCredential(m.name, m.header, m.value);
4973
- 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
+ });
4974
5288
  }
4975
5289
  catch (e) {
4976
- 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 } : {}) });
4977
5292
  }
4978
5293
  }
4979
5294
  /** Part D credential messages. The caller is this connection's VERIFIED
@@ -5969,6 +6284,13 @@ export class SidecarServer {
5969
6284
  this.ch.send({ t: 'host-sync-result', id: m.id, ok: false, error: 'this sidecar cannot sync host config' });
5970
6285
  return;
5971
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
+ }
5972
6294
  try {
5973
6295
  await this.opts.hostSync.put(identity.clientId, m.path, m.content);
5974
6296
  this.ch.send({ t: 'host-sync-result', id: m.id, ok: true });
@@ -5987,6 +6309,13 @@ export class SidecarServer {
5987
6309
  this.ch.send({ t: 'host-sync-result', id: m.id, ok: false, error: 'this sidecar cannot sync host config' });
5988
6310
  return;
5989
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
+ }
5990
6319
  try {
5991
6320
  await this.opts.hostSync.remove(identity.clientId, m.path);
5992
6321
  this.ch.send({ t: 'host-sync-result', id: m.id, ok: true });