@corenel/harness 0.20.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 (131) 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 +10 -0
  41. package/dist/core/spawn.d.ts.map +1 -1
  42. package/dist/core/spawn.js +28 -5
  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/guardrail.d.ts +8 -0
  49. package/dist/guardrail/guardrail.d.ts.map +1 -1
  50. package/dist/guardrail/guardrail.js +12 -0
  51. package/dist/guardrail/guardrail.js.map +1 -1
  52. package/dist/guardrail/index.d.ts +1 -1
  53. package/dist/guardrail/index.d.ts.map +1 -1
  54. package/dist/guardrail/index.js +1 -1
  55. package/dist/guardrail/index.js.map +1 -1
  56. package/dist/guardrail/serialize.d.ts.map +1 -1
  57. package/dist/guardrail/serialize.js +3 -0
  58. package/dist/guardrail/serialize.js.map +1 -1
  59. package/dist/guardrail/validate.d.ts.map +1 -1
  60. package/dist/guardrail/validate.js +4 -0
  61. package/dist/guardrail/validate.js.map +1 -1
  62. package/dist/orchestrator/sidecarTools.d.ts.map +1 -1
  63. package/dist/orchestrator/sidecarTools.js +8 -4
  64. package/dist/orchestrator/sidecarTools.js.map +1 -1
  65. package/dist/prompts/fitProject.d.ts +6 -0
  66. package/dist/prompts/fitProject.d.ts.map +1 -0
  67. package/dist/prompts/fitProject.js +6 -0
  68. package/dist/prompts/fitProject.js.map +1 -0
  69. package/dist/prompts/library/defaults.generated.js +7 -7
  70. package/dist/prompts/library/defaults.generated.js.map +1 -1
  71. package/dist/prompts/library/index.d.ts +2 -1
  72. package/dist/prompts/library/index.d.ts.map +1 -1
  73. package/dist/prompts/library/index.js +11 -16
  74. package/dist/prompts/library/index.js.map +1 -1
  75. package/dist/providers/gateway.d.ts +1 -0
  76. package/dist/providers/gateway.d.ts.map +1 -1
  77. package/dist/providers/gateway.js +1 -0
  78. package/dist/providers/gateway.js.map +1 -1
  79. package/dist/sidecar/askUserBroker.d.ts +40 -0
  80. package/dist/sidecar/askUserBroker.d.ts.map +1 -0
  81. package/dist/sidecar/askUserBroker.js +103 -0
  82. package/dist/sidecar/askUserBroker.js.map +1 -0
  83. package/dist/sidecar/fleetJwe.d.ts +25 -0
  84. package/dist/sidecar/fleetJwe.d.ts.map +1 -0
  85. package/dist/sidecar/fleetJwe.js +115 -0
  86. package/dist/sidecar/fleetJwe.js.map +1 -0
  87. package/dist/sidecar/host.d.ts +77 -1
  88. package/dist/sidecar/host.d.ts.map +1 -1
  89. package/dist/sidecar/host.js +220 -6
  90. package/dist/sidecar/host.js.map +1 -1
  91. package/dist/sidecar/identity.d.ts +4 -0
  92. package/dist/sidecar/identity.d.ts.map +1 -1
  93. package/dist/sidecar/memberGate.d.ts.map +1 -1
  94. package/dist/sidecar/memberGate.js +5 -0
  95. package/dist/sidecar/memberGate.js.map +1 -1
  96. package/dist/sidecar/server.d.ts +46 -4
  97. package/dist/sidecar/server.d.ts.map +1 -1
  98. package/dist/sidecar/server.js +523 -13
  99. package/dist/sidecar/server.js.map +1 -1
  100. package/dist/systemParts.d.ts +0 -1
  101. package/dist/systemParts.d.ts.map +1 -1
  102. package/dist/systemParts.js +6 -2
  103. package/dist/systemParts.js.map +1 -1
  104. package/dist/todos.d.ts +8 -0
  105. package/dist/todos.d.ts.map +1 -1
  106. package/dist/todos.js +21 -17
  107. package/dist/todos.js.map +1 -1
  108. package/dist/tools/builtins.d.ts +8 -0
  109. package/dist/tools/builtins.d.ts.map +1 -1
  110. package/dist/tools/builtins.js +21 -1
  111. package/dist/tools/builtins.js.map +1 -1
  112. package/dist/tools/namespaces.d.ts +8 -0
  113. package/dist/tools/namespaces.d.ts.map +1 -1
  114. package/dist/tools/namespaces.js +18 -0
  115. package/dist/tools/namespaces.js.map +1 -1
  116. package/dist/tools/skillTool.d.ts.map +1 -1
  117. package/dist/tools/skillTool.js +22 -1
  118. package/dist/tools/skillTool.js.map +1 -1
  119. package/dist/tools/toolFacts.d.ts +9 -0
  120. package/dist/tools/toolFacts.d.ts.map +1 -0
  121. package/dist/tools/toolFacts.js +19 -0
  122. package/dist/tools/toolFacts.js.map +1 -0
  123. package/dist/tools/toolSearch.d.ts +25 -6
  124. package/dist/tools/toolSearch.d.ts.map +1 -1
  125. package/dist/tools/toolSearch.js +62 -14
  126. package/dist/tools/toolSearch.js.map +1 -1
  127. package/dist/tools/workflowTool.d.ts +34 -0
  128. package/dist/tools/workflowTool.d.ts.map +1 -0
  129. package/dist/tools/workflowTool.js +122 -0
  130. package/dist/tools/workflowTool.js.map +1 -0
  131. package/package.json +6 -3
@@ -1,9 +1,10 @@
1
- import { decodePcm16, FLEET_LOGINS_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, 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;
@@ -635,6 +779,27 @@ export class SidecarServer {
635
779
  writable: true,
636
780
  value: void 0
637
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
+ });
638
803
  /** Detach from the peer routes, when this connection serves MCP. */
639
804
  Object.defineProperty(this, "peersDetach", {
640
805
  enumerable: true,
@@ -934,6 +1099,9 @@ export class SidecarServer {
934
1099
  this.identityAssertResolve = undefined;
935
1100
  this.attendedDetach?.();
936
1101
  this.attendedDetach = undefined;
1102
+ this.askSeatLeave?.();
1103
+ this.askSeatLeave = undefined;
1104
+ this.askSeat = undefined;
937
1105
  this.peersDetach?.();
938
1106
  this.peersDetach = undefined;
939
1107
  // Leave the live set, or this directory reads as connected for ever and
@@ -1065,8 +1233,47 @@ export class SidecarServer {
1065
1233
  this.ch.send({ t: 'run-error', id: m.id, error: 'not authenticated' });
1066
1234
  return;
1067
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
+ }
1068
1241
  void this.onRun(m);
1069
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
+ }
1070
1277
  case 'subscribe':
1071
1278
  // Guarded by auth like `run` (auth boundary). Pre-auth: drop silently —
1072
1279
  // subscribe has no response channel, so no message is the correct
@@ -1623,6 +1830,24 @@ export class SidecarServer {
1623
1830
  this.noteOutcome('denied');
1624
1831
  this.countFailedHello();
1625
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
+ }
1626
1851
  /** Tell the daemon's rate limiter how a hello ended. Its failure is never
1627
1852
  * the handshake's. */
1628
1853
  noteOutcome(outcome) {
@@ -1667,23 +1892,27 @@ export class SidecarServer {
1667
1892
  * caller's uniform denial on the wire. */
1668
1893
  async identityGate(jkt, boundUser) {
1669
1894
  if (!this.opts.identity)
1670
- return true;
1895
+ return 'ok';
1671
1896
  const sidecarId = this.opts.info?.id;
1672
1897
  if (!sidecarId) {
1673
1898
  this.opts.log?.('identity: refused (this daemon has no id for an assertion to name)');
1674
- return false;
1899
+ return 'refused';
1675
1900
  }
1676
1901
  const nonce = makeNonce();
1677
1902
  const orgId = this.opts.admission?.orgId;
1678
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';
1679
1908
  const verdict = await this.awaitAndVerifyIdentity(sidecarId, nonce, jkt, IDENTITY_TIMEOUT_MS);
1680
1909
  if (!verdict.ok) {
1681
1910
  this.opts.log?.(`identity: unverified (${verdict.reason})`);
1682
- return false;
1911
+ return 'refused';
1683
1912
  }
1684
1913
  if (!this.admits(verdict.identity)) {
1685
1914
  this.opts.log?.(orgId ? `identity: verified, but not a member of ${orgId}` : 'identity: verified, but not the account this daemon belongs to');
1686
- return false;
1915
+ return this.paused() ? 'paused' : 'refused';
1687
1916
  }
1688
1917
  /* TEAM: ONE USER PER DEVICE. A device belongs to the first verified user
1689
1918
  * it connected as; another member signed in on the same browser does not
@@ -1691,7 +1920,7 @@ export class SidecarServer {
1691
1920
  * user). A device recorded with no user yet is taken by this one. */
1692
1921
  if (this.opts.admission?.oneUserPerDevice && boundUser !== undefined && boundUser !== verdict.identity.userId) {
1693
1922
  this.opts.log?.('identity: verified, but this device belongs to another user');
1694
- return false;
1923
+ return 'refused';
1695
1924
  }
1696
1925
  this.verifiedUserId = verdict.identity.userId;
1697
1926
  this.verifiedIdentity = verdict.identity;
@@ -1699,7 +1928,7 @@ export class SidecarServer {
1699
1928
  this.verifiedPerson = { ...(name ? { name } : {}), ...(email ? { email } : {}) };
1700
1929
  this.identityResult = { verified: true };
1701
1930
  this.opts.log?.('identity: verified');
1702
- return true;
1931
+ return 'ok';
1703
1932
  }
1704
1933
  /** Start the periodic re-check of an authenticated connection's identity
1705
1934
  * (`SidecarAdmission.recheckMs`, team mode). */
@@ -1839,9 +2068,13 @@ export class SidecarServer {
1839
2068
  return null;
1840
2069
  }
1841
2070
  const deviceJkt = await jwkThumbprint(key);
1842
- if (!(await this.identityGate(deviceJkt, record.verifiedUserId))) {
2071
+ const gate = await this.identityGate(deviceJkt, record.verifiedUserId);
2072
+ if (gate !== 'ok') {
1843
2073
  this.unwatchRevocation();
1844
- this.denyCredential('identity');
2074
+ if (gate === 'paused')
2075
+ this.denyPaused();
2076
+ else
2077
+ this.denyCredential('identity');
1845
2078
  return null;
1846
2079
  }
1847
2080
  return { deviceId: id, deviceJkt };
@@ -1896,8 +2129,12 @@ export class SidecarServer {
1896
2129
  }
1897
2130
  /* WHO, before anything is written: an unverified or unadmitted identity
1898
2131
  * is refused here with the code still unburned. */
1899
- if (!(await this.identityGate(keyJkt))) {
1900
- 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');
1901
2138
  return null;
1902
2139
  }
1903
2140
  const deviceId = makeDeviceId();
@@ -2019,7 +2256,10 @@ export class SidecarServer {
2019
2256
  if (this.verifiedIdentity && !this.admits(this.verifiedIdentity)) {
2020
2257
  this.opts.log?.('identity: no longer admitted by the fleet state');
2021
2258
  this.unwatchRevocation();
2022
- this.denyCredential('identity');
2259
+ if (this.paused())
2260
+ this.denyPaused();
2261
+ else
2262
+ this.denyCredential('identity');
2023
2263
  this.helloInFlight = false;
2024
2264
  return;
2025
2265
  }
@@ -2213,6 +2453,20 @@ export class SidecarServer {
2213
2453
  call: (method, args) => this.hostCall('mcp', method, args),
2214
2454
  });
2215
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
+ }
2216
2470
  if (this.opts.attendance && !isPeerDaemon) {
2217
2471
  /* A pending member (Part E) is never a recipient of anything: every
2218
2472
  * send below checks the status at the moment it is made, so a member
@@ -2445,6 +2699,11 @@ export class SidecarServer {
2445
2699
  // A client starts a task only where the daemon will honour it: an older
2446
2700
  // runner ignores `task`, and a dropped task must never look like a start.
2447
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] : []),
2448
2707
  // Whether this daemon answers run-plan-get (a runner that reads run records).
2449
2708
  ...(this.opts.runner?.runPlan ? [RUN_PLAN_FEATURE] : []),
2450
2709
  // Whether this daemon fans a delegated callee's own node events out to
@@ -3267,6 +3526,225 @@ export class SidecarServer {
3267
3526
  this.ch.send({ t: 'run-error', id: m.id, error: message });
3268
3527
  }
3269
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
+ }
3270
3748
  /** The identity to attribute a host-sync write to. Returns either the
3271
3749
  * authenticated `clientId`, or an error string explaining why there is none --
3272
3750
  * either this connection is not authenticated yet, or it authenticated with no
@@ -5100,6 +5578,21 @@ export class SidecarServer {
5100
5578
  const target = crewConfigTarget(canonical);
5101
5579
  if (!target)
5102
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
+ }
5103
5596
  if (reading) {
5104
5597
  if (target.kind === 'crew')
5105
5598
  return {};
@@ -5151,7 +5644,9 @@ export class SidecarServer {
5151
5644
  * or null when that cannot be decided. Only whole agents are left out:
5152
5645
  * dropping individual entries would read as deletions to a syncing client.
5153
5646
  * The one exception is a run's session folder (`sessions/<runId>`), which
5154
- * 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). */
5155
5650
  async visibleConfigEntries(svc, path, entries) {
5156
5651
  const access = this.teamAccess();
5157
5652
  if (!access)
@@ -5180,6 +5675,9 @@ export class SidecarServer {
5180
5675
  }
5181
5676
  if (!shown)
5182
5677
  continue;
5678
+ const chat = chatContentOf(full);
5679
+ if (chat !== null && chat.requester !== null && !isAdmin && (this.verifiedUserId === undefined || chat.requester !== this.verifiedUserId))
5680
+ continue;
5183
5681
  const run = runContentOf(full);
5184
5682
  if (run?.kind === 'sessions' && run.id !== null) {
5185
5683
  const key = `${target.agent}/${run.id}`;
@@ -5512,6 +6010,18 @@ function runContentOf(path) {
5512
6010
  const file = segs.length === 4 ? /^(.+)\.jsonl$/i.exec(segs[3]) : null;
5513
6011
  return { kind, id: file ? file[1] : null, folder: false };
5514
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
+ }
5515
6025
  /** `run-history`'s `crew`, off the wire: anything but the two known shapes is
5516
6026
  * treated as absent (the global crew, as an older client meant). The id is
5517
6027
  * only a selector among the CALLER's own roots -- the runner matches it