@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.
- package/dist/chat/engine.d.ts +47 -0
- package/dist/chat/engine.d.ts.map +1 -0
- package/dist/chat/engine.js +803 -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 +39 -0
- package/dist/chat/persist.d.ts.map +1 -0
- package/dist/chat/persist.js +107 -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 +7 -7
- 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/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/host.d.ts +77 -1
- package/dist/sidecar/host.d.ts.map +1 -1
- package/dist/sidecar/host.js +220 -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 +46 -4
- package/dist/sidecar/server.d.ts.map +1 -1
- package/dist/sidecar/server.js +523 -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 +8 -0
- package/dist/tools/builtins.d.ts.map +1 -1
- package/dist/tools/builtins.js +21 -1
- 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,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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
2071
|
+
const gate = await this.identityGate(deviceJkt, record.verifiedUserId);
|
|
2072
|
+
if (gate !== 'ok') {
|
|
1843
2073
|
this.unwatchRevocation();
|
|
1844
|
-
|
|
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
|
-
|
|
1900
|
-
|
|
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.
|
|
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
|