@corenel/harness 0.19.0 → 0.21.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 (163) hide show
  1. package/dist/chat/engine.d.ts +47 -0
  2. package/dist/chat/engine.d.ts.map +1 -0
  3. package/dist/chat/engine.js +803 -0
  4. package/dist/chat/engine.js.map +1 -0
  5. package/dist/chat/events.d.ts +5 -0
  6. package/dist/chat/events.d.ts.map +1 -0
  7. package/dist/chat/events.js +100 -0
  8. package/dist/chat/events.js.map +1 -0
  9. package/dist/chat/index.d.ts +9 -0
  10. package/dist/chat/index.d.ts.map +1 -0
  11. package/dist/chat/index.js +9 -0
  12. package/dist/chat/index.js.map +1 -0
  13. package/dist/chat/liveFold.d.ts +10 -0
  14. package/dist/chat/liveFold.d.ts.map +1 -0
  15. package/dist/chat/liveFold.js +31 -0
  16. package/dist/chat/liveFold.js.map +1 -0
  17. package/dist/chat/messages.d.ts +20 -0
  18. package/dist/chat/messages.d.ts.map +1 -0
  19. package/dist/chat/messages.js +56 -0
  20. package/dist/chat/messages.js.map +1 -0
  21. package/dist/chat/persist.d.ts +39 -0
  22. package/dist/chat/persist.d.ts.map +1 -0
  23. package/dist/chat/persist.js +107 -0
  24. package/dist/chat/persist.js.map +1 -0
  25. package/dist/chat/runWatchdog.d.ts +21 -0
  26. package/dist/chat/runWatchdog.d.ts.map +1 -0
  27. package/dist/chat/runWatchdog.js +92 -0
  28. package/dist/chat/runWatchdog.js.map +1 -0
  29. package/dist/chat/stitch.d.ts +26 -0
  30. package/dist/chat/stitch.d.ts.map +1 -0
  31. package/dist/chat/stitch.js +40 -0
  32. package/dist/chat/stitch.js.map +1 -0
  33. package/dist/chat/types.d.ts +163 -0
  34. package/dist/chat/types.d.ts.map +1 -0
  35. package/dist/chat/types.js +2 -0
  36. package/dist/chat/types.js.map +1 -0
  37. package/dist/core/loop.d.ts.map +1 -1
  38. package/dist/core/loop.js +50 -15
  39. package/dist/core/loop.js.map +1 -1
  40. package/dist/core/spawn.d.ts +17 -0
  41. package/dist/core/spawn.d.ts.map +1 -1
  42. package/dist/core/spawn.js +48 -14
  43. package/dist/core/spawn.js.map +1 -1
  44. package/dist/fileSchema.js +1 -1
  45. package/dist/guardrail/builtins.d.ts.map +1 -1
  46. package/dist/guardrail/builtins.js +11 -6
  47. package/dist/guardrail/builtins.js.map +1 -1
  48. package/dist/guardrail/glob.d.ts +1 -2
  49. package/dist/guardrail/glob.d.ts.map +1 -1
  50. package/dist/guardrail/glob.js +4 -29
  51. package/dist/guardrail/glob.js.map +1 -1
  52. package/dist/guardrail/guardrail.d.ts +8 -0
  53. package/dist/guardrail/guardrail.d.ts.map +1 -1
  54. package/dist/guardrail/guardrail.js +12 -0
  55. package/dist/guardrail/guardrail.js.map +1 -1
  56. package/dist/guardrail/index.d.ts +1 -1
  57. package/dist/guardrail/index.d.ts.map +1 -1
  58. package/dist/guardrail/index.js +1 -1
  59. package/dist/guardrail/index.js.map +1 -1
  60. package/dist/guardrail/serialize.d.ts.map +1 -1
  61. package/dist/guardrail/serialize.js +3 -0
  62. package/dist/guardrail/serialize.js.map +1 -1
  63. package/dist/guardrail/validate.d.ts.map +1 -1
  64. package/dist/guardrail/validate.js +4 -0
  65. package/dist/guardrail/validate.js.map +1 -1
  66. package/dist/install/lockfile.d.ts +25 -5
  67. package/dist/install/lockfile.d.ts.map +1 -1
  68. package/dist/install/lockfile.js +60 -4
  69. package/dist/install/lockfile.js.map +1 -1
  70. package/dist/install/nameEntries.d.ts +28 -0
  71. package/dist/install/nameEntries.d.ts.map +1 -0
  72. package/dist/install/nameEntries.js +198 -0
  73. package/dist/install/nameEntries.js.map +1 -0
  74. package/dist/install/pinRefusal.d.ts +4 -0
  75. package/dist/install/pinRefusal.d.ts.map +1 -0
  76. package/dist/install/pinRefusal.js +19 -0
  77. package/dist/install/pinRefusal.js.map +1 -0
  78. package/dist/install/requirements.d.ts +7 -0
  79. package/dist/install/requirements.d.ts.map +1 -0
  80. package/dist/install/requirements.js +23 -0
  81. package/dist/install/requirements.js.map +1 -0
  82. package/dist/orchestrator/sidecarTools.d.ts.map +1 -1
  83. package/dist/orchestrator/sidecarTools.js +8 -4
  84. package/dist/orchestrator/sidecarTools.js.map +1 -1
  85. package/dist/prompts/fitProject.d.ts +6 -0
  86. package/dist/prompts/fitProject.d.ts.map +1 -0
  87. package/dist/prompts/fitProject.js +6 -0
  88. package/dist/prompts/fitProject.js.map +1 -0
  89. package/dist/prompts/library/defaults.generated.js +7 -7
  90. package/dist/prompts/library/defaults.generated.js.map +1 -1
  91. package/dist/prompts/library/index.d.ts +2 -1
  92. package/dist/prompts/library/index.d.ts.map +1 -1
  93. package/dist/prompts/library/index.js +11 -16
  94. package/dist/prompts/library/index.js.map +1 -1
  95. package/dist/providers/egressGuard.d.ts +17 -0
  96. package/dist/providers/egressGuard.d.ts.map +1 -0
  97. package/dist/providers/egressGuard.js +26 -0
  98. package/dist/providers/egressGuard.js.map +1 -0
  99. package/dist/providers/gateway.d.ts +34 -0
  100. package/dist/providers/gateway.d.ts.map +1 -1
  101. package/dist/providers/gateway.js +43 -2
  102. package/dist/providers/gateway.js.map +1 -1
  103. package/dist/sidecar/askUserBroker.d.ts +40 -0
  104. package/dist/sidecar/askUserBroker.d.ts.map +1 -0
  105. package/dist/sidecar/askUserBroker.js +103 -0
  106. package/dist/sidecar/askUserBroker.js.map +1 -0
  107. package/dist/sidecar/fleetJwe.d.ts +25 -0
  108. package/dist/sidecar/fleetJwe.d.ts.map +1 -0
  109. package/dist/sidecar/fleetJwe.js +115 -0
  110. package/dist/sidecar/fleetJwe.js.map +1 -0
  111. package/dist/sidecar/fleetJws.d.ts +15 -0
  112. package/dist/sidecar/fleetJws.d.ts.map +1 -0
  113. package/dist/sidecar/fleetJws.js +47 -0
  114. package/dist/sidecar/fleetJws.js.map +1 -0
  115. package/dist/sidecar/host.d.ts +81 -1
  116. package/dist/sidecar/host.d.ts.map +1 -1
  117. package/dist/sidecar/host.js +227 -6
  118. package/dist/sidecar/host.js.map +1 -1
  119. package/dist/sidecar/identity.d.ts +9 -0
  120. package/dist/sidecar/identity.d.ts.map +1 -1
  121. package/dist/sidecar/memberGate.d.ts.map +1 -1
  122. package/dist/sidecar/memberGate.js +5 -0
  123. package/dist/sidecar/memberGate.js.map +1 -1
  124. package/dist/sidecar/server.d.ts +61 -10
  125. package/dist/sidecar/server.d.ts.map +1 -1
  126. package/dist/sidecar/server.js +592 -15
  127. package/dist/sidecar/server.js.map +1 -1
  128. package/dist/skills.d.ts +18 -0
  129. package/dist/skills.d.ts.map +1 -1
  130. package/dist/skills.js +93 -12
  131. package/dist/skills.js.map +1 -1
  132. package/dist/systemParts.d.ts +0 -1
  133. package/dist/systemParts.d.ts.map +1 -1
  134. package/dist/systemParts.js +6 -2
  135. package/dist/systemParts.js.map +1 -1
  136. package/dist/todos.d.ts +8 -0
  137. package/dist/todos.d.ts.map +1 -1
  138. package/dist/todos.js +21 -17
  139. package/dist/todos.js.map +1 -1
  140. package/dist/tools/builtins.d.ts +8 -0
  141. package/dist/tools/builtins.d.ts.map +1 -1
  142. package/dist/tools/builtins.js +21 -1
  143. package/dist/tools/builtins.js.map +1 -1
  144. package/dist/tools/namespaces.d.ts +8 -0
  145. package/dist/tools/namespaces.d.ts.map +1 -1
  146. package/dist/tools/namespaces.js +18 -0
  147. package/dist/tools/namespaces.js.map +1 -1
  148. package/dist/tools/skillTool.d.ts.map +1 -1
  149. package/dist/tools/skillTool.js +22 -1
  150. package/dist/tools/skillTool.js.map +1 -1
  151. package/dist/tools/toolFacts.d.ts +9 -0
  152. package/dist/tools/toolFacts.d.ts.map +1 -0
  153. package/dist/tools/toolFacts.js +19 -0
  154. package/dist/tools/toolFacts.js.map +1 -0
  155. package/dist/tools/toolSearch.d.ts +25 -6
  156. package/dist/tools/toolSearch.d.ts.map +1 -1
  157. package/dist/tools/toolSearch.js +62 -14
  158. package/dist/tools/toolSearch.js.map +1 -1
  159. package/dist/tools/workflowTool.d.ts +34 -0
  160. package/dist/tools/workflowTool.d.ts.map +1 -0
  161. package/dist/tools/workflowTool.js +122 -0
  162. package/dist/tools/workflowTool.js.map +1 -0
  163. package/package.json +8 -4
@@ -1,9 +1,10 @@
1
- import { decodePcm16, 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, 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';
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
+ import { AskUserBroker } from './askUserBroker.js';
7
8
  /** A workflow reply's refusal, cut to the length a host accepts
8
9
  * (MAX_WORKFLOW_REPLY_ERROR): a longer one is refused whole by the host's
9
10
  * reply check, and the person would lose the reason. */
@@ -41,6 +42,9 @@ export const WATCH_REFUSED = 'You can watch only runs you started, or unattended
41
42
  export const ACCESS_NO_ROLE = 'NO_ACCESS: you have no role on this agent, or too low a role for this.';
42
43
  export const ACCESS_NOT_OWNER = "NOT_OWNER: only this agent's owner or the daemon admin can change it.";
43
44
  export const ACCESS_ADMIN_ONLY = 'NOT_OWNER: only the daemon admin can change the crew folder itself.';
45
+ /** A team agent's chats (`crew/<agent>/chats/**`) are written only by the
46
+ * daemon's chat store; no client may write or delete them over the config channel. */
47
+ export const ACCESS_CHAT_FILES = 'NOT_OWNER: chats are kept by the daemon and cannot be changed or deleted here.';
44
48
  export const ACCESS_STORE_REFUSED = 'The access store is never writable.';
45
49
  export const ACCESS_CHECK_FAILED = 'NOT_OWNER: could not check access.';
46
50
  export const ACCESS_FAILED = 'Access could not be checked or changed. The daemon log has the reason.';
@@ -211,6 +215,146 @@ function agentRunsRefOf(x) {
211
215
  default: return null;
212
216
  }
213
217
  }
218
+ /* DAEMON CHAT INPUT (Harness Chat Plan 2). Validated here like the
219
+ * neighbouring messages, before the chat port sees any of it; the port checks
220
+ * ids again before a file is touched. */
221
+ /** A chat turn's text and one injected message: at most this many characters. */
222
+ const CHAT_MAX_TEXT = 200000;
223
+ const CHAT_MAX_AGENT_LEN = 128;
224
+ /** Messages in one run-inject. */
225
+ const CHAT_MAX_INJECT = 20;
226
+ const CHAT_MAX_RUN_ID_LEN = 200;
227
+ const CHAT_MAX_MESSAGE_ID_LEN = 128;
228
+ const CHAT_MAX_REASON_LEN = 500;
229
+ /** An ask_user answer: questions, choices per question, and their lengths. */
230
+ const CHAT_MAX_ANSWER_KEYS = 32;
231
+ const CHAT_MAX_ANSWER_CHOICES = 32;
232
+ const CHAT_MAX_ANSWER_LEN = 2000;
233
+ /** The ask-user deadline shown when the runner gave none: its default park
234
+ * timeout (PARK_TIMEOUT_MS in packages/sidecar, which this package cannot
235
+ * import). The runner's own bound still decides; this is only what is shown. */
236
+ const CHAT_ASK_FALLBACK_MS = 30 * 60 * 1000;
237
+ /** The runner's RUN_NO_ACCESS text: a chat needs a verified person. */
238
+ const CHAT_NO_ACCESS = 'You do not have access to run this agent.';
239
+ const CHAT_UNAVAILABLE = 'this sidecar does not hold chats';
240
+ const CHAT_NOT_ALONE = 'A chat turn runs on its own; it cannot take a session or a task.';
241
+ const CHAT_BAD_AGENT = 'That is not a crew agent name.';
242
+ const CHAT_BAD_TEXT = `A chat turn needs text of at most ${CHAT_MAX_TEXT} characters.`;
243
+ const CHAT_BAD_SPEC = 'chat must be an object.';
244
+ const CHAT_BAD_ID = 'CHAT_BAD_ID: that is not a chat id';
245
+ const CHAT_BAD_MEMBER = 'CHAT_BAD_ID: that is not a member id';
246
+ const CHAT_BAD_LIMIT = 'A chat limit must be a positive number of dollars.';
247
+ const CHAT_BAD_RUN = 'That is not a run id.';
248
+ const CHAT_BAD_INJECT = `Send 1 to ${CHAT_MAX_INJECT} messages, each with an id and text.`;
249
+ const CHAT_INJECT_FAILED = 'Could not deliver the messages.';
250
+ const CHAT_READ_FAILED = 'Could not read the chats.';
251
+ function chatAgentOk(a) {
252
+ return typeof a === 'string' && a.length <= CHAT_MAX_AGENT_LEN && AGENT_RUNS_NAME_RE.test(a);
253
+ }
254
+ function chatIdOk(x) {
255
+ return typeof x === 'string' && CHAT_ID_RE.test(x);
256
+ }
257
+ function plainObject(x) {
258
+ return typeof x === 'object' && x !== null && !Array.isArray(x);
259
+ }
260
+ /** A chat message's `ref`: absent, or a valid ref naming the same agent.
261
+ * Null is a refusal. */
262
+ function chatRefOf(x, agent) {
263
+ if (x === undefined)
264
+ return {};
265
+ const ref = agentRunsRefOf(x);
266
+ return ref !== null && ref.agent === agent ? { ref } : null;
267
+ }
268
+ /** `run.chat` rebuilt from its own fields, or the refusal to send. */
269
+ function chatSpecOf(x, agent) {
270
+ if (!plainObject(x))
271
+ return CHAT_BAD_SPEC;
272
+ if (x.conversationId !== undefined && !chatIdOk(x.conversationId))
273
+ return CHAT_BAD_ID;
274
+ const maxUsd = x.maxUsd;
275
+ if (maxUsd !== undefined && !(typeof maxUsd === 'number' && Number.isFinite(maxUsd) && maxUsd > 0))
276
+ return CHAT_BAD_LIMIT;
277
+ const ref = chatRefOf(x.ref, agent);
278
+ if (ref === null)
279
+ return AGENT_RUNS_BAD_REF;
280
+ return {
281
+ conversationId: typeof x.conversationId === 'string' ? x.conversationId : null,
282
+ ...(typeof maxUsd === 'number' ? { maxUsd } : {}),
283
+ ...ref,
284
+ };
285
+ }
286
+ /** run-inject's messages rebuilt, or null when any is malformed. */
287
+ function injectMessagesOf(x) {
288
+ if (!Array.isArray(x) || x.length === 0 || x.length > CHAT_MAX_INJECT)
289
+ return null;
290
+ const out = [];
291
+ for (const m of x) {
292
+ if (!plainObject(m))
293
+ return null;
294
+ const { id, text } = m;
295
+ if (typeof id !== 'string' || !id || id.length > CHAT_MAX_MESSAGE_ID_LEN || hasControlChar(id))
296
+ return null;
297
+ if (typeof text !== 'string' || !text.trim() || text.length > CHAT_MAX_TEXT)
298
+ return null;
299
+ out.push({ id, text });
300
+ }
301
+ return out;
302
+ }
303
+ /** An ask-user answer rebuilt: null (declined), the answers, or undefined
304
+ * when it is malformed (then it is ignored, and the ask stays open). */
305
+ function askAnswersOf(x) {
306
+ if (x === null)
307
+ return null;
308
+ if (!plainObject(x))
309
+ return undefined;
310
+ const entries = Object.entries(x);
311
+ if (entries.length > CHAT_MAX_ANSWER_KEYS)
312
+ return undefined;
313
+ const out = [];
314
+ for (const [key, choices] of entries) {
315
+ if (key.length > CHAT_MAX_ANSWER_LEN || !Array.isArray(choices) || choices.length > CHAT_MAX_ANSWER_CHOICES)
316
+ return undefined;
317
+ const kept = [];
318
+ for (const c of choices) {
319
+ if (typeof c !== 'string' || c.length > CHAT_MAX_ANSWER_LEN)
320
+ return undefined;
321
+ kept.push(c);
322
+ }
323
+ out.push([key, kept]);
324
+ }
325
+ return Object.fromEntries(out);
326
+ }
327
+ /** A summary rebuilt from its own fields, so a port that returned more could
328
+ * never put it on the wire. */
329
+ function chatSummaryOf(c) {
330
+ return {
331
+ conversationId: String(c.conversationId), agent: String(c.agent), title: String(c.title),
332
+ updatedAt: Number(c.updatedAt), turns: Number(c.turns), usd: Number(c.usd),
333
+ };
334
+ }
335
+ function storedChatOf(c) {
336
+ return {
337
+ id: String(c.id), agent: String(c.agent), requester: String(c.requester), title: String(c.title),
338
+ messages: Array.isArray(c.messages) ? c.messages : [], updatedAt: Number(c.updatedAt),
339
+ ...(c.titleLocked !== undefined ? { titleLocked: c.titleLocked === true } : {}),
340
+ ...(typeof c.model === 'string' ? { model: c.model } : {}),
341
+ ...(typeof c.system === 'string' ? { system: c.system } : {}),
342
+ ...(typeof c.maxUsd === 'number' ? { maxUsd: c.maxUsd } : {}),
343
+ };
344
+ }
345
+ /** The text of a chat read failure a reply may carry: a coded refusal, or the
346
+ * runner's own wording about the agent. Anything else (a path, an errno) is
347
+ * null, and the caller logs it and sends fixed text. */
348
+ function chatErrorOf(err) {
349
+ const msg = errMessage(err);
350
+ if (msg.length > CHAT_MAX_REASON_LEN)
351
+ return null;
352
+ if (/^CHAT_[A-Z_]+: /.test(msg) || msg === CHAT_NO_ACCESS)
353
+ return msg;
354
+ if (/^(crew agent not found|invalid crew agent name): [A-Za-z0-9_-]*$/.test(msg))
355
+ return msg;
356
+ return null;
357
+ }
214
358
  /** The access code a thrown refusal carries, when it is one a reply may name. */
215
359
  function accessCodeOf(err) {
216
360
  const code = typeof err === 'object' && err !== null ? err.code : undefined;
@@ -566,6 +710,13 @@ export class SidecarServer {
566
710
  writable: true,
567
711
  value: undefined
568
712
  });
