@corenel/harness 0.20.0 → 0.22.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.
- package/dist/chat/engine.d.ts +53 -0
- package/dist/chat/engine.d.ts.map +1 -0
- package/dist/chat/engine.js +885 -0
- package/dist/chat/engine.js.map +1 -0
- package/dist/chat/events.d.ts +5 -0
- package/dist/chat/events.d.ts.map +1 -0
- package/dist/chat/events.js +100 -0
- package/dist/chat/events.js.map +1 -0
- package/dist/chat/index.d.ts +9 -0
- package/dist/chat/index.d.ts.map +1 -0
- package/dist/chat/index.js +9 -0
- package/dist/chat/index.js.map +1 -0
- package/dist/chat/liveFold.d.ts +10 -0
- package/dist/chat/liveFold.d.ts.map +1 -0
- package/dist/chat/liveFold.js +31 -0
- package/dist/chat/liveFold.js.map +1 -0
- package/dist/chat/messages.d.ts +20 -0
- package/dist/chat/messages.d.ts.map +1 -0
- package/dist/chat/messages.js +56 -0
- package/dist/chat/messages.js.map +1 -0
- package/dist/chat/persist.d.ts +47 -0
- package/dist/chat/persist.d.ts.map +1 -0
- package/dist/chat/persist.js +118 -0
- package/dist/chat/persist.js.map +1 -0
- package/dist/chat/runWatchdog.d.ts +21 -0
- package/dist/chat/runWatchdog.d.ts.map +1 -0
- package/dist/chat/runWatchdog.js +92 -0
- package/dist/chat/runWatchdog.js.map +1 -0
- package/dist/chat/stitch.d.ts +26 -0
- package/dist/chat/stitch.d.ts.map +1 -0
- package/dist/chat/stitch.js +40 -0
- package/dist/chat/stitch.js.map +1 -0
- package/dist/chat/types.d.ts +163 -0
- package/dist/chat/types.d.ts.map +1 -0
- package/dist/chat/types.js +2 -0
- package/dist/chat/types.js.map +1 -0
- package/dist/core/loop.d.ts.map +1 -1
- package/dist/core/loop.js +50 -15
- package/dist/core/loop.js.map +1 -1
- package/dist/core/spawn.d.ts +10 -0
- package/dist/core/spawn.d.ts.map +1 -1
- package/dist/core/spawn.js +28 -5
- package/dist/core/spawn.js.map +1 -1
- package/dist/fileSchema.js +1 -1
- package/dist/guardrail/builtins.d.ts.map +1 -1
- package/dist/guardrail/builtins.js +11 -6
- package/dist/guardrail/builtins.js.map +1 -1
- package/dist/guardrail/guardrail.d.ts +8 -0
- package/dist/guardrail/guardrail.d.ts.map +1 -1
- package/dist/guardrail/guardrail.js +12 -0
- package/dist/guardrail/guardrail.js.map +1 -1
- package/dist/guardrail/index.d.ts +1 -1
- package/dist/guardrail/index.d.ts.map +1 -1
- package/dist/guardrail/index.js +1 -1
- package/dist/guardrail/index.js.map +1 -1
- package/dist/guardrail/serialize.d.ts.map +1 -1
- package/dist/guardrail/serialize.js +3 -0
- package/dist/guardrail/serialize.js.map +1 -1
- package/dist/guardrail/validate.d.ts.map +1 -1
- package/dist/guardrail/validate.js +4 -0
- package/dist/guardrail/validate.js.map +1 -1
- package/dist/orchestrator/sidecarTools.d.ts.map +1 -1
- package/dist/orchestrator/sidecarTools.js +8 -4
- package/dist/orchestrator/sidecarTools.js.map +1 -1
- package/dist/prompts/fitProject.d.ts +6 -0
- package/dist/prompts/fitProject.d.ts.map +1 -0
- package/dist/prompts/fitProject.js +6 -0
- package/dist/prompts/fitProject.js.map +1 -0
- package/dist/prompts/library/defaults.generated.js +9 -9
- package/dist/prompts/library/defaults.generated.js.map +1 -1
- package/dist/prompts/library/index.d.ts +2 -1
- package/dist/prompts/library/index.d.ts.map +1 -1
- package/dist/prompts/library/index.js +11 -16
- package/dist/prompts/library/index.js.map +1 -1
- package/dist/providers/gateway.d.ts +1 -0
- package/dist/providers/gateway.d.ts.map +1 -1
- package/dist/providers/gateway.js +1 -0
- package/dist/providers/gateway.js.map +1 -1
- package/dist/sessions/fileSessionStore.d.ts.map +1 -1
- package/dist/sessions/fileSessionStore.js.map +1 -1
- package/dist/sidecar/askUserBroker.d.ts +40 -0
- package/dist/sidecar/askUserBroker.d.ts.map +1 -0
- package/dist/sidecar/askUserBroker.js +103 -0
- package/dist/sidecar/askUserBroker.js.map +1 -0
- package/dist/sidecar/fleetJwe.d.ts +25 -0
- package/dist/sidecar/fleetJwe.d.ts.map +1 -0
- package/dist/sidecar/fleetJwe.js +115 -0
- package/dist/sidecar/fleetJwe.js.map +1 -0
- package/dist/sidecar/fsChanges.d.ts +23 -0
- package/dist/sidecar/fsChanges.d.ts.map +1 -0
- package/dist/sidecar/fsChanges.js +77 -0
- package/dist/sidecar/fsChanges.js.map +1 -0
- package/dist/sidecar/host.d.ts +91 -1
- package/dist/sidecar/host.d.ts.map +1 -1
- package/dist/sidecar/host.js +269 -6
- package/dist/sidecar/host.js.map +1 -1
- package/dist/sidecar/identity.d.ts +4 -0
- package/dist/sidecar/identity.d.ts.map +1 -1
- package/dist/sidecar/memberGate.d.ts.map +1 -1
- package/dist/sidecar/memberGate.js +5 -0
- package/dist/sidecar/memberGate.js.map +1 -1
- package/dist/sidecar/server.d.ts +61 -4
- package/dist/sidecar/server.d.ts.map +1 -1
- package/dist/sidecar/server.js +583 -13
- package/dist/sidecar/server.js.map +1 -1
- package/dist/systemParts.d.ts +0 -1
- package/dist/systemParts.d.ts.map +1 -1
- package/dist/systemParts.js +6 -2
- package/dist/systemParts.js.map +1 -1
- package/dist/todos.d.ts +8 -0
- package/dist/todos.d.ts.map +1 -1
- package/dist/todos.js +21 -17
- package/dist/todos.js.map +1 -1
- package/dist/tools/builtins.d.ts +14 -1
- package/dist/tools/builtins.d.ts.map +1 -1
- package/dist/tools/builtins.js +27 -3
- package/dist/tools/builtins.js.map +1 -1
- package/dist/tools/namespaces.d.ts +8 -0
- package/dist/tools/namespaces.d.ts.map +1 -1
- package/dist/tools/namespaces.js +18 -0
- package/dist/tools/namespaces.js.map +1 -1
- package/dist/tools/skillTool.d.ts.map +1 -1
- package/dist/tools/skillTool.js +22 -1
- package/dist/tools/skillTool.js.map +1 -1
- package/dist/tools/toolFacts.d.ts +9 -0
- package/dist/tools/toolFacts.d.ts.map +1 -0
- package/dist/tools/toolFacts.js +19 -0
- package/dist/tools/toolFacts.js.map +1 -0
- package/dist/tools/toolSearch.d.ts +25 -6
- package/dist/tools/toolSearch.d.ts.map +1 -1
- package/dist/tools/toolSearch.js +62 -14
- package/dist/tools/toolSearch.js.map +1 -1
- package/dist/tools/workflowTool.d.ts +34 -0
- package/dist/tools/workflowTool.d.ts.map +1 -0
- package/dist/tools/workflowTool.js +122 -0
- package/dist/tools/workflowTool.js.map +1 -0
- package/package.json +6 -3
package/dist/sidecar/server.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
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, FS_CHANGED_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';
|
|
8
|
+
import { fsChangesFor } from './fsChanges.js';
|
|
7
9
|
/** A workflow reply's refusal, cut to the length a host accepts
|
|
8
10
|
* (MAX_WORKFLOW_REPLY_ERROR): a longer one is refused whole by the host's
|
|
9
11
|
* reply check, and the person would lose the reason. */
|
|
@@ -41,6 +43,9 @@ export const WATCH_REFUSED = 'You can watch only runs you started, or unattended
|
|
|
41
43
|
export const ACCESS_NO_ROLE = 'NO_ACCESS: you have no role on this agent, or too low a role for this.';
|
|
42
44
|
export const ACCESS_NOT_OWNER = "NOT_OWNER: only this agent's owner or the daemon admin can change it.";
|
|
43
45
|
export const ACCESS_ADMIN_ONLY = 'NOT_OWNER: only the daemon admin can change the crew folder itself.';
|
|
46
|
+
/** A team agent's chats (`crew/<agent>/chats/**`) are written only by the
|
|
47
|
+
* daemon's chat store; no client may write or delete them over the config channel. */
|
|
48
|
+
export const ACCESS_CHAT_FILES = 'NOT_OWNER: chats are kept by the daemon and cannot be changed or deleted here.';
|
|
44
49
|
export const ACCESS_STORE_REFUSED = 'The access store is never writable.';
|
|
45
50
|
export const ACCESS_CHECK_FAILED = 'NOT_OWNER: could not check access.';
|
|
46
51
|
export const ACCESS_FAILED = 'Access could not be checked or changed. The daemon log has the reason.';
|
|
@@ -95,6 +100,13 @@ function providedMcpUrls(v) {
|
|
|
95
100
|
const PEER_DAEMON_MESSAGES = new Set([
|
|
96
101
|
'hello', 'client-proof', 'identity-assert', 'host-call-result',
|
|
97
102
|
]);
|
|
103
|
+
/** The file tools whose success is news for the daemon's other clients (the
|
|
104
|
+
* `fs-changed` broadcast): every tool that writes or removes a file under a
|
|
105
|
+
* root. The fs tools have no rename or mkdir; a write makes its parents. */
|
|
106
|
+
const FS_CHANGE_TOOLS = new Set(['sidecar_fs_write_file', 'sidecar_fs_write_bytes', 'sidecar_fs_delete']);
|
|
107
|
+
/** Gives each connection the id it publishes file changes under, so the bus
|
|
108
|
+
* can tell the writer apart from everyone else. Per process, like the bus. */
|
|
109
|
+
let fsConnectionSeq = 0;
|
|
98
110
|
/** Server names a credential message may never carry: auth.json is keyed by
|
|
99
111
|
* server name, and these are Object.prototype's own. */
|
|
100
112
|
const AGENT_CRED_RESERVED_SERVERS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
@@ -211,6 +223,146 @@ function agentRunsRefOf(x) {
|
|
|
211
223
|
default: return null;
|
|
212
224
|
}
|
|
213
225
|
}
|
|
226
|
+
/* DAEMON CHAT INPUT (Harness Chat Plan 2). Validated here like the
|
|
227
|
+
* neighbouring messages, before the chat port sees any of it; the port checks
|
|
228
|
+
* ids again before a file is touched. */
|
|
229
|
+
/** A chat turn's text and one injected message: at most this many characters. */
|
|
230
|
+
const CHAT_MAX_TEXT = 200000;
|
|
231
|
+
const CHAT_MAX_AGENT_LEN = 128;
|
|
232
|
+
/** Messages in one run-inject. */
|
|
233
|
+
const CHAT_MAX_INJECT = 20;
|
|
234
|
+
const CHAT_MAX_RUN_ID_LEN = 200;
|
|
235
|
+
const CHAT_MAX_MESSAGE_ID_LEN = 128;
|
|
236
|
+
const CHAT_MAX_REASON_LEN = 500;
|
|
237
|
+
/** An ask_user answer: questions, choices per question, and their lengths. */
|
|
238
|
+
const CHAT_MAX_ANSWER_KEYS = 32;
|
|
239
|
+
const CHAT_MAX_ANSWER_CHOICES = 32;
|
|
240
|
+
const CHAT_MAX_ANSWER_LEN = 2000;
|
|
241
|
+
/** The ask-user deadline shown when the runner gave none: its default park
|
|
242
|
+
* timeout (PARK_TIMEOUT_MS in packages/sidecar, which this package cannot
|
|
243
|
+
* import). The runner's own bound still decides; this is only what is shown. */
|
|
244
|
+
const CHAT_ASK_FALLBACK_MS = 30 * 60 * 1000;
|
|
245
|
+
/** The runner's RUN_NO_ACCESS text: a chat needs a verified person. */
|
|
246
|
+
const CHAT_NO_ACCESS = 'You do not have access to run this agent.';
|
|
247
|
+
const CHAT_UNAVAILABLE = 'this sidecar does not hold chats';
|
|
248
|
+
const CHAT_NOT_ALONE = 'A chat turn runs on its own; it cannot take a session or a task.';
|
|
249
|
+
const CHAT_BAD_AGENT = 'That is not a crew agent name.';
|
|
250
|
+
const CHAT_BAD_TEXT = `A chat turn needs text of at most ${CHAT_MAX_TEXT} characters.`;
|
|
251
|
+
const CHAT_BAD_SPEC = 'chat must be an object.';
|
|
252
|
+
const CHAT_BAD_ID = 'CHAT_BAD_ID: that is not a chat id';
|
|
253
|
+
const CHAT_BAD_MEMBER = 'CHAT_BAD_ID: that is not a member id';
|
|
254
|
+
const CHAT_BAD_LIMIT = 'A chat limit must be a positive number of dollars.';
|
|
255
|
+
const CHAT_BAD_RUN = 'That is not a run id.';
|
|
256
|
+
const CHAT_BAD_INJECT = `Send 1 to ${CHAT_MAX_INJECT} messages, each with an id and text.`;
|
|
257
|
+
const CHAT_INJECT_FAILED = 'Could not deliver the messages.';
|
|
258
|
+
const CHAT_READ_FAILED = 'Could not read the chats.';
|
|
259
|
+
function chatAgentOk(a) {
|
|
260
|
+
return typeof a === 'string' && a.length <= CHAT_MAX_AGENT_LEN && AGENT_RUNS_NAME_RE.test(a);
|
|
261
|
+
}
|
|
262
|
+
function chatIdOk(x) {
|
|
263
|
+
return typeof x === 'string' && CHAT_ID_RE.test(x);
|
|
264
|
+
}
|
|
265
|
+
function plainObject(x) {
|
|
266
|
+
return typeof x === 'object' && x !== null && !Array.isArray(x);
|
|
267
|
+
}
|
|
268
|
+
/** A chat message's `ref`: absent, or a valid ref naming the same agent.
|
|
269
|
+
* Null is a refusal. */
|
|
270
|
+
function chatRefOf(x, agent) {
|
|
271
|
+
if (x === undefined)
|
|
272
|
+
return {};
|
|
273
|
+
const ref = agentRunsRefOf(x);
|
|
274
|
+
return ref !== null && ref.agent === agent ? { ref } : null;
|
|
275
|
+
}
|
|
276
|
+
/** `run.chat` rebuilt from its own fields, or the refusal to send. */
|
|
277
|
+
function chatSpecOf(x, agent) {
|
|
278
|
+
if (!plainObject(x))
|
|
279
|
+
return CHAT_BAD_SPEC;
|
|
280
|
+
if (x.conversationId !== undefined && !chatIdOk(x.conversationId))
|
|
281
|
+
return CHAT_BAD_ID;
|
|
282
|
+
const maxUsd = x.maxUsd;
|
|
283
|
+
if (maxUsd !== undefined && !(typeof maxUsd === 'number' && Number.isFinite(maxUsd) && maxUsd > 0))
|
|
284
|
+
return CHAT_BAD_LIMIT;
|
|
285
|
+
const ref = chatRefOf(x.ref, agent);
|
|
286
|
+
if (ref === null)
|
|
287
|
+
return AGENT_RUNS_BAD_REF;
|
|
288
|
+
return {
|
|
289
|
+
conversationId: typeof x.conversationId === 'string' ? x.conversationId : null,
|
|
290
|
+
...(typeof maxUsd === 'number' ? { maxUsd } : {}),
|
|
291
|
+
...ref,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
/** run-inject's messages rebuilt, or null when any is malformed. */
|
|
295
|
+
function injectMessagesOf(x) {
|
|
296
|
+
if (!Array.isArray(x) || x.length === 0 || x.length > CHAT_MAX_INJECT)
|
|
297
|
+
return null;
|
|
298
|
+
const out = [];
|
|
299
|
+
for (const m of x) {
|
|
300
|
+
if (!plainObject(m))
|
|
301
|
+
return null;
|
|
302
|
+
const { id, text } = m;
|
|
303
|
+
if (typeof id !== 'string' || !id || id.length > CHAT_MAX_MESSAGE_ID_LEN || hasControlChar(id))
|
|
304
|
+
return null;
|
|
305
|
+
if (typeof text !== 'string' || !text.trim() || text.length > CHAT_MAX_TEXT)
|
|
306
|
+
return null;
|
|
307
|
+
out.push({ id, text });
|
|
308
|
+
}
|
|
309
|
+
return out;
|
|
310
|
+
}
|
|
311
|
+
/** An ask-user answer rebuilt: null (declined), the answers, or undefined
|
|
312
|
+
* when it is malformed (then it is ignored, and the ask stays open). */
|
|
313
|
+
function askAnswersOf(x) {
|
|
314
|
+
if (x === null)
|
|
315
|
+
return null;
|
|
316
|
+
if (!plainObject(x))
|
|
317
|
+
return undefined;
|
|
318
|
+
const entries = Object.entries(x);
|
|
319
|
+
if (entries.length > CHAT_MAX_ANSWER_KEYS)
|
|
320
|
+
return undefined;
|
|
321
|
+
const out = [];
|
|
322
|
+
for (const [key, choices] of entries) {
|
|
323
|
+
if (key.length > CHAT_MAX_ANSWER_LEN || !Array.isArray(choices) || choices.length > CHAT_MAX_ANSWER_CHOICES)
|
|
324
|
+
return undefined;
|
|
325
|
+
const kept = [];
|
|
326
|
+
for (const c of choices) {
|
|
327
|
+
if (typeof c !== 'string' || c.length > CHAT_MAX_ANSWER_LEN)
|
|
328
|
+
return undefined;
|
|
329
|
+
kept.push(c);
|
|
330
|
+
}
|
|
331
|
+
out.push([key, kept]);
|
|
332
|
+
}
|
|
333
|
+
return Object.fromEntries(out);
|
|
334
|
+
}
|
|
335
|
+
/** A summary rebuilt from its own fields, so a port that returned more could
|
|
336
|
+
* never put it on the wire. */
|
|
337
|
+
function chatSummaryOf(c) {
|
|
338
|
+
return {
|
|
339
|
+
conversationId: String(c.conversationId), agent: String(c.agent), title: String(c.title),
|
|
340
|
+
updatedAt: Number(c.updatedAt), turns: Number(c.turns), usd: Number(c.usd),
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
function storedChatOf(c) {
|
|
344
|
+
return {
|
|
345
|
+
id: String(c.id), agent: String(c.agent), requester: String(c.requester), title: String(c.title),
|
|
346
|
+
messages: Array.isArray(c.messages) ? c.messages : [], updatedAt: Number(c.updatedAt),
|
|
347
|
+
...(c.titleLocked !== undefined ? { titleLocked: c.titleLocked === true } : {}),
|
|
348
|
+
...(typeof c.model === 'string' ? { model: c.model } : {}),
|
|
349
|
+
...(typeof c.system === 'string' ? { system: c.system } : {}),
|
|
350
|
+
...(typeof c.maxUsd === 'number' ? { maxUsd: c.maxUsd } : {}),
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
/** The text of a chat read failure a reply may carry: a coded refusal, or the
|
|
354
|
+
* runner's own wording about the agent. Anything else (a path, an errno) is
|
|
355
|
+
* null, and the caller logs it and sends fixed text. */
|
|
356
|
+
function chatErrorOf(err) {
|
|
357
|
+
const msg = errMessage(err);
|
|
358
|
+
if (msg.length > CHAT_MAX_REASON_LEN)
|
|
359
|
+
return null;
|
|
360
|
+
if (/^CHAT_[A-Z_]+: /.test(msg) || msg === CHAT_NO_ACCESS)
|
|
361
|
+
return msg;
|
|
362
|
+
if (/^(crew agent not found|invalid crew agent name): [A-Za-z0-9_-]*$/.test(msg))
|
|
363
|
+
return msg;
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
214
366
|
/** The access code a thrown refusal carries, when it is one a reply may name. */
|
|
215
367
|
function accessCodeOf(err) {
|
|
216
368
|
const code = typeof err === 'object' && err !== null ? err.code : undefined;
|
|
@@ -635,6 +787,27 @@ export class SidecarServer {
|
|
|
635
787
|
writable: true,
|
|
636
788
|
value: void 0
|
|
637
789
|
});
|
|
790
|
+
/** This connection's seat for chat ask_user (verified connections only),
|
|
791
|
+
* and its leave function, called once on close. */
|
|
792
|
+
Object.defineProperty(this, "askSeat", {
|
|
793
|
+
enumerable: true,
|
|
794
|
+
configurable: true,
|
|
795
|
+
writable: true,
|
|
796
|
+
value: void 0
|
|
797
|
+
});
|
|
798
|
+
Object.defineProperty(this, "askSeatLeave", {
|
|
799
|
+
enumerable: true,
|
|
800
|
+
configurable: true,
|
|
801
|
+
writable: true,
|
|
802
|
+
value: void 0
|
|
803
|
+
});
|
|
804
|
+
/** Used when the daemon wired no shared broker: this connection only. */
|
|
805
|
+
Object.defineProperty(this, "ownAskUsers", {
|
|
806
|
+
enumerable: true,
|
|
807
|
+
configurable: true,
|
|
808
|
+
writable: true,
|
|
809
|
+
value: void 0
|
|
810
|
+
});
|
|
638
811
|
/** Detach from the peer routes, when this connection serves MCP. */
|
|
639
812
|
Object.defineProperty(this, "peersDetach", {
|
|
640
813
|
enumerable: true,
|
|
@@ -720,6 +893,14 @@ export class SidecarServer {
|
|
|
720
893
|
writable: true,
|
|
721
894
|
value: void 0
|
|
722
895
|
});
|
|
896
|
+
/** What this connection publishes its file changes under on the shared bus,
|
|
897
|
+
* and how it recognises its own changes there (never sent back to it). */
|
|
898
|
+
Object.defineProperty(this, "fsConnectionId", {
|
|
899
|
+
enumerable: true,
|
|
900
|
+
configurable: true,
|
|
901
|
+
writable: true,
|
|
902
|
+
value: `c${++fsConnectionSeq}`
|
|
903
|
+
});
|
|
723
904
|
/* ---- sessions ---- */
|
|
724
905
|
/** Per-session stream stoppers for THIS connection.
|
|
725
906
|
*
|
|
@@ -866,6 +1047,19 @@ export class SidecarServer {
|
|
|
866
1047
|
return;
|
|
867
1048
|
this.ch.send({ t: 'room-progress', room });
|
|
868
1049
|
});
|
|
1050
|
+
/* Same gating as roots and rooms, plus: never the writer itself (it knows
|
|
1051
|
+
* what it wrote, and its own listeners already fired). Only the root and
|
|
1052
|
+
* the path cross, never content. No team rule narrows fs READS for a
|
|
1053
|
+
* member (onInvoke gates writes only, and every active member is served
|
|
1054
|
+
* the same file tools over the same roots), so any connection this would
|
|
1055
|
+
* reach could list the same path itself. */
|
|
1056
|
+
const offFsChanges = opts.fsChanges?.subscribe((c) => {
|
|
1057
|
+
if (c.by === this.fsConnectionId)
|
|
1058
|
+
return;
|
|
1059
|
+
if (!this.authed || this.closed || this.peerDaemon || this.memberPending())
|
|
1060
|
+
return;
|
|
1061
|
+
this.ch.send({ t: 'fs-changed', root: c.root, path: c.path });
|
|
1062
|
+
});
|
|
869
1063
|
/* A member approved while connected is told so at once (Part E), so the
|
|
870
1064
|
* waiting screen can move on; the gate itself re-reads the status on the
|
|
871
1065
|
* next message. Only THIS connection's verified user is ever told.
|
|
@@ -934,6 +1128,9 @@ export class SidecarServer {
|
|
|
934
1128
|
this.identityAssertResolve = undefined;
|
|
935
1129
|
this.attendedDetach?.();
|
|
936
1130
|
this.attendedDetach = undefined;
|
|
1131
|
+
this.askSeatLeave?.();
|
|
1132
|
+
this.askSeatLeave = undefined;
|
|
1133
|
+
this.askSeat = undefined;
|
|
937
1134
|
this.peersDetach?.();
|
|
938
1135
|
this.peersDetach = undefined;
|
|
939
1136
|
// Leave the live set, or this directory reads as connected for ever and
|
|
@@ -947,6 +1144,7 @@ export class SidecarServer {
|
|
|
947
1144
|
this.runDetachers.clear();
|
|
948
1145
|
offRoots?.();
|
|
949
1146
|
offRooms?.();
|
|
1147
|
+
offFsChanges?.();
|
|
950
1148
|
offMembers?.();
|
|
951
1149
|
offAdmission?.();
|
|
952
1150
|
// Stop streaming process output into a dead socket. The PROCESSES keep
|
|
@@ -1065,8 +1263,47 @@ export class SidecarServer {
|
|
|
1065
1263
|
this.ch.send({ t: 'run-error', id: m.id, error: 'not authenticated' });
|
|
1066
1264
|
return;
|
|
1067
1265
|
}
|
|
1266
|
+
// A chat turn (Harness Chat Plan 2) takes its own path: the daemon's chat port, never runner.run.
|
|
1267
|
+
if (m.chat !== undefined) {
|
|
1268
|
+
this.onChatRun(m);
|
|
1269
|
+
return;
|
|
1270
|
+
}
|
|
1068
1271
|
void this.onRun(m);
|
|
1069
1272
|
return;
|
|
1273
|
+
case 'run-inject':
|
|
1274
|
+
if (!this.authed) {
|
|
1275
|
+
this.ch.send({ t: 'run-inject-result', id: m.id, ok: false, reason: 'not authenticated' });
|
|
1276
|
+
return;
|
|
1277
|
+
}
|
|
1278
|
+
void this.onRunInject(m);
|
|
1279
|
+
return;
|
|
1280
|
+
case 'chat-list':
|
|
1281
|
+
if (!this.authed) {
|
|
1282
|
+
this.ch.send({ t: 'chat-list-result', id: m.id, ok: false, error: 'not authenticated' });
|
|
1283
|
+
return;
|
|
1284
|
+
}
|
|
1285
|
+
void this.onChatList(m);
|
|
1286
|
+
return;
|
|
1287
|
+
case 'chat-get':
|
|
1288
|
+
if (!this.authed) {
|
|
1289
|
+
this.ch.send({ t: 'chat-get-result', id: m.id, ok: false, error: 'not authenticated' });
|
|
1290
|
+
return;
|
|
1291
|
+
}
|
|
1292
|
+
void this.onChatGet(m);
|
|
1293
|
+
return;
|
|
1294
|
+
case 'ask-user-answer': {
|
|
1295
|
+
// No reply channel, like permission-answer: before authentication, or
|
|
1296
|
+
// from a connection with no seat, it is dropped. The broker ignores an
|
|
1297
|
+
// answer from a seat the ask was not shown to, or for a settled id.
|
|
1298
|
+
if (!this.authed)
|
|
1299
|
+
return;
|
|
1300
|
+
const seat = this.askSeat;
|
|
1301
|
+
const answers = askAnswersOf(m.answers);
|
|
1302
|
+
if (!seat || !Number.isSafeInteger(m.id) || answers === undefined)
|
|
1303
|
+
return;
|
|
1304
|
+
this.askUsers().answer(m.id, seat, answers);
|
|
1305
|
+
return;
|
|
1306
|
+
}
|
|
1070
1307
|
case 'subscribe':
|
|
1071
1308
|
// Guarded by auth like `run` (auth boundary). Pre-auth: drop silently —
|
|
1072
1309
|
// subscribe has no response channel, so no message is the correct
|
|
@@ -1623,6 +1860,24 @@ export class SidecarServer {
|
|
|
1623
1860
|
this.noteOutcome('denied');
|
|
1624
1861
|
this.countFailedHello();
|
|
1625
1862
|
}
|
|
1863
|
+
/** Whether the policy admits nobody for now, for a reason that clears by itself. A throw reads as not paused. */
|
|
1864
|
+
paused() {
|
|
1865
|
+
try {
|
|
1866
|
+
return this.opts.admission?.paused?.() === true;
|
|
1867
|
+
}
|
|
1868
|
+
catch (err) {
|
|
1869
|
+
this.opts.log?.(`admission paused() threw: ${errMessage(err)}`);
|
|
1870
|
+
return false;
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
/** Refuse a PROVEN device while sign-in is paused, saying so, and close: a retry is a fresh connection. Not a
|
|
1874
|
+
* failed credential, so it is not counted toward MAX_FAILED_HELLOS or against the address. */
|
|
1875
|
+
denyPaused() {
|
|
1876
|
+
this.opts.log?.('denied: sign-in is paused until the fleet state allows it again');
|
|
1877
|
+
this.ch.send({ t: 'denied', reason: SIGN_IN_PAUSED_DENIED });
|
|
1878
|
+
this.noteOutcome('paused');
|
|
1879
|
+
this.ch.close();
|
|
1880
|
+
}
|
|
1626
1881
|
/** Tell the daemon's rate limiter how a hello ended. Its failure is never
|
|
1627
1882
|
* the handshake's. */
|
|
1628
1883
|
noteOutcome(outcome) {
|
|
@@ -1667,23 +1922,27 @@ export class SidecarServer {
|
|
|
1667
1922
|
* caller's uniform denial on the wire. */
|
|
1668
1923
|
async identityGate(jkt, boundUser) {
|
|
1669
1924
|
if (!this.opts.identity)
|
|
1670
|
-
return
|
|
1925
|
+
return 'ok';
|
|
1671
1926
|
const sidecarId = this.opts.info?.id;
|
|
1672
1927
|
if (!sidecarId) {
|
|
1673
1928
|
this.opts.log?.('identity: refused (this daemon has no id for an assertion to name)');
|
|
1674
|
-
return
|
|
1929
|
+
return 'refused';
|
|
1675
1930
|
}
|
|
1676
1931
|
const nonce = makeNonce();
|
|
1677
1932
|
const orgId = this.opts.admission?.orgId;
|
|
1678
1933
|
this.ch.send({ t: 'identity-challenge', nonce, sidecarId, ...(orgId ? { orgId } : {}), ...(this.challengeIssuer() ? { issuer: this.challengeIssuer() } : {}) });
|
|
1934
|
+
/* PAUSED: after the challenge (so an older client still reads an account refusal and keeps its device), and
|
|
1935
|
+
* without waiting for an assertion: nobody would be admitted whatever it said. */
|
|
1936
|
+
if (this.paused())
|
|
1937
|
+
return 'paused';
|
|
1679
1938
|
const verdict = await this.awaitAndVerifyIdentity(sidecarId, nonce, jkt, IDENTITY_TIMEOUT_MS);
|
|
1680
1939
|
if (!verdict.ok) {
|
|
1681
1940
|
this.opts.log?.(`identity: unverified (${verdict.reason})`);
|
|
1682
|
-
return
|
|
1941
|
+
return 'refused';
|
|
1683
1942
|
}
|
|
1684
1943
|
if (!this.admits(verdict.identity)) {
|
|
1685
1944
|
this.opts.log?.(orgId ? `identity: verified, but not a member of ${orgId}` : 'identity: verified, but not the account this daemon belongs to');
|
|
1686
|
-
return
|
|
1945
|
+
return this.paused() ? 'paused' : 'refused';
|
|
1687
1946
|
}
|
|
1688
1947
|
/* TEAM: ONE USER PER DEVICE. A device belongs to the first verified user
|
|
1689
1948
|
* it connected as; another member signed in on the same browser does not
|
|
@@ -1691,7 +1950,7 @@ export class SidecarServer {
|
|
|
1691
1950
|
* user). A device recorded with no user yet is taken by this one. */
|
|
1692
1951
|
if (this.opts.admission?.oneUserPerDevice && boundUser !== undefined && boundUser !== verdict.identity.userId) {
|
|
1693
1952
|
this.opts.log?.('identity: verified, but this device belongs to another user');
|
|
1694
|
-
return
|
|
1953
|
+
return 'refused';
|
|
1695
1954
|
}
|
|
1696
1955
|
this.verifiedUserId = verdict.identity.userId;
|
|
1697
1956
|
this.verifiedIdentity = verdict.identity;
|
|
@@ -1699,7 +1958,7 @@ export class SidecarServer {
|
|
|
1699
1958
|
this.verifiedPerson = { ...(name ? { name } : {}), ...(email ? { email } : {}) };
|
|
1700
1959
|
this.identityResult = { verified: true };
|
|
1701
1960
|
this.opts.log?.('identity: verified');
|
|
1702
|
-
return
|
|
1961
|
+
return 'ok';
|
|
1703
1962
|
}
|
|
1704
1963
|
/** Start the periodic re-check of an authenticated connection's identity
|
|
1705
1964
|
* (`SidecarAdmission.recheckMs`, team mode). */
|
|
@@ -1839,9 +2098,13 @@ export class SidecarServer {
|
|
|
1839
2098
|
return null;
|
|
1840
2099
|
}
|
|
1841
2100
|
const deviceJkt = await jwkThumbprint(key);
|
|
1842
|
-
|
|
2101
|
+
const gate = await this.identityGate(deviceJkt, record.verifiedUserId);
|
|
2102
|
+
if (gate !== 'ok') {
|
|
1843
2103
|
this.unwatchRevocation();
|
|
1844
|
-
|
|
2104
|
+
if (gate === 'paused')
|
|
2105
|
+
this.denyPaused();
|
|
2106
|
+
else
|
|
2107
|
+
this.denyCredential('identity');
|
|
1845
2108
|
return null;
|
|
1846
2109
|
}
|
|
1847
2110
|
return { deviceId: id, deviceJkt };
|
|
@@ -1896,8 +2159,12 @@ export class SidecarServer {
|
|
|
1896
2159
|
}
|
|
1897
2160
|
/* WHO, before anything is written: an unverified or unadmitted identity
|
|
1898
2161
|
* is refused here with the code still unburned. */
|
|
1899
|
-
|
|
1900
|
-
|
|
2162
|
+
const gate = await this.identityGate(keyJkt);
|
|
2163
|
+
if (gate !== 'ok') {
|
|
2164
|
+
if (gate === 'paused')
|
|
2165
|
+
this.denyPaused();
|
|
2166
|
+
else
|
|
2167
|
+
this.denyCredential('identity');
|
|
1901
2168
|
return null;
|
|
1902
2169
|
}
|
|
1903
2170
|
const deviceId = makeDeviceId();
|
|
@@ -2019,7 +2286,10 @@ export class SidecarServer {
|
|
|
2019
2286
|
if (this.verifiedIdentity && !this.admits(this.verifiedIdentity)) {
|
|
2020
2287
|
this.opts.log?.('identity: no longer admitted by the fleet state');
|
|
2021
2288
|
this.unwatchRevocation();
|
|
2022
|
-
this.
|
|
2289
|
+
if (this.paused())
|
|
2290
|
+
this.denyPaused();
|
|
2291
|
+
else
|
|
2292
|
+
this.denyCredential('identity');
|
|
2023
2293
|
this.helloInFlight = false;
|
|
2024
2294
|
return;
|
|
2025
2295
|
}
|
|
@@ -2213,6 +2483,20 @@ export class SidecarServer {
|
|
|
2213
2483
|
call: (method, args) => this.hostCall('mcp', method, args),
|
|
2214
2484
|
});
|
|
2215
2485
|
}
|
|
2486
|
+
/* Chat ask_user (R8): a verified person's connection takes a seat, so an
|
|
2487
|
+
* ask for them or for the admin reaches it, and one already open is
|
|
2488
|
+
* delivered now. A pending member is shown nothing, checked per send. */
|
|
2489
|
+
if (this.opts.runner?.chat && this.verifiedUserId && !isPeerDaemon) {
|
|
2490
|
+
const seat = {
|
|
2491
|
+
userId: this.verifiedUserId,
|
|
2492
|
+
ask: (p) => { if (!this.memberPending())
|
|
2493
|
+
this.ch.send({ t: 'ask-user', ...p }); },
|
|
2494
|
+
resolved: (id, by) => { if (!this.memberPending())
|
|
2495
|
+
this.ch.send({ t: 'ask-user-resolved', id, by }); },
|
|
2496
|
+
};
|
|
2497
|
+
this.askSeat = seat;
|
|
2498
|
+
this.askSeatLeave = this.askUsers().join(seat);
|
|
2499
|
+
}
|
|
2216
2500
|
if (this.opts.attendance && !isPeerDaemon) {
|
|
2217
2501
|
/* A pending member (Part E) is never a recipient of anything: every
|
|
2218
2502
|
* send below checks the status at the moment it is made, so a member
|
|
@@ -2445,12 +2729,19 @@ export class SidecarServer {
|
|
|
2445
2729
|
// A client starts a task only where the daemon will honour it: an older
|
|
2446
2730
|
// runner ignores `task`, and a dropped task must never look like a start.
|
|
2447
2731
|
...(this.opts.runner?.supportsTasks ? [RUN_TASK_FEATURE] : []),
|
|
2732
|
+
// Daemon chat (Harness Chat Plan 2): run.chat, run-inject, chat-list,
|
|
2733
|
+
// chat-get and ask-user, only with a chat port behind them and a
|
|
2734
|
+
// verified user on this connection: every chat turn and read is refused
|
|
2735
|
+
// without one, so a client must not offer a surface that always refuses.
|
|
2736
|
+
...(this.opts.runner?.chat && this.verifiedUserId ? [CHAT_FEATURE] : []),
|
|
2448
2737
|
// Whether this daemon answers run-plan-get (a runner that reads run records).
|
|
2449
2738
|
...(this.opts.runner?.runPlan ? [RUN_PLAN_FEATURE] : []),
|
|
2450
2739
|
// Whether this daemon fans a delegated callee's own node events out to
|
|
2451
2740
|
// watchers of the parent run (the running-pills card). Only with a live
|
|
2452
2741
|
// hub behind it -- there is otherwise nothing to fan out from.
|
|
2453
2742
|
...(this.opts.runs ? [CHILD_EVENTS_FEATURE] : []),
|
|
2743
|
+
// Whether a write another client makes reaches this one as fs-changed.
|
|
2744
|
+
...(this.opts.fsChanges ? [FS_CHANGED_FEATURE] : []),
|
|
2454
2745
|
...(this.opts.acp ? ['acp'] : []),
|
|
2455
2746
|
...(this.opts.roots?.areas.length ? ['add-roots'] : []),
|
|
2456
2747
|
...(this.opts.roots?.areas.length && this.opts.roots.create ? ['create-roots'] : []),
|
|
@@ -2906,6 +3197,9 @@ export class SidecarServer {
|
|
|
2906
3197
|
}
|
|
2907
3198
|
}
|
|
2908
3199
|
this.ch.send({ t: 'invoke-result', id: m.id, ok: true, result });
|
|
3200
|
+
// After the answer, and only for a change that happened.
|
|
3201
|
+
if (FS_CHANGE_TOOLS.has(m.name))
|
|
3202
|
+
this.publishFsChange(m.args);
|
|
2909
3203
|
}
|
|
2910
3204
|
catch (e) {
|
|
2911
3205
|
// A read that finds nothing there (ENOENT) answered the question -- the
|
|
@@ -2917,6 +3211,31 @@ export class SidecarServer {
|
|
|
2917
3211
|
this.ch.send({ t: 'invoke-result', id: m.id, ok: false, error: errMessage(e) });
|
|
2918
3212
|
}
|
|
2919
3213
|
}
|
|
3214
|
+
/** Announce a file tool's change to the daemon's other clients. The root is
|
|
3215
|
+
* the one the tool resolved against, spelled as the daemon reports its
|
|
3216
|
+
* roots (that is what every client names it by), and the path is relative
|
|
3217
|
+
* to it, resolved as the tool confined it (fsChangesFor). Never throws:
|
|
3218
|
+
* the change already happened and its caller already has its answer. */
|
|
3219
|
+
publishFsChange(args) {
|
|
3220
|
+
const bus = this.opts.fsChanges;
|
|
3221
|
+
if (!bus)
|
|
3222
|
+
return;
|
|
3223
|
+
try {
|
|
3224
|
+
const path = typeof args.path === 'string' ? args.path : '';
|
|
3225
|
+
if (!path)
|
|
3226
|
+
return;
|
|
3227
|
+
const snap = this.opts.roots?.snapshot();
|
|
3228
|
+
const info = this.opts.info;
|
|
3229
|
+
const roots = snap?.roots ?? info?.roots ?? (info?.root ? [info.root] : []);
|
|
3230
|
+
const primary = snap?.root ?? info?.root ?? roots[0];
|
|
3231
|
+
// Where the tool actually wrote (a `../` or absolute path can land in another root).
|
|
3232
|
+
for (const change of fsChangesFor(roots, primary, args.root, path))
|
|
3233
|
+
bus.publish({ ...change, by: this.fsConnectionId });
|
|
3234
|
+
}
|
|
3235
|
+
catch (err) {
|
|
3236
|
+
this.opts.log?.(`fs change not announced: ${errMessage(err)}`);
|
|
3237
|
+
}
|
|
3238
|
+
}
|
|
2920
3239
|
/** Runs a crew agent via the configured `runner` and streams its lifecycle back
|
|
2921
3240
|
* to the host: `run-accepted` (with the daemon-assigned runId) once the runner
|
|
2922
3241
|
* has started, one `event` per `onEvent` callback, then exactly one of
|
|
@@ -3267,6 +3586,225 @@ export class SidecarServer {
|
|
|
3267
3586
|
this.ch.send({ t: 'run-error', id: m.id, error: message });
|
|
3268
3587
|
}
|
|
3269
3588
|
}
|
|
3589
|
+
/** The daemon's ask_user broker, or this connection's own when none is wired. */
|
|
3590
|
+
askUsers() {
|
|
3591
|
+
return this.opts.askUsers ?? (this.ownAskUsers ?? (this.ownAskUsers = new AskUserBroker()));
|
|
3592
|
+
}
|
|
3593
|
+
/** The sidecar admin's verified user id, now; undefined when unknown. A throw is "none". */
|
|
3594
|
+
chatAdmin() {
|
|
3595
|
+
try {
|
|
3596
|
+
return this.opts.access?.adminId();
|
|
3597
|
+
}
|
|
3598
|
+
catch {
|
|
3599
|
+
return undefined;
|
|
3600
|
+
}
|
|
3601
|
+
}
|
|
3602
|
+
/** One chat turn (Harness Chat Plan 2). The requester is THIS connection's
|
|
3603
|
+
* verified user and never a message field; with none it is refused. The
|
|
3604
|
+
* events, `run-accepted` (with the conversation id), `run-done` and the hub
|
|
3605
|
+
* publishing follow `onRun`. A refused or failed turn sends `run-error`. */
|
|
3606
|
+
onChatRun(m) {
|
|
3607
|
+
const refuse = (error) => { this.ch.send({ t: 'run-error', id: m.id, error }); };
|
|
3608
|
+
const port = this.opts.runner?.chat;
|
|
3609
|
+
if (!port) {
|
|
3610
|
+
refuse(CHAT_UNAVAILABLE);
|
|
3611
|
+
return;
|
|
3612
|
+
}
|
|
3613
|
+
if (m.sessionId !== undefined || m.task !== undefined || m.autonomy !== undefined) {
|
|
3614
|
+
refuse(CHAT_NOT_ALONE);
|
|
3615
|
+
return;
|
|
3616
|
+
}
|
|
3617
|
+
const user = this.verifiedUserId;
|
|
3618
|
+
if (!user) {
|
|
3619
|
+
refuse(CHAT_NO_ACCESS);
|
|
3620
|
+
return;
|
|
3621
|
+
}
|
|
3622
|
+
if (!chatAgentOk(m.agent)) {
|
|
3623
|
+
refuse(CHAT_BAD_AGENT);
|
|
3624
|
+
return;
|
|
3625
|
+
}
|
|
3626
|
+
if (typeof m.input !== 'string' || m.input.length > CHAT_MAX_TEXT) {
|
|
3627
|
+
refuse(CHAT_BAD_TEXT);
|
|
3628
|
+
return;
|
|
3629
|
+
}
|
|
3630
|
+
const spec = chatSpecOf(m.chat, m.agent);
|
|
3631
|
+
if (typeof spec === 'string') {
|
|
3632
|
+
refuse(spec);
|
|
3633
|
+
return;
|
|
3634
|
+
}
|
|
3635
|
+
const agent = m.agent;
|
|
3636
|
+
const hub = this.opts.runs;
|
|
3637
|
+
let runId;
|
|
3638
|
+
let ended = false;
|
|
3639
|
+
const early = [];
|
|
3640
|
+
const forward = (id, event) => {
|
|
3641
|
+
hub?.publish(id, event);
|
|
3642
|
+
this.ch.send({ t: 'event', id: m.id, runId: id, event });
|
|
3643
|
+
};
|
|
3644
|
+
const done = (r) => {
|
|
3645
|
+
if (ended)
|
|
3646
|
+
return;
|
|
3647
|
+
ended = true;
|
|
3648
|
+
if ((r.outcome === 'done' || r.outcome === 'stopped') && runId !== undefined) {
|
|
3649
|
+
const stopReason = r.stopReason ?? r.outcome;
|
|
3650
|
+
hub?.finish(runId, { text: r.text, stopReason });
|
|
3651
|
+
this.ch.send({ t: 'run-done', id: m.id, runId, text: r.text, stopReason, outcome: r.outcome });
|
|
3652
|
+
return;
|
|
3653
|
+
}
|
|
3654
|
+
const outcome = r.outcome === 'refused' ? 'refused' : 'failed';
|
|
3655
|
+
const error = r.error ?? (r.outcome === 'refused' ? 'The chat turn was refused.'
|
|
3656
|
+
: runId === undefined ? 'The chat turn ended without a run.' : 'The chat turn failed.');
|
|
3657
|
+
// Watchers too: a failed run must not look merely silent to a second viewer.
|
|
3658
|
+
if (runId !== undefined)
|
|
3659
|
+
hub?.fail(runId, error);
|
|
3660
|
+
this.ch.send({ t: 'run-error', id: m.id, error, outcome });
|
|
3661
|
+
};
|
|
3662
|
+
const askers = () => {
|
|
3663
|
+
const admin = this.chatAdmin();
|
|
3664
|
+
return admin && admin !== user ? [user, admin] : [user];
|
|
3665
|
+
};
|
|
3666
|
+
try {
|
|
3667
|
+
port.turn({
|
|
3668
|
+
agent, requester: user, identity: { clientId: this.clientId },
|
|
3669
|
+
conversationId: spec.conversationId, text: m.input,
|
|
3670
|
+
...(spec.ref ? { ref: spec.ref } : {}),
|
|
3671
|
+
...(spec.maxUsd !== undefined ? { maxUsd: spec.maxUsd } : {}),
|
|
3672
|
+
}, {
|
|
3673
|
+
onAccepted: (id, conversationId) => {
|
|
3674
|
+
// One run per turn: a second id is not this turn's.
|
|
3675
|
+
if (ended || runId !== undefined)
|
|
3676
|
+
return;
|
|
3677
|
+
runId = id;
|
|
3678
|
+
hub?.open(id, user);
|
|
3679
|
+
this.ch.send({ t: 'run-accepted', id: m.id, runId: id, conversationId });
|
|
3680
|
+
for (const e of early.splice(0))
|
|
3681
|
+
if (e.runId === id)
|
|
3682
|
+
forward(id, e.event);
|
|
3683
|
+
},
|
|
3684
|
+
onEvent: (id, event) => {
|
|
3685
|
+
if (ended)
|
|
3686
|
+
return;
|
|
3687
|
+
// Buffered until the run's id is known, as onRun does.
|
|
3688
|
+
if (runId === undefined) {
|
|
3689
|
+
early.push({ runId: id, event });
|
|
3690
|
+
return;
|
|
3691
|
+
}
|
|
3692
|
+
if (id === runId)
|
|
3693
|
+
forward(id, event);
|
|
3694
|
+
},
|
|
3695
|
+
onDone: done,
|
|
3696
|
+
// R8 / Q2: the requester and the admin, read when the question is asked.
|
|
3697
|
+
askUser: (id, questions, signal, deadline) => this.askUsers().ask({
|
|
3698
|
+
users: askers(), runId: id, agent, questions,
|
|
3699
|
+
deadline: typeof deadline === 'number' && Number.isFinite(deadline) ? deadline : this.now() + CHAT_ASK_FALLBACK_MS,
|
|
3700
|
+
}, signal),
|
|
3701
|
+
});
|
|
3702
|
+
}
|
|
3703
|
+
catch (err) {
|
|
3704
|
+
done({ conversationId: spec.conversationId, runId: null, outcome: 'failed', error: errMessage(err), text: '' });
|
|
3705
|
+
}
|
|
3706
|
+
}
|
|
3707
|
+
/** Messages typed while a chat turn runs. The requester is THIS
|
|
3708
|
+
* connection's verified user; the port refuses a run that is not theirs. */
|
|
3709
|
+
async onRunInject(m) {
|
|
3710
|
+
const reply = (ok, reason) => {
|
|
3711
|
+
this.ch.send({ t: 'run-inject-result', id: m.id, ok, ...(reason !== undefined ? { reason } : {}) });
|
|
3712
|
+
};
|
|
3713
|
+
const port = this.opts.runner?.chat;
|
|
3714
|
+
if (!port) {
|
|
3715
|
+
reply(false, CHAT_UNAVAILABLE);
|
|
3716
|
+
return;
|
|
3717
|
+
}
|
|
3718
|
+
const user = this.verifiedUserId;
|
|
3719
|
+
if (!user) {
|
|
3720
|
+
reply(false, CHAT_NO_ACCESS);
|
|
3721
|
+
return;
|
|
3722
|
+
}
|
|
3723
|
+
if (typeof m.runId !== 'string' || !m.runId || m.runId.length > CHAT_MAX_RUN_ID_LEN || hasControlChar(m.runId)) {
|
|
3724
|
+
reply(false, CHAT_BAD_RUN);
|
|
3725
|
+
return;
|
|
3726
|
+
}
|
|
3727
|
+
const messages = injectMessagesOf(m.messages);
|
|
3728
|
+
if (!messages) {
|
|
3729
|
+
reply(false, CHAT_BAD_INJECT);
|
|
3730
|
+
return;
|
|
3731
|
+
}
|
|
3732
|
+
try {
|
|
3733
|
+
const r = await port.inject(m.runId, user, messages, m.hard === true);
|
|
3734
|
+
if (r.ok === true)
|
|
3735
|
+
reply(true);
|
|
3736
|
+
else
|
|
3737
|
+
reply(false, typeof r.reason === 'string' && r.reason ? r.reason.slice(0, CHAT_MAX_REASON_LEN) : CHAT_INJECT_FAILED);
|
|
3738
|
+
}
|
|
3739
|
+
catch (err) {
|
|
3740
|
+
this.opts.log?.(`chat: run-inject threw: ${errMessage(err)}`);
|
|
3741
|
+
reply(false, CHAT_INJECT_FAILED);
|
|
3742
|
+
}
|
|
3743
|
+
}
|
|
3744
|
+
/** The checks chat-list and chat-get share: a port, a verified caller, and
|
|
3745
|
+
* a well-formed agent, ref and member. Answers the request's parts, or the
|
|
3746
|
+
* refusal to send. `isAdmin` is the daemon's own answer, never the message's. */
|
|
3747
|
+
chatReadOf(m) {
|
|
3748
|
+
const port = this.opts.runner?.chat;
|
|
3749
|
+
if (!port)
|
|
3750
|
+
return CHAT_UNAVAILABLE;
|
|
3751
|
+
const caller = this.verifiedUserId;
|
|
3752
|
+
if (!caller)
|
|
3753
|
+
return CHAT_NO_ACCESS;
|
|
3754
|
+
if (!chatAgentOk(m.agent))
|
|
3755
|
+
return CHAT_BAD_AGENT;
|
|
3756
|
+
const ref = chatRefOf(m.ref, m.agent);
|
|
3757
|
+
if (ref === null)
|
|
3758
|
+
return AGENT_RUNS_BAD_REF;
|
|
3759
|
+
if (m.member !== undefined && !chatIdOk(m.member))
|
|
3760
|
+
return CHAT_BAD_MEMBER;
|
|
3761
|
+
return {
|
|
3762
|
+
port, caller, agent: m.agent, ...ref,
|
|
3763
|
+
...(m.member !== undefined ? { member: m.member } : {}),
|
|
3764
|
+
isAdmin: sameAdmin(caller, this.chatAdmin()),
|
|
3765
|
+
};
|
|
3766
|
+
}
|
|
3767
|
+
async onChatList(m) {
|
|
3768
|
+
const q = this.chatReadOf(m);
|
|
3769
|
+
if (typeof q === 'string') {
|
|
3770
|
+
this.ch.send({ t: 'chat-list-result', id: m.id, ok: false, error: q });
|
|
3771
|
+
return;
|
|
3772
|
+
}
|
|
3773
|
+
const { port, ...rest } = q;
|
|
3774
|
+
try {
|
|
3775
|
+
const chats = await port.list({ ...rest, identity: { clientId: this.clientId } });
|
|
3776
|
+
this.ch.send({ t: 'chat-list-result', id: m.id, ok: true, chats: chats.map(chatSummaryOf) });
|
|
3777
|
+
}
|
|
3778
|
+
catch (err) {
|
|
3779
|
+
const error = chatErrorOf(err);
|
|
3780
|
+
if (error === null)
|
|
3781
|
+
this.opts.log?.(`chat: chat-list threw: ${errMessage(err)}`);
|
|
3782
|
+
this.ch.send({ t: 'chat-list-result', id: m.id, ok: false, error: error ?? CHAT_READ_FAILED });
|
|
3783
|
+
}
|
|
3784
|
+
}
|
|
3785
|
+
async onChatGet(m) {
|
|
3786
|
+
const refuse = (error) => { this.ch.send({ t: 'chat-get-result', id: m.id, ok: false, error }); };
|
|
3787
|
+
const q = this.chatReadOf(m);
|
|
3788
|
+
if (typeof q === 'string') {
|
|
3789
|
+
refuse(q);
|
|
3790
|
+
return;
|
|
3791
|
+
}
|
|
3792
|
+
if (!chatIdOk(m.conversationId)) {
|
|
3793
|
+
refuse(CHAT_BAD_ID);
|
|
3794
|
+
return;
|
|
3795
|
+
}
|
|
3796
|
+
const { port, ...rest } = q;
|
|
3797
|
+
try {
|
|
3798
|
+
const conversation = await port.get({ ...rest, identity: { clientId: this.clientId }, conversationId: m.conversationId });
|
|
3799
|
+
this.ch.send({ t: 'chat-get-result', id: m.id, ok: true, conversation: storedChatOf(conversation) });
|
|
3800
|
+
}
|
|
3801
|
+
catch (err) {
|
|
3802
|
+
const error = chatErrorOf(err);
|
|
3803
|
+
if (error === null)
|
|
3804
|
+
this.opts.log?.(`chat: chat-get threw: ${errMessage(err)}`);
|
|
3805
|
+
refuse(error ?? CHAT_READ_FAILED);
|
|
3806
|
+
}
|
|
3807
|
+
}
|
|
3270
3808
|
/** The identity to attribute a host-sync write to. Returns either the
|
|
3271
3809
|
* authenticated `clientId`, or an error string explaining why there is none --
|
|
3272
3810
|
* either this connection is not authenticated yet, or it authenticated with no
|
|
@@ -5100,6 +5638,21 @@ export class SidecarServer {
|
|
|
5100
5638
|
const target = crewConfigTarget(canonical);
|
|
5101
5639
|
if (!target)
|
|
5102
5640
|
return {};
|
|
5641
|
+
/* A chat (R15/Q6) is its member's and the admin's alone, the agent's
|
|
5642
|
+
* owner included, and only the daemon writes it (Q1: chats are kept).
|
|
5643
|
+
* The chats folder may be listed with any role; the listing keeps only
|
|
5644
|
+
* this user's own requester folder (visibleConfigEntries). */
|
|
5645
|
+
const chat = target.kind === 'agent' ? chatContentOf(canonical) : null;
|
|
5646
|
+
if (target.kind === 'agent' && chat !== null) {
|
|
5647
|
+
if (!reading)
|
|
5648
|
+
return { refusal: ACCESS_CHAT_FILES };
|
|
5649
|
+
if (chat.requester === null) {
|
|
5650
|
+
return m.t === 'config-list' && (await access.can(daemonAgent(target.agent), user, 'see')) ? {} : { refusal };
|
|
5651
|
+
}
|
|
5652
|
+
if (sameAdmin(user, access.adminId()))
|
|
5653
|
+
return {};
|
|
5654
|
+
return user !== undefined && chat.requester === user ? {} : { refusal };
|
|
5655
|
+
}
|
|
5103
5656
|
if (reading) {
|
|
5104
5657
|
if (target.kind === 'crew')
|
|
5105
5658
|
return {};
|
|
@@ -5151,7 +5704,9 @@ export class SidecarServer {
|
|
|
5151
5704
|
* or null when that cannot be decided. Only whole agents are left out:
|
|
5152
5705
|
* dropping individual entries would read as deletions to a syncing client.
|
|
5153
5706
|
* 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).
|
|
5707
|
+
* is run content: only the runs this user may read are listed (D5). So is a
|
|
5708
|
+
* chat's requester folder (`chats/<requester>`): only this user's own, or
|
|
5709
|
+
* every one for the admin, is listed (R15/Q6). */
|
|
5155
5710
|
async visibleConfigEntries(svc, path, entries) {
|
|
5156
5711
|
const access = this.teamAccess();
|
|
5157
5712
|
if (!access)
|
|
@@ -5180,6 +5735,9 @@ export class SidecarServer {
|
|
|
5180
5735
|
}
|
|
5181
5736
|
if (!shown)
|
|
5182
5737
|
continue;
|
|
5738
|
+
const chat = chatContentOf(full);
|
|
5739
|
+
if (chat !== null && chat.requester !== null && !isAdmin && (this.verifiedUserId === undefined || chat.requester !== this.verifiedUserId))
|
|
5740
|
+
continue;
|
|
5183
5741
|
const run = runContentOf(full);
|
|
5184
5742
|
if (run?.kind === 'sessions' && run.id !== null) {
|
|
5185
5743
|
const key = `${target.agent}/${run.id}`;
|
|
@@ -5512,6 +6070,18 @@ function runContentOf(path) {
|
|
|
5512
6070
|
const file = segs.length === 4 ? /^(.+)\.jsonl$/i.exec(segs[3]) : null;
|
|
5513
6071
|
return { kind, id: file ? file[1] : null, folder: false };
|
|
5514
6072
|
}
|
|
6073
|
+
/** Where a daemon-crew config path is inside an agent's chats
|
|
6074
|
+
* (`crew/<agent>/chats[/<requester>[/...]]`, the daemon chat store's layout).
|
|
6075
|
+
* `requester` is the folder's requester id (the `<requester>` segment, as the
|
|
6076
|
+
* canonical path spells it), null for the chats folder itself. Null for any
|
|
6077
|
+
* other path. The `chats` segment compares case-insensitively; the caller
|
|
6078
|
+
* hands in the canonical path. */
|
|
6079
|
+
function chatContentOf(path) {
|
|
6080
|
+
const segs = path.split('/');
|
|
6081
|
+
if (segs.length < 3 || segs[0].toLowerCase() !== 'crew' || segs[2].toLowerCase() !== 'chats')
|
|
6082
|
+
return null;
|
|
6083
|
+
return { requester: segs.length === 3 ? null : segs[3] || null };
|
|
6084
|
+
}
|
|
5515
6085
|
/** `run-history`'s `crew`, off the wire: anything but the two known shapes is
|
|
5516
6086
|
* treated as absent (the global crew, as an older client meant). The id is
|
|
5517
6087
|
* only a selector among the CALLER's own roots -- the runner matches it
|