713
+ /** The identity this connection verified as, for re-asking admission when it changes. */
714
+ Object.defineProperty(this, "verifiedIdentity", {
715
+ enumerable: true,
716
+ configurable: true,
717
+ writable: true,
718
+ value: undefined
719
+ });
569
720
  /** The verified person's display name/email, set with verifiedUserId. */
570
721
  Object.defineProperty(this, "verifiedPerson", {
571
722
  enumerable: true,
@@ -628,6 +779,27 @@ export class SidecarServer {
628
779
  writable: true,
629
780
  value: void 0
630
781
  });
782
+ /** This connection's seat for chat ask_user (verified connections only),
783
+ * and its leave function, called once on close. */
784
+ Object.defineProperty(this, "askSeat", {
785
+ enumerable: true,
786
+ configurable: true,
787
+ writable: true,
788
+ value: void 0
789
+ });
790
+ Object.defineProperty(this, "askSeatLeave", {
791
+ enumerable: true,
792
+ configurable: true,
793
+ writable: true,
794
+ value: void 0
795
+ });
796
+ /** Used when the daemon wired no shared broker: this connection only. */
797
+ Object.defineProperty(this, "ownAskUsers", {
798
+ enumerable: true,
799
+ configurable: true,
800
+ writable: true,
801
+ value: void 0
802
+ });
631
803
  /** Detach from the peer routes, when this connection serves MCP. */
632
804
  Object.defineProperty(this, "peersDetach", {
633
805
  enumerable: true,
@@ -865,12 +1037,18 @@ export class SidecarServer {
865
1037
  * A member REMOVED while connected is disconnected at once, peer daemon
866
1038
  * connections of theirs included: every stream this connection holds
867
1039
  * (run events, session logs, process output, broadcasts) stops here, not
868
- * whenever the transport gets round to reporting the close. */
1040
+ * whenever the transport gets round to reporting the close.
1041
+ * A user the fleet policy newly blocks (slice 4) is disconnected the same
1042
+ * way; nothing is revoked. A browser that was connected redials, and its
1043
+ * redial is refused at the handshake with FLEET_LOGINS, which it can
1044
+ * explain. */
869
1045
  const offMembers = opts.members?.subscribe((userId, event) => {
870
1046
  if (!this.authed || this.closed || userId !== this.verifiedUserId)
871
1047
  return;
872
- if (event === 'removed') {
873
- this.opts.log?.('member removed; closing their connection');
1048
+ if (event === 'removed' || event === 'blocked') {
1049
+ this.opts.log?.(event === 'removed'
1050
+ ? 'member removed; closing their connection'
1051
+ : 'sign-in limited by fleet policy; closing their connection');
874
1052
  teardown();
875
1053
  this.ch.close();
876
1054
  return;
@@ -878,6 +1056,18 @@ export class SidecarServer {
878
1056
  if (event === 'active' && !this.peerDaemon)
879
1057
  this.ch.send({ t: 'member-status-changed', status: 'active' });
880
1058
  });
1059
+ /* Fleet mode: the admission decision follows the fleet state. A session it
1060
+ * no longer admits (sign-in closed, or another org now named) is closed at
1061
+ * once, not at the next re-check (spec 2026-10-18, section 4). */
1062
+ const offAdmission = opts.admission?.onChange?.(() => {
1063
+ if (!this.authed || this.closed || !this.verifiedIdentity)
1064
+ return;
1065
+ if (this.admits(this.verifiedIdentity))
1066
+ return;
1067
+ this.opts.log?.('sign-in closed by the fleet state; closing the connection');
1068
+ teardown();
1069
+ this.ch.close();
1070
+ });
881
1071
  /* Runs once, from the transport's close or from a member removal above. */
882
1072
  let tornDown = false;
883
1073
  const teardown = () => {
@@ -909,6 +1099,9 @@ export class SidecarServer {
909
1099
  this.identityAssertResolve = undefined;
910
1100
  this.attendedDetach?.();
911
1101
  this.attendedDetach = undefined;
1102
+ this.askSeatLeave?.();
1103
+ this.askSeatLeave = undefined;
1104
+ this.askSeat = undefined;
912
1105
  this.peersDetach?.();
913
1106
  this.peersDetach = undefined;
914
1107
  // Leave the live set, or this directory reads as connected for ever and
@@ -923,6 +1116,7 @@ export class SidecarServer {
923
1116
  offRoots?.();
924
1117
  offRooms?.();
925
1118
  offMembers?.();
1119
+ offAdmission?.();
926
1120
  // Stop streaming process output into a dead socket. The PROCESSES keep
927
1121
  // running - that is the point of the table living on the daemon - but this
928
1122
  // connection stops hearing about them until something re-attaches.
@@ -1039,8 +1233,47 @@ export class SidecarServer {
1039
1233
  this.ch.send({ t: 'run-error', id: m.id, error: 'not authenticated' });
1040
1234
  return;
1041
1235
  }
1236
+ // A chat turn (Harness Chat Plan 2) takes its own path: the daemon's chat port, never runner.run.
1237
+ if (m.chat !== undefined) {
1238
+ this.onChatRun(m);
1239
+ return;
1240
+ }
1042
1241
  void this.onRun(m);
1043
1242
  return;
1243
+ case 'run-inject':
1244
+ if (!this.authed) {
1245
+ this.ch.send({ t: 'run-inject-result', id: m.id, ok: false, reason: 'not authenticated' });
1246
+ return;
1247
+ }
1248
+ void this.onRunInject(m);
1249
+ return;
1250
+ case 'chat-list':
1251
+ if (!this.authed) {
1252
+ this.ch.send({ t: 'chat-list-result', id: m.id, ok: false, error: 'not authenticated' });
1253
+ return;
1254
+ }
1255
+ void this.onChatList(m);
1256
+ return;
1257
+ case 'chat-get':
1258
+ if (!this.authed) {
1259
+ this.ch.send({ t: 'chat-get-result', id: m.id, ok: false, error: 'not authenticated' });
1260
+ return;
1261
+ }
1262
+ void this.onChatGet(m);
1263
+ return;
1264
+ case 'ask-user-answer': {
1265
+ // No reply channel, like permission-answer: before authentication, or
1266
+ // from a connection with no seat, it is dropped. The broker ignores an
1267
+ // answer from a seat the ask was not shown to, or for a settled id.
1268
+ if (!this.authed)
1269
+ return;
1270
+ const seat = this.askSeat;
1271
+ const answers = askAnswersOf(m.answers);
1272
+ if (!seat || !Number.isSafeInteger(m.id) || answers === undefined)
1273
+ return;
1274
+ this.askUsers().answer(m.id, seat, answers);
1275
+ return;
1276
+ }
1044
1277
  case 'subscribe':
1045
1278
  // Guarded by auth like `run` (auth boundary). Pre-auth: drop silently —
1046
1279
  // subscribe has no response channel, so no message is the correct
@@ -1597,6 +1830,24 @@ export class SidecarServer {
1597
1830
  this.noteOutcome('denied');
1598
1831
  this.countFailedHello();
1599
1832
  }
1833
+ /** Whether the policy admits nobody for now, for a reason that clears by itself. A throw reads as not paused. */
1834
+ paused() {
1835
+ try {
1836
+ return this.opts.admission?.paused?.() === true;
1837
+ }
1838
+ catch (err) {
1839
+ this.opts.log?.(`admission paused() threw: ${errMessage(err)}`);
1840
+ return false;
1841
+ }
1842
+ }
1843
+ /** Refuse a PROVEN device while sign-in is paused, saying so, and close: a retry is a fresh connection. Not a
1844
+ * failed credential, so it is not counted toward MAX_FAILED_HELLOS or against the address. */
1845
+ denyPaused() {
1846
+ this.opts.log?.('denied: sign-in is paused until the fleet state allows it again');
1847
+ this.ch.send({ t: 'denied', reason: SIGN_IN_PAUSED_DENIED });
1848
+ this.noteOutcome('paused');
1849
+ this.ch.close();
1850
+ }
1600
1851
  /** Tell the daemon's rate limiter how a hello ended. Its failure is never
1601
1852
  * the handshake's. */
1602
1853
  noteOutcome(outcome) {
@@ -1641,23 +1892,27 @@ export class SidecarServer {
1641
1892
  * caller's uniform denial on the wire. */
1642
1893
  async identityGate(jkt, boundUser) {
1643
1894
  if (!this.opts.identity)
1644
- return true;
1895
+ return 'ok';
1645
1896
  const sidecarId = this.opts.info?.id;
1646
1897
  if (!sidecarId) {
1647
1898
  this.opts.log?.('identity: refused (this daemon has no id for an assertion to name)');
1648
- return false;
1899
+ return 'refused';
1649
1900
  }
1650
1901
  const nonce = makeNonce();
1651
1902
  const orgId = this.opts.admission?.orgId;
1652
1903
  this.ch.send({ t: 'identity-challenge', nonce, sidecarId, ...(orgId ? { orgId } : {}), ...(this.challengeIssuer() ? { issuer: this.challengeIssuer() } : {}) });
1904
+ /* PAUSED: after the challenge (so an older client still reads an account refusal and keeps its device), and
1905
+ * without waiting for an assertion: nobody would be admitted whatever it said. */
1906
+ if (this.paused())
1907
+ return 'paused';
1653
1908
  const verdict = await this.awaitAndVerifyIdentity(sidecarId, nonce, jkt, IDENTITY_TIMEOUT_MS);
1654
1909
  if (!verdict.ok) {
1655
1910
  this.opts.log?.(`identity: unverified (${verdict.reason})`);
1656
- return false;
1911
+ return 'refused';
1657
1912
  }
1658
1913
  if (!this.admits(verdict.identity)) {
1659
1914
  this.opts.log?.(orgId ? `identity: verified, but not a member of ${orgId}` : 'identity: verified, but not the account this daemon belongs to');
1660
- return false;
1915
+ return this.paused() ? 'paused' : 'refused';
1661
1916
  }
1662
1917
  /* TEAM: ONE USER PER DEVICE. A device belongs to the first verified user
1663
1918
  * it connected as; another member signed in on the same browser does not
@@ -1665,14 +1920,15 @@ export class SidecarServer {
1665
1920
  * user). A device recorded with no user yet is taken by this one. */
1666
1921
  if (this.opts.admission?.oneUserPerDevice && boundUser !== undefined && boundUser !== verdict.identity.userId) {
1667
1922
  this.opts.log?.('identity: verified, but this device belongs to another user');
1668
- return false;
1923
+ return 'refused';
1669
1924
  }
1670
1925
  this.verifiedUserId = verdict.identity.userId;
1926
+ this.verifiedIdentity = verdict.identity;
1671
1927
  const { name, email } = verdict.identity;
1672
1928
  this.verifiedPerson = { ...(name ? { name } : {}), ...(email ? { email } : {}) };
1673
1929
  this.identityResult = { verified: true };
1674
1930
  this.opts.log?.('identity: verified');
1675
- return true;
1931
+ return 'ok';
1676
1932
  }
1677
1933
  /** Start the periodic re-check of an authenticated connection's identity
1678
1934
  * (`SidecarAdmission.recheckMs`, team mode). */
@@ -1713,6 +1969,9 @@ export class SidecarServer {
1713
1969
  this.opts.log?.(`identity re-check failed (${reason}); closing the connection`);
1714
1970
  this.ch.close();
1715
1971
  }
1972
+ else if (verdict.ok) {
1973
+ this.verifiedIdentity = verdict.identity;
1974
+ }
1716
1975
  }
1717
1976
  finally {
1718
1977
  this.recheckInFlight = false;
@@ -1809,9 +2068,13 @@ export class SidecarServer {
1809
2068
  return null;
1810
2069
  }
1811
2070
  const deviceJkt = await jwkThumbprint(key);
1812
- if (!(await this.identityGate(deviceJkt, record.verifiedUserId))) {
2071
+ const gate = await this.identityGate(deviceJkt, record.verifiedUserId);
2072
+ if (gate !== 'ok') {
1813
2073
  this.unwatchRevocation();
1814
- this.denyCredential('identity');
2074
+ if (gate === 'paused')
2075
+ this.denyPaused();
2076
+ else
2077
+ this.denyCredential('identity');
1815
2078
  return null;
1816
2079
  }
1817
2080
  return { deviceId: id, deviceJkt };
@@ -1866,8 +2129,12 @@ export class SidecarServer {
1866
2129
  }
1867
2130
  /* WHO, before anything is written: an unverified or unadmitted identity
1868
2131
  * is refused here with the code still unburned. */
1869
- if (!(await this.identityGate(keyJkt))) {
1870
- this.denyCredential('identity');
2132
+ const gate = await this.identityGate(keyJkt);
2133
+ if (gate !== 'ok') {
2134
+ if (gate === 'paused')
2135
+ this.denyPaused();
2136
+ else
2137
+ this.denyCredential('identity');
1871
2138
  return null;
1872
2139
  }
1873
2140
  const deviceId = makeDeviceId();
@@ -1968,6 +2235,34 @@ export class SidecarServer {
1968
2235
  this.ch.close();
1969
2236
  return;
1970
2237
  }
2238
+ /* BLOCKED BY FLEET POLICY (slice 4). Local approval holds a user back
2239
+ * (they connect and wait at the pending gate); the fleet policy refuses
2240
+ * them outright, before this connection is authenticated and before first
2241
+ * sight would record them as pending. Peer daemon connections of theirs
2242
+ * included. Sent after the identity challenge, so the client reads an
2243
+ * account refusal and keeps its device. */
2244
+ if (this.opts.members && this.verifiedUserId && this.fleetBlocked(this.verifiedUserId)) {
2245
+ this.opts.log?.('denied: sign-in limited by fleet policy');
2246
+ this.ch.send({ t: 'denied', reason: FLEET_LOGINS_DENIED });
2247
+ this.noteOutcome('denied');
2248
+ this.unwatchRevocation();
2249
+ this.ch.close();
2250
+ return;
2251
+ }
2252
+ /* The admission policy may have changed while the credential was being
2253
+ * checked (the change callback ignores a connection that is not yet
2254
+ * authenticated). Ask once more, just before authenticating, so a decision
2255
+ * the fleet already revoked is never acted on. */
2256
+ if (this.verifiedIdentity && !this.admits(this.verifiedIdentity)) {
2257
+ this.opts.log?.('identity: no longer admitted by the fleet state');
2258
+ this.unwatchRevocation();
2259
+ if (this.paused())
2260
+ this.denyPaused();
2261
+ else
2262
+ this.denyCredential('identity');
2263
+ this.helloInFlight = false;
2264
+ return;
2265
+ }
1971
2266
  /** What every pairing assertion on this connection binds: the thumbprint
1972
2267
  * of the device key it proved. */
1973
2268
  this.deviceJkt = cred.deviceJkt;
@@ -2158,6 +2453,20 @@ export class SidecarServer {
2158
2453
  call: (method, args) => this.hostCall('mcp', method, args),
2159
2454
  });
2160
2455
  }
2456
+ /* Chat ask_user (R8): a verified person's connection takes a seat, so an
2457
+ * ask for them or for the admin reaches it, and one already open is
2458
+ * delivered now. A pending member is shown nothing, checked per send. */
2459
+ if (this.opts.runner?.chat && this.verifiedUserId && !isPeerDaemon) {
2460
+ const seat = {
2461
+ userId: this.verifiedUserId,
2462
+ ask: (p) => { if (!this.memberPending())
2463
+ this.ch.send({ t: 'ask-user', ...p }); },
2464
+ resolved: (id, by) => { if (!this.memberPending())
2465
+ this.ch.send({ t: 'ask-user-resolved', id, by }); },
2466
+ };
2467
+ this.askSeat = seat;
2468
+ this.askSeatLeave = this.askUsers().join(seat);
2469
+ }
2161
2470
  if (this.opts.attendance && !isPeerDaemon) {
2162
2471
  /* A pending member (Part E) is never a recipient of anything: every
2163
2472
  * send below checks the status at the moment it is made, so a member
@@ -2390,6 +2699,11 @@ export class SidecarServer {
2390
2699
  // A client starts a task only where the daemon will honour it: an older
2391
2700
  // runner ignores `task`, and a dropped task must never look like a start.
2392
2701
  ...(this.opts.runner?.supportsTasks ? [RUN_TASK_FEATURE] : []),
2702
+ // Daemon chat (Harness Chat Plan 2): run.chat, run-inject, chat-list,
2703
+ // chat-get and ask-user, only with a chat port behind them and a
2704
+ // verified user on this connection: every chat turn and read is refused
2705
+ // without one, so a client must not offer a surface that always refuses.
2706
+ ...(this.opts.runner?.chat && this.verifiedUserId ? [CHAT_FEATURE] : []),
2393
2707
  // Whether this daemon answers run-plan-get (a runner that reads run records).
2394
2708
  ...(this.opts.runner?.runPlan ? [RUN_PLAN_FEATURE] : []),
2395
2709
  // Whether this daemon fans a delegated callee's own node events out to
@@ -3212,6 +3526,225 @@ export class SidecarServer {
3212
3526
  this.ch.send({ t: 'run-error', id: m.id, error: message });
3213
3527
  }
3214
3528
  }
3529
+ /** The daemon's ask_user broker, or this connection's own when none is wired. */
3530
+ askUsers() {
3531
+ return this.opts.askUsers ?? (this.ownAskUsers ?? (this.ownAskUsers = new AskUserBroker()));
3532
+ }
3533
+ /** The sidecar admin's verified user id, now; undefined when unknown. A throw is "none". */
3534
+ chatAdmin() {
3535
+ try {
3536
+ return this.opts.access?.adminId();
3537
+ }
3538
+ catch {
3539
+ return undefined;
3540
+ }
3541
+ }
3542
+ /** One chat turn (Harness Chat Plan 2). The requester is THIS connection's
3543
+ * verified user and never a message field; with none it is refused. The
3544
+ * events, `run-accepted` (with the conversation id), `run-done` and the hub
3545
+ * publishing follow `onRun`. A refused or failed turn sends `run-error`. */
3546
+ onChatRun(m) {
3547
+ const refuse = (error) => { this.ch.send({ t: 'run-error', id: m.id, error }); };
3548
+ const port = this.opts.runner?.chat;
3549
+ if (!port) {
3550
+ refuse(CHAT_UNAVAILABLE);
3551
+ return;
3552
+ }
3553
+ if (m.sessionId !== undefined || m.task !== undefined || m.autonomy !== undefined) {
3554
+ refuse(CHAT_NOT_ALONE);
3555
+ return;
3556
+ }
3557
+ const user = this.verifiedUserId;
3558
+ if (!user) {
3559
+ refuse(CHAT_NO_ACCESS);
3560
+ return;
3561
+ }
3562
+ if (!chatAgentOk(m.agent)) {
3563
+ refuse(CHAT_BAD_AGENT);
3564
+ return;
3565
+ }
3566
+ if (typeof m.input !== 'string' || m.input.length > CHAT_MAX_TEXT) {
3567
+ refuse(CHAT_BAD_TEXT);
3568
+ return;
3569
+ }
3570
+ const spec = chatSpecOf(m.chat, m.agent);
3571
+ if (typeof spec === 'string') {
3572
+ refuse(spec);
3573
+ return;
3574
+ }
3575
+ const agent = m.agent;
3576
+ const hub = this.opts.runs;
3577
+ let runId;
3578
+ let ended = false;
3579
+ const early = [];
3580
+ const forward = (id, event) => {
3581
+ hub?.publish(id, event);
3582
+ this.ch.send({ t: 'event', id: m.id, runId: id, event });
3583
+ };
3584
+ const done = (r) => {
3585
+ if (ended)
3586
+ return;
3587
+ ended = true;
3588
+ if ((r.outcome === 'done' || r.outcome === 'stopped') && runId !== undefined) {
3589
+ const stopReason = r.stopReason ?? r.outcome;
3590
+ hub?.finish(runId, { text: r.text, stopReason });
3591
+ this.ch.send({ t: 'run-done', id: m.id, runId, text: r.text, stopReason, outcome: r.outcome });
3592
+ return;
3593
+ }
3594
+ const outcome = r.outcome === 'refused' ? 'refused' : 'failed';
3595
+ const error = r.error ?? (r.outcome === 'refused' ? 'The chat turn was refused.'
3596
+ : runId === undefined ? 'The chat turn ended without a run.' : 'The chat turn failed.');
3597
+ // Watchers too: a failed run must not look merely silent to a second viewer.
3598
+ if (runId !== undefined)
3599
+ hub?.fail(runId, error);
3600
+ this.ch.send({ t: 'run-error', id: m.id, error, outcome });
3601
+ };
3602
+ const askers = () => {
3603
+ const admin = this.chatAdmin();
3604
+ return admin && admin !== user ? [user, admin] : [user];
3605
+ };
3606
+ try {
3607
+ port.turn({
3608
+ agent, requester: user, identity: { clientId: this.clientId },
3609
+ conversationId: spec.conversationId, text: m.input,
3610
+ ...(spec.ref ? { ref: spec.ref } : {}),
3611
+ ...(spec.maxUsd !== undefined ? { maxUsd: spec.maxUsd } : {}),
3612
+ }, {
3613
+ onAccepted: (id, conversationId) => {
3614
+ // One run per turn: a second id is not this turn's.
3615
+ if (ended || runId !== undefined)
3616
+ return;
3617
+ runId = id;
3618
+ hub?.open(id, user);
3619
+ this.ch.send({ t: 'run-accepted', id: m.id, runId: id, conversationId });
3620
+ for (const e of early.splice(0))
3621
+ if (e.runId === id)
3622
+ forward(id, e.event);
3623
+ },
3624
+ onEvent: (id, event) => {
3625
+ if (ended)
3626
+ return;
3627
+ // Buffered until the run's id is known, as onRun does.
3628
+ if (runId === undefined) {
3629
+ early.push({ runId: id, event });
3630
+ return;
3631
+ }
3632
+ if (id === runId)
3633
+ forward(id, event);
3634
+ },
3635
+ onDone: done,
3636
+ // R8 / Q2: the requester and the admin, read when the question is asked.
3637
+ askUser: (id, questions, signal, deadline) => this.askUsers().ask({
3638
+ users: askers(), runId: id, agent, questions,
3639
+ deadline: typeof deadline === 'number' && Number.isFinite(deadline) ? deadline : this.now() + CHAT_ASK_FALLBACK_MS,
3640
+ }, signal),
3641
+ });
3642
+ }
3643
+ catch (err) {
3644
+ done({ conversationId: spec.conversationId, runId: null, outcome: 'failed', error: errMessage(err), text: '' });
3645
+ }
3646
+ }
3647
+ /** Messages typed while a chat turn runs. The requester is THIS
3648
+ * connection's verified user; the port refuses a run that is not theirs. */
3649
+ async onRunInject(m) {
3650
+ const reply = (ok, reason) => {
3651
+ this.ch.send({ t: 'run-inject-result', id: m.id, ok, ...(reason !== undefined ? { reason } : {}) });
3652
+ };
3653
+ const port = this.opts.runner?.chat;
3654
+ if (!port) {
3655
+ reply(false, CHAT_UNAVAILABLE);
3656
+ return;
3657
+ }
3658
+ const user = this.verifiedUserId;
3659
+ if (!user) {
3660
+ reply(false, CHAT_NO_ACCESS);
3661
+ return;
3662
+ }
3663
+ if (typeof m.runId !== 'string' || !m.runId || m.runId.length > CHAT_MAX_RUN_ID_LEN || hasControlChar(m.runId)) {
3664
+ reply(false, CHAT_BAD_RUN);
3665
+ return;
3666
+ }
3667
+ const messages = injectMessagesOf(m.messages);
3668
+ if (!messages) {
3669
+ reply(false, CHAT_BAD_INJECT);
3670
+ return;
3671
+ }
3672
+ try {
3673
+ const r = await port.inject(m.runId, user, messages, m.hard === true);
3674
+ if (r.ok === true)
3675
+ reply(true);
3676
+ else
3677
+ reply(false, typeof r.reason === 'string' && r.reason ? r.reason.slice(0, CHAT_MAX_REASON_LEN) : CHAT_INJECT_FAILED);
3678
+ }
3679
+ catch (err) {
3680
+ this.opts.log?.(`chat: run-inject threw: ${errMessage(err)}`);
3681
+ reply(false, CHAT_INJECT_FAILED);
3682
+ }
3683
+ }
3684
+ /** The checks chat-list and chat-get share: a port, a verified caller, and
3685
+ * a well-formed agent, ref and member. Answers the request's parts, or the
3686
+ * refusal to send. `isAdmin` is the daemon's own answer, never the message's. */
3687
+ chatReadOf(m) {
3688
+ const port = this.opts.runner?.chat;
3689
+ if (!port)
3690
+ return CHAT_UNAVAILABLE;
3691
+ const caller = this.verifiedUserId;
3692
+ if (!caller)
3693
+ return CHAT_NO_ACCESS;
3694
+ if (!chatAgentOk(m.agent))
3695
+ return CHAT_BAD_AGENT;
3696
+ const ref = chatRefOf(m.ref, m.agent);
3697
+ if (ref === null)
3698
+ return AGENT_RUNS_BAD_REF;
3699
+ if (m.member !== undefined && !chatIdOk(m.member))
3700
+ return CHAT_BAD_MEMBER;
3701
+ return {
3702
+ port, caller, agent: m.agent, ...ref,
3703
+ ...(m.member !== undefined ? { member: m.member } : {}),
3704
+ isAdmin: sameAdmin(caller, this.chatAdmin()),
3705
+ };
3706
+ }
3707
+ async onChatList(m) {
3708
+ const q = this.chatReadOf(m);
3709
+ if (typeof q === 'string') {
3710
+ this.ch.send({ t: 'chat-list-result', id: m.id, ok: false, error: q });
3711
+ return;
3712
+ }
3713
+ const { port, ...rest } = q;
3714
+ try {
3715
+ const chats = await port.list({ ...rest, identity: { clientId: this.clientId } });
3716
+ this.ch.send({ t: 'chat-list-result', id: m.id, ok: true, chats: chats.map(chatSummaryOf) });
3717
+ }
3718
+ catch (err) {
3719
+ const error = chatErrorOf(err);
3720
+ if (error === null)
3721
+ this.opts.log?.(`chat: chat-list threw: ${errMessage(err)}`);
3722
+ this.ch.send({ t: 'chat-list-result', id: m.id, ok: false, error: error ?? CHAT_READ_FAILED });
3723
+ }
3724
+ }
3725
+ async onChatGet(m) {
3726
+ const refuse = (error) => { this.ch.send({ t: 'chat-get-result', id: m.id, ok: false, error }); };
3727
+ const q = this.chatReadOf(m);
3728
+ if (typeof q === 'string') {
3729
+ refuse(q);
3730
+ return;
3731
+ }
3732
+ if (!chatIdOk(m.conversationId)) {
3733
+ refuse(CHAT_BAD_ID);
3734
+ return;
3735
+ }
3736
+ const { port, ...rest } = q;
3737
+ try {
3738
+ const conversation = await port.get({ ...rest, identity: { clientId: this.clientId }, conversationId: m.conversationId });
3739
+ this.ch.send({ t: 'chat-get-result', id: m.id, ok: true, conversation: storedChatOf(conversation) });
3740
+ }
3741
+ catch (err) {
3742
+ const error = chatErrorOf(err);
3743
+ if (error === null)
3744
+ this.opts.log?.(`chat: chat-get threw: ${errMessage(err)}`);
3745
+ refuse(error ?? CHAT_READ_FAILED);
3746
+ }
3747
+ }
3215
3748
  /** The identity to attribute a host-sync write to. Returns either the
3216
3749
  * authenticated `clientId`, or an error string explaining why there is none --
3217
3750
  * either this connection is not authenticated yet, or it authenticated with no
@@ -4528,6 +5061,17 @@ export class SidecarServer {
4528
5061
  ? { t: 'access-result', id: m.id, ok: true, ...(reply.access ? { access: reply.access } : {}), ...(reply.members ? { members: reply.members } : {}) }
4529
5062
  : { t: 'access-result', id: m.id, ok: false, code: reply.code, error: reply.error });
4530
5063
  }
5064
+ /** Does the fleet policy refuse this user outright (slice 4)? A lookup that throws answers no here: the
5065
+ * pending gate, which reads a throwing lookup as pending, still holds them back, and the daemon admin is
5066
+ * never locked out by a broken member list. */
5067
+ fleetBlocked(userId) {
5068
+ try {
5069
+ return this.opts.members?.statusOf(userId) === 'blocked';
5070
+ }
5071
+ catch {
5072
+ return false;
5073
+ }
5074
+ }
4531
5075
  /** Team mode with a member list, and this connection's verified user is not
4532
5076
  * an active member right now. Read fresh on every call. Member status
4533
5077
  * ALONE: a peer daemon connection is exempted from the request gate in
@@ -4579,6 +5123,7 @@ export class SidecarServer {
4579
5123
  userId: r.userId, status: r.status, firstSeen: r.firstSeen, label: r.label,
4580
5124
  ...(r.approvedBy !== undefined ? { approvedBy: r.approvedBy } : {}),
4581
5125
  ...(r.approvedAt !== undefined ? { approvedAt: r.approvedAt } : {}),
5126
+ ...(r.blockedByFleet === true ? { blockedByFleet: true } : {}),
4582
5127
  })),
4583
5128
  } : {}),
4584
5129
  }
@@ -5033,6 +5578,21 @@ export class SidecarServer {
5033
5578
  const target = crewConfigTarget(canonical);
5034
5579
  if (!target)
5035
5580
  return {};
5581
+ /* A chat (R15/Q6) is its member's and the admin's alone, the agent's
5582
+ * owner included, and only the daemon writes it (Q1: chats are kept).
5583
+ * The chats folder may be listed with any role; the listing keeps only
5584
+ * this user's own requester folder (visibleConfigEntries). */
5585
+ const chat = target.kind === 'agent' ? chatContentOf(canonical) : null;
5586
+ if (target.kind === 'agent' && chat !== null) {
5587
+ if (!reading)
5588
+ return { refusal: ACCESS_CHAT_FILES };
5589
+ if (chat.requester === null) {
5590
+ return m.t === 'config-list' && (await access.can(daemonAgent(target.agent), user, 'see')) ? {} : { refusal };
5591
+ }
5592
+ if (sameAdmin(user, access.adminId()))
5593
+ return {};
5594
+ return user !== undefined && chat.requester === user ? {} : { refusal };
5595
+ }
5036
5596
  if (reading) {
5037
5597
  if (target.kind === 'crew')
5038
5598
  return {};
@@ -5084,7 +5644,9 @@ export class SidecarServer {
5084
5644
  * or null when that cannot be decided. Only whole agents are left out:
5085
5645
  * dropping individual entries would read as deletions to a syncing client.
5086
5646
  * The one exception is a run's session folder (`sessions/<runId>`), which
5087
- * is run content: only the runs this user may read are listed (D5). */
5647
+ * is run content: only the runs this user may read are listed (D5). So is a
5648
+ * chat's requester folder (`chats/<requester>`): only this user's own, or
5649
+ * every one for the admin, is listed (R15/Q6). */
5088
5650
  async visibleConfigEntries(svc, path, entries) {
5089
5651
  const access = this.teamAccess();
5090
5652
  if (!access)
@@ -5113,6 +5675,9 @@ export class SidecarServer {
5113
5675
  }
5114
5676
  if (!shown)
5115
5677
  continue;
5678
+ const chat = chatContentOf(full);
5679
+ if (chat !== null && chat.requester !== null && !isAdmin && (this.verifiedUserId === undefined || chat.requester !== this.verifiedUserId))
5680
+ continue;
5116
5681
  const run = runContentOf(full);
5117
5682
  if (run?.kind === 'sessions' && run.id !== null) {
5118
5683
  const key = `${target.agent}/${run.id}`;
@@ -5445,6 +6010,18 @@ function runContentOf(path) {
5445
6010
  const file = segs.length === 4 ? /^(.+)\.jsonl$/i.exec(segs[3]) : null;
5446
6011
  return { kind, id: file ? file[1] : null, folder: false };
5447
6012
  }
6013
+ /** Where a daemon-crew config path is inside an agent's chats
6014
+ * (`crew/<agent>/chats[/<requester>[/...]]`, the daemon chat store's layout).
6015
+ * `requester` is the folder's requester id (the `<requester>` segment, as the
6016
+ * canonical path spells it), null for the chats folder itself. Null for any
6017
+ * other path. The `chats` segment compares case-insensitively; the caller
6018
+ * hands in the canonical path. */
6019
+ function chatContentOf(path) {
6020
+ const segs = path.split('/');
6021
+ if (segs.length < 3 || segs[0].toLowerCase() !== 'crew' || segs[2].toLowerCase() !== 'chats')
6022
+ return null;
6023
+ return { requester: segs.length === 3 ? null : segs[3] || null };
6024
+ }
5448
6025
  /** `run-history`'s `crew`, off the wire: anything but the two known shapes is
5449
6026
  * treated as absent (the global crew, as an older client meant). The id is
5450
6027
  * only a selector among the CALLER's own roots -- the runner matches it