@corenel/harness 0.22.0 → 0.24.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/README.md +48 -0
- package/dist/approvals/service.d.ts +3 -0
- package/dist/approvals/service.d.ts.map +1 -1
- package/dist/approvals/service.js +13 -0
- package/dist/approvals/service.js.map +1 -1
- package/dist/approvals/suggest.js +1 -1
- package/dist/approvals/suggest.js.map +1 -1
- package/dist/chat/engine.d.ts +3 -0
- package/dist/chat/engine.d.ts.map +1 -1
- package/dist/chat/engine.js +10 -6
- package/dist/chat/engine.js.map +1 -1
- package/dist/chat/types.d.ts +1 -0
- package/dist/chat/types.d.ts.map +1 -1
- package/dist/core/loop.d.ts.map +1 -1
- package/dist/core/loop.js +138 -81
- package/dist/core/loop.js.map +1 -1
- package/dist/core/spawn.d.ts +3 -1
- package/dist/core/spawn.d.ts.map +1 -1
- package/dist/core/spawn.js +16 -1
- package/dist/core/spawn.js.map +1 -1
- package/dist/dag/executeNode.js +4 -4
- package/dist/dag/executeNode.js.map +1 -1
- package/dist/guardrail/builtins.d.ts.map +1 -1
- package/dist/guardrail/builtins.js +2 -0
- package/dist/guardrail/builtins.js.map +1 -1
- package/dist/guardrail/guardrail.d.ts +9 -0
- package/dist/guardrail/guardrail.d.ts.map +1 -1
- package/dist/guardrail/guardrail.js +13 -0
- package/dist/guardrail/guardrail.js.map +1 -1
- package/dist/guardrail/index.d.ts +3 -3
- package/dist/guardrail/index.d.ts.map +1 -1
- package/dist/guardrail/index.js +2 -2
- package/dist/guardrail/index.js.map +1 -1
- package/dist/guardrail/permission-service.d.ts +44 -3
- package/dist/guardrail/permission-service.d.ts.map +1 -1
- package/dist/guardrail/permission-service.js +58 -11
- package/dist/guardrail/permission-service.js.map +1 -1
- package/dist/guardrail/store.d.ts +4 -0
- package/dist/guardrail/store.d.ts.map +1 -1
- package/dist/guardrail/store.js +6 -0
- package/dist/guardrail/store.js.map +1 -1
- package/dist/prompts/config.d.ts +6 -2
- package/dist/prompts/config.d.ts.map +1 -1
- package/dist/prompts/config.js +6 -3
- package/dist/prompts/config.js.map +1 -1
- package/dist/prompts/host.d.ts +3 -2
- package/dist/prompts/host.d.ts.map +1 -1
- package/dist/prompts/host.js.map +1 -1
- package/dist/prompts/index.d.ts +8 -1
- package/dist/prompts/index.d.ts.map +1 -1
- package/dist/prompts/index.js +9 -7
- package/dist/prompts/index.js.map +1 -1
- package/dist/prompts/library/defaults.generated.d.ts.map +1 -1
- package/dist/prompts/library/defaults.generated.js +15 -14
- package/dist/prompts/library/defaults.generated.js.map +1 -1
- package/dist/prompts/systemPrompts.d.ts +3 -0
- package/dist/prompts/systemPrompts.d.ts.map +1 -1
- package/dist/prompts/systemPrompts.js +1 -1
- package/dist/prompts/systemPrompts.js.map +1 -1
- package/dist/sessions/fileSessionStore.d.ts.map +1 -1
- package/dist/sessions/fileSessionStore.js +18 -4
- package/dist/sessions/fileSessionStore.js.map +1 -1
- package/dist/sessions/jsonl.d.ts +1 -0
- package/dist/sessions/jsonl.d.ts.map +1 -1
- package/dist/sessions/jsonl.js +10 -0
- package/dist/sessions/jsonl.js.map +1 -1
- package/dist/sidecar/accessTestRig.d.ts +9 -2
- package/dist/sidecar/accessTestRig.d.ts.map +1 -1
- package/dist/sidecar/accessTestRig.js +13 -6
- package/dist/sidecar/accessTestRig.js.map +1 -1
- package/dist/sidecar/host.d.ts +96 -10
- package/dist/sidecar/host.d.ts.map +1 -1
- package/dist/sidecar/host.js +328 -6
- package/dist/sidecar/host.js.map +1 -1
- package/dist/sidecar/memberGate.d.ts.map +1 -1
- package/dist/sidecar/memberGate.js +17 -0
- package/dist/sidecar/memberGate.js.map +1 -1
- package/dist/sidecar/server.d.ts +171 -6
- package/dist/sidecar/server.d.ts.map +1 -1
- package/dist/sidecar/server.js +793 -65
- package/dist/sidecar/server.js.map +1 -1
- package/dist/skills.d.ts +9 -1
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +22 -8
- package/dist/skills.js.map +1 -1
- package/dist/templateGuard.d.ts +21 -0
- package/dist/templateGuard.d.ts.map +1 -0
- package/dist/templateGuard.js +150 -0
- package/dist/templateGuard.js.map +1 -0
- package/dist/tools/builtins.d.ts +1 -0
- package/dist/tools/builtins.d.ts.map +1 -1
- package/dist/tools/builtins.js +33 -3
- package/dist/tools/builtins.js.map +1 -1
- package/dist/tools/callAgent.d.ts +6 -2
- package/dist/tools/callAgent.d.ts.map +1 -1
- package/dist/tools/callAgent.js +7 -3
- package/dist/tools/callAgent.js.map +1 -1
- package/dist/tools/files.d.ts.map +1 -1
- package/dist/tools/files.js +4 -0
- package/dist/tools/files.js.map +1 -1
- package/dist/tools/namespaces.d.ts +1 -1
- package/dist/tools/namespaces.d.ts.map +1 -1
- package/dist/tools/namespaces.js +6 -2
- package/dist/tools/namespaces.js.map +1 -1
- package/dist/tools/sessions.d.ts.map +1 -1
- package/dist/tools/sessions.js +1 -0
- package/dist/tools/sessions.js.map +1 -1
- package/dist/tools/skillTool.d.ts +29 -0
- package/dist/tools/skillTool.d.ts.map +1 -1
- package/dist/tools/skillTool.js +73 -10
- package/dist/tools/skillTool.js.map +1 -1
- package/dist/tools/web.d.ts.map +1 -1
- package/dist/tools/web.js +19 -5
- package/dist/tools/web.js.map +1 -1
- package/dist/tools/workflowTool.d.ts +2 -18
- package/dist/tools/workflowTool.d.ts.map +1 -1
- package/dist/tools/workflowTool.js.map +1 -1
- package/package.json +3 -3
package/dist/sidecar/server.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CHAT_FEATURE, CHAT_ID_RE, decodePcm16, FLEET_LOGINS_DENIED, SIGN_IN_PAUSED_DENIED, SPEECH_FEATURE, AGENT_CRED_ERROR_CODES, agentCredHeadersProblem, DEFAULT_STATE_DIR, PEER_DAEMON_FEATURE, TEAM_MEMBERS_FEATURE, RUN_TASK_FEATURE, RUN_PLAN_FEATURE, CREW_DEPLOY_FEATURE, DEVICES_FEATURE, AGENT_ACCESS_FEATURE, AGENT_RUNS_DEFAULT_LIMIT, AGENT_RUNS_FEATURE, AGENT_RUNS_MAX_LIMIT, PEER_LINKS_FEATURE, CHILD_EVENTS_FEATURE, FS_CHANGED_FEATURE, CREW_DEFINITION_FILES, crewConfigTarget, denialNote, isRunOrigin, cleanPerson } from '@corenel/protocol';
|
|
1
|
+
import { CHAT_FEATURE, CHAT_ID_RE, decodePcm16, FLEET_LOGINS_DENIED, SIGN_IN_PAUSED_DENIED, SPEECH_FEATURE, AGENT_CRED_ERROR_CODES, agentCredHeadersProblem, DEFAULT_STATE_DIR, PEER_DAEMON_FEATURE, TEAM_MEMBERS_FEATURE, RUN_TASK_FEATURE, RUN_PLAN_FEATURE, CREW_DEPLOY_FEATURE, FLEET_ITEMS_FEATURE, FLEET_USE_FEATURE, KNOWLEDGE_FEATURE, KNOWLEDGE_OFF_FEATURE, KNOWLEDGE_NOT_KEPT, KNOWLEDGE_WRITE_ADMIN_ONLY, KNOWLEDGE_ADMIN_ONLY, KNOWLEDGE_UNREACHABLE, knowledgeConflictSentence, SIDECAR_KNOWLEDGE_TOOLS, SIDECAR_KNOWLEDGE_WRITE, FLEET_USE_LIMITS, isWorkflowCallInputs, DEVICES_FEATURE, AGENT_ACCESS_FEATURE, AGENT_RUNS_DEFAULT_LIMIT, AGENT_RUNS_FEATURE, AGENT_RUNS_MAX_LIMIT, PEER_LINKS_FEATURE, CHILD_EVENTS_FEATURE, FS_CHANGED_FEATURE, ATTENDANCE_LEAVE_FEATURE, ATTENDANCE_JOIN_FEATURE, MCP_ALLOW_FEATURE, MCP_ADMIN_ERROR_CODES, CREW_DEFINITION_FILES, crewConfigTarget, denialNote, isRunOrigin, cleanPerson } from '@corenel/protocol';
|
|
2
2
|
import { SIDECAR_PROTOCOL, errMessage, isLoopbackHost, isLoopbackAddress, parseMcpToolAddress, isWebPushSubscription, MAX_ASSERTION_LEN, isClerkOrgId, MAX_WORKFLOW_PACKAGE_B64, MAX_WORKFLOW_REPLY_ERROR, WORKFLOW_RUNNER_FEATURE, } from '@corenel/protocol';
|
|
3
3
|
import { activityForViewer, askForViewer, withoutViewerClaims } from './askView.js';
|
|
4
4
|
import { capabilityMissingMessage } from './hostCapability.js';
|
|
@@ -12,6 +12,57 @@ import { fsChangesFor } from './fsChanges.js';
|
|
|
12
12
|
function clipReplyError(error) {
|
|
13
13
|
return error.length > MAX_WORKFLOW_REPLY_ERROR ? error.slice(0, MAX_WORKFLOW_REPLY_ERROR) : error;
|
|
14
14
|
}
|
|
15
|
+
/** A folder as a caller who does not manage the folders sees it: no path, and
|
|
16
|
+
* an error that is a fixed sentence naming the folder by its label only. */
|
|
17
|
+
function knowledgeViewForMember(f) {
|
|
18
|
+
const view = { id: f.id, label: f.label, writable: f.writable, state: f.state, origin: f.origin };
|
|
19
|
+
if (f.state === 'conflict') {
|
|
20
|
+
if (f.conflict)
|
|
21
|
+
view.conflict = f.conflict;
|
|
22
|
+
view.error = knowledgeConflictSentence(f.label, f.conflict);
|
|
23
|
+
}
|
|
24
|
+
else if (f.state === 'unreachable') {
|
|
25
|
+
view.error = KNOWLEDGE_UNREACHABLE;
|
|
26
|
+
}
|
|
27
|
+
return view;
|
|
28
|
+
}
|
|
29
|
+
/** Only the wire fields of a folder, so nothing else a port holds is sent. */
|
|
30
|
+
function knowledgeViewForManager(f) {
|
|
31
|
+
return {
|
|
32
|
+
id: f.id, label: f.label, writable: f.writable, state: f.state, origin: f.origin,
|
|
33
|
+
...(f.conflict ? { conflict: f.conflict } : {}),
|
|
34
|
+
...(f.error !== undefined ? { error: f.error } : {}),
|
|
35
|
+
...(f.path !== undefined ? { path: f.path } : {}),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/** A `knowledge-set` `add` as sent, checked field by field; null when its shape is wrong. */
|
|
39
|
+
function knowledgeAddOf(v) {
|
|
40
|
+
if (typeof v !== 'object' || v === null || Array.isArray(v))
|
|
41
|
+
return null;
|
|
42
|
+
const o = v;
|
|
43
|
+
if (typeof o.path !== 'string')
|
|
44
|
+
return null;
|
|
45
|
+
if (o.writable !== undefined && typeof o.writable !== 'boolean')
|
|
46
|
+
return null;
|
|
47
|
+
if (o.label !== undefined && typeof o.label !== 'string')
|
|
48
|
+
return null;
|
|
49
|
+
return {
|
|
50
|
+
path: o.path,
|
|
51
|
+
...(o.writable === true ? { writable: true } : {}),
|
|
52
|
+
...(typeof o.label === 'string' ? { label: o.label } : {}),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/** What a knowledge-set with neither or both of add and remove, or a malformed one, answers. */
|
|
56
|
+
export const KNOWLEDGE_SET_SHAPE = 'Give one folder to add or the id of one folder to remove.';
|
|
57
|
+
/** What a knowledge message answers a caller who does not manage the folders when the port throws. */
|
|
58
|
+
export const KNOWLEDGE_FAILED = 'The sidecar could not answer that knowledge request. Its admin can find the reason in its log.';
|
|
59
|
+
/** The refusal for workflow inputs that fail isWorkflowCallInputs. */
|
|
60
|
+
export const FLEET_WORKFLOW_INPUTS_ERROR = `workflow inputs must be an object of at most ${FLEET_USE_LIMITS.inputsBytes / 1024} KiB`;
|
|
61
|
+
/** At most this many fleet workflow calls open at once on one connection (each loads and verifies its archive). */
|
|
62
|
+
export const FLEET_CALLS_OPEN_MAX = 16;
|
|
63
|
+
export const FLEET_CALLS_TOO_MANY = 'too many fleet workflow calls open';
|
|
64
|
+
/** A team connection whose host folder the daemon has not resolved (yet): it cannot be named. */
|
|
65
|
+
export const FLEET_CALLER_NO_FOLDER = 'this connection has no host folder yet; reconnect and try again';
|
|
15
66
|
/** A deliberate refusal from the approvals port, written for the person who
|
|
16
67
|
* asked: its message goes on the wire as is. Recognized by `name`, not by
|
|
17
68
|
* `instanceof`, so a second copy of this module (a test alias, a bundled
|
|
@@ -34,6 +85,13 @@ export function isApprovalsRefusal(e) {
|
|
|
34
85
|
export const WORKFLOWS_VERIFY_FIRST = 'in team mode this machine verifies who you are before it takes workflow packages or hands back results; sign in and reconnect';
|
|
35
86
|
export const WORKFLOWS_FOLDER_UNBOUND = 'in team mode a synced folder belongs to the first verified user that connected as it, and none has yet; its workflow packages and results stay closed until one does';
|
|
36
87
|
export const WORKFLOWS_NOT_FOLDER_OWNER = 'in team mode this synced folder belongs to a different verified user; only that user can send it workflow packages or take its results';
|
|
88
|
+
/* Team mode, everything a connection's host folder speaks for besides workflow-*: its runs (the folder is the
|
|
89
|
+
* owner root and the `via` a daemon-crew run is recorded under), chat turns and reads, run history, transcripts,
|
|
90
|
+
* crew memory, fleet workflow calls and host-sync writes into the folder. The same rule as the workflow-* messages (hostFolderStanding): the folder
|
|
91
|
+
* comes from the hello's clientId, which is not secret, so it speaks only for the verified user it is bound to. */
|
|
92
|
+
export const HOST_FOLDER_VERIFY_FIRST = 'in team mode this machine verifies who you are before it runs agents for this app or shows its runs, chats or memory; sign in and reconnect';
|
|
93
|
+
export const HOST_FOLDER_UNBOUND = 'in team mode a synced folder belongs to the first verified user that connected as it, and none has yet; its runs, chats and memory stay closed until one does';
|
|
94
|
+
export const HOST_FOLDER_NOT_OWNER = 'in team mode this synced folder belongs to a different verified user; only that user can run agents as it or read its runs, chats and memory';
|
|
37
95
|
export const APPROVALS_FAILED = 'Standing approvals could not be read or changed. The daemon log has the reason.';
|
|
38
96
|
export const APPROVALS_NO_USER = 'In team mode standing approvals need a verified user on this connection.';
|
|
39
97
|
export const APPROVALS_NAME_AGENT = 'Name the agent: in team mode standing approvals belong to one agent, and only its owner can revoke them.';
|
|
@@ -66,6 +124,11 @@ export const AGENT_CRED_FAILED = 'Agent credentials could not be checked or chan
|
|
|
66
124
|
export const AGENT_CRED_LOGIN_FAILED = 'The sign-in did not finish. The daemon log has the reason.';
|
|
67
125
|
export const AGENT_CRED_SOLO = 'This daemon does not keep agent access.';
|
|
68
126
|
export const MCP_ADMIN_ONLY = "In team mode only the daemon admin can use or change this machine's MCP servers directly.";
|
|
127
|
+
/** A command runs as the daemon's OS user, outside every root and every
|
|
128
|
+
* per-member check, so on a team daemon it is the admin's alone. */
|
|
129
|
+
export const COMMANDS_ADMIN_ONLY = 'In team mode only the daemon admin can run commands on this machine directly.';
|
|
130
|
+
/** The tools a connected client could use to run a command or reach a running one. */
|
|
131
|
+
const COMMAND_TOOLS = new Set(['sidecar_shell_exec', 'proc_start', 'proc_stop', 'proc_list']);
|
|
69
132
|
export const SESSION_REFUSED = 'In team mode a session is reachable only by the member who created it, or the daemon admin.';
|
|
70
133
|
export const SNAPSHOT_REFUSED = 'In team mode you can restore only from a snapshot of a session you created.';
|
|
71
134
|
export const SNAPSHOT_NAME_REFUSED = 'In team mode a snapshot name is chosen by the daemon; only the daemon admin can name one.';
|
|
@@ -115,6 +178,11 @@ const AGENT_CRED_RESERVED_SERVERS = new Set(['__proto__', 'constructor', 'protot
|
|
|
115
178
|
function logSafe(s) {
|
|
116
179
|
return s.replace(/[\x00-\x1f\x7f-\x9f]/g, '');
|
|
117
180
|
}
|
|
181
|
+
/** The MCP admin code a thrown refusal carries, when it is one a reply may name. */
|
|
182
|
+
function mcpAdminCodeOf(err) {
|
|
183
|
+
const code = typeof err === 'object' && err !== null ? err.code : undefined;
|
|
184
|
+
return typeof code === 'string' && MCP_ADMIN_ERROR_CODES.includes(code) ? code : null;
|
|
185
|
+
}
|
|
118
186
|
/** The code a thrown refusal carries, when it is one a reply may name. */
|
|
119
187
|
function agentCredCodeOf(err) {
|
|
120
188
|
const code = typeof err === 'object' && err !== null ? err.code : undefined;
|
|
@@ -787,6 +855,23 @@ export class SidecarServer {
|
|
|
787
855
|
writable: true,
|
|
788
856
|
value: void 0
|
|
789
857
|
});
|
|
858
|
+
/** This connection sent `attendance-leave`: it is detached for good and
|
|
859
|
+
* never attached again, and an answer it sends is dropped. */
|
|
860
|
+
Object.defineProperty(this, "leftAttendance", {
|
|
861
|
+
enumerable: true,
|
|
862
|
+
configurable: true,
|
|
863
|
+
writable: true,
|
|
864
|
+
value: false
|
|
865
|
+
});
|
|
866
|
+
/** The welcome was sent: the hello is finished. An `attendance-join` is
|
|
867
|
+
* read only after it, so it cannot attach in the middle of the hello (whose
|
|
868
|
+
* own attach, or a leave that arrived meanwhile, decides that). */
|
|
869
|
+
Object.defineProperty(this, "welcomed", {
|
|
870
|
+
enumerable: true,
|
|
871
|
+
configurable: true,
|
|
872
|
+
writable: true,
|
|
873
|
+
value: false
|
|
874
|
+
});
|
|
790
875
|
/** This connection's seat for chat ask_user (verified connections only),
|
|
791
876
|
* and its leave function, called once on close. */
|
|
792
877
|
Object.defineProperty(this, "askSeat", {
|
|
@@ -876,6 +961,14 @@ export class SidecarServer {
|
|
|
876
961
|
writable: true,
|
|
877
962
|
value: new Set()
|
|
878
963
|
});
|
|
964
|
+
/** This connection's open fleet workflow calls by request id: aborted by `fleet-workflow-cancel` and,
|
|
965
|
+
* all of them, when the connection closes. */
|
|
966
|
+
Object.defineProperty(this, "fleetCalls", {
|
|
967
|
+
enumerable: true,
|
|
968
|
+
configurable: true,
|
|
969
|
+
writable: true,
|
|
970
|
+
value: new Map()
|
|
971
|
+
});
|
|
879
972
|
/** Processes THIS connection is watching. A shared process table means two
|
|
880
973
|
* browsers can be paired to one daemon, and a client that never asked about a
|
|
881
974
|
* process should not be streamed its output - so output is fanned out per
|
|
@@ -1142,6 +1235,10 @@ export class SidecarServer {
|
|
|
1142
1235
|
for (const detach of this.runDetachers)
|
|
1143
1236
|
detach();
|
|
1144
1237
|
this.runDetachers.clear();
|
|
1238
|
+
// A fleet workflow call has no one left to answer: stop every open one.
|
|
1239
|
+
for (const call of this.fleetCalls.values())
|
|
1240
|
+
call.abort();
|
|
1241
|
+
this.fleetCalls.clear();
|
|
1145
1242
|
offRoots?.();
|
|
1146
1243
|
offRooms?.();
|
|
1147
1244
|
offFsChanges?.();
|
|
@@ -1265,7 +1362,7 @@ export class SidecarServer {
|
|
|
1265
1362
|
}
|
|
1266
1363
|
// A chat turn (Harness Chat Plan 2) takes its own path: the daemon's chat port, never runner.run.
|
|
1267
1364
|
if (m.chat !== undefined) {
|
|
1268
|
-
this.onChatRun(m);
|
|
1365
|
+
void this.onChatRun(m);
|
|
1269
1366
|
return;
|
|
1270
1367
|
}
|
|
1271
1368
|
void this.onRun(m);
|
|
@@ -1327,6 +1424,10 @@ export class SidecarServer {
|
|
|
1327
1424
|
void this.onAcpStart(m);
|
|
1328
1425
|
return;
|
|
1329
1426
|
case 'acp-set-mode':
|
|
1427
|
+
if (!this.authed) {
|
|
1428
|
+
this.ch.send({ t: 'acp-error', id: m.id, error: 'not authenticated' });
|
|
1429
|
+
return;
|
|
1430
|
+
}
|
|
1330
1431
|
void this.onAcpSetMode(m);
|
|
1331
1432
|
return;
|
|
1332
1433
|
case 'acp-prompt':
|
|
@@ -1348,10 +1449,45 @@ export class SidecarServer {
|
|
|
1348
1449
|
// answer is dropped silently rather than forwarded into the registry.
|
|
1349
1450
|
if (!this.authed)
|
|
1350
1451
|
return;
|
|
1452
|
+
// A connection that left attendance is shown no asks, so answers none.
|
|
1453
|
+
if (this.leftAttendance)
|
|
1454
|
+
return;
|
|
1351
1455
|
// The note is the client's text: cleaned HERE, at the daemon's edge,
|
|
1352
1456
|
// and only a deny carries one.
|
|
1353
1457
|
this.opts.attendance?.answer(m.id, m.decision, this.attended, m.decision === 'deny' ? denialNote(m.note) : undefined);
|
|
1354
1458
|
return;
|
|
1459
|
+
case 'attendance-leave':
|
|
1460
|
+
/* A client keeping this connection open only for its own calls (a
|
|
1461
|
+
* browser that selected another machine but still has work running
|
|
1462
|
+
* on this one). Detached now, so an ask it would never show falls to
|
|
1463
|
+
* the unattended policy as if it had disconnected, instead of
|
|
1464
|
+
* waiting on it. Auth-gated like permission-answer; no reply. */
|
|
1465
|
+
if (!this.authed)
|
|
1466
|
+
return;
|
|
1467
|
+
this.leftAttendance = true;
|
|
1468
|
+
this.attendedDetach?.();
|
|
1469
|
+
this.attendedDetach = undefined;
|
|
1470
|
+
/* Its ask_user seat goes too: a question shown on a connection that
|
|
1471
|
+
* is not attending would wait on nobody. The seat object stays, for
|
|
1472
|
+
* a join, and an ask still open is shown to it again then. */
|
|
1473
|
+
this.askSeatLeave?.();
|
|
1474
|
+
this.askSeatLeave = undefined;
|
|
1475
|
+
return;
|
|
1476
|
+
case 'attendance-join':
|
|
1477
|
+
/* The client takes this connection back for the person (a browser
|
|
1478
|
+
* that switched back to this machine): it attends again, and the
|
|
1479
|
+
* attach replays what is waiting, as at hello. Only after a leave on
|
|
1480
|
+
* THIS connection, so an attendance is never held twice, and only
|
|
1481
|
+
* once the hello is finished (welcomed). A peer daemon never gets
|
|
1482
|
+
* here (its messages are filtered above) and is never attached
|
|
1483
|
+
* (attachAttendance). No reply. */
|
|
1484
|
+
if (!this.authed || !this.welcomed || this.closed || !this.leftAttendance)
|
|
1485
|
+
return;
|
|
1486
|
+
this.leftAttendance = false;
|
|
1487
|
+
this.attachAttendance();
|
|
1488
|
+
if (this.askSeat && !this.askSeatLeave)
|
|
1489
|
+
this.askSeatLeave = this.askUsers().join(this.askSeat);
|
|
1490
|
+
return;
|
|
1355
1491
|
case 'roots-browse':
|
|
1356
1492
|
// Auth-gated like every other request: directory enumeration must never
|
|
1357
1493
|
// precede authentication. Failures answer ok:false rather than throwing
|
|
@@ -1381,6 +1517,10 @@ export class SidecarServer {
|
|
|
1381
1517
|
this.ch.send({ t: 'proc-error', id: m.id, error: 'not authenticated' });
|
|
1382
1518
|
return;
|
|
1383
1519
|
}
|
|
1520
|
+
if (this.commandRefusal()) {
|
|
1521
|
+
this.ch.send({ t: 'proc-error', id: m.id, error: COMMANDS_ADMIN_ONLY });
|
|
1522
|
+
return;
|
|
1523
|
+
}
|
|
1384
1524
|
void this.onProcSpawn(m);
|
|
1385
1525
|
return;
|
|
1386
1526
|
case 'proc-attach':
|
|
@@ -1388,6 +1528,10 @@ export class SidecarServer {
|
|
|
1388
1528
|
this.ch.send({ t: 'proc-attach-result', id: m.id, ok: false, error: 'not authenticated' });
|
|
1389
1529
|
return;
|
|
1390
1530
|
}
|
|
1531
|
+
if (this.commandRefusal()) {
|
|
1532
|
+
this.ch.send({ t: 'proc-attach-result', id: m.id, ok: false, error: COMMANDS_ADMIN_ONLY });
|
|
1533
|
+
return;
|
|
1534
|
+
}
|
|
1391
1535
|
this.onProcAttach(m);
|
|
1392
1536
|
return;
|
|
1393
1537
|
case 'proc-input':
|
|
@@ -1395,6 +1539,10 @@ export class SidecarServer {
|
|
|
1395
1539
|
this.ch.send({ t: 'proc-input-result', id: m.id, ok: false, error: 'not authenticated' });
|
|
1396
1540
|
return;
|
|
1397
1541
|
}
|
|
1542
|
+
if (this.commandRefusal()) {
|
|
1543
|
+
this.ch.send({ t: 'proc-input-result', id: m.id, ok: false, error: COMMANDS_ADMIN_ONLY });
|
|
1544
|
+
return;
|
|
1545
|
+
}
|
|
1398
1546
|
this.onProcSimple(m.id, 'proc-input-result', () => this.requireProcs().write(m.procId, m.text));
|
|
1399
1547
|
return;
|
|
1400
1548
|
case 'proc-kill':
|
|
@@ -1402,6 +1550,10 @@ export class SidecarServer {
|
|
|
1402
1550
|
this.ch.send({ t: 'proc-kill-result', id: m.id, ok: false, error: 'not authenticated' });
|
|
1403
1551
|
return;
|
|
1404
1552
|
}
|
|
1553
|
+
if (this.commandRefusal()) {
|
|
1554
|
+
this.ch.send({ t: 'proc-kill-result', id: m.id, ok: false, error: COMMANDS_ADMIN_ONLY });
|
|
1555
|
+
return;
|
|
1556
|
+
}
|
|
1405
1557
|
this.onProcSimple(m.id, 'proc-kill-result', () => this.requireProcs().kill(m.procId));
|
|
1406
1558
|
return;
|
|
1407
1559
|
case 'stt-start': {
|
|
@@ -1470,7 +1622,8 @@ export class SidecarServer {
|
|
|
1470
1622
|
// An empty list is the honest answer for a daemon without the capability:
|
|
1471
1623
|
// it is running nothing, and an error here would make the common "what is
|
|
1472
1624
|
// running?" probe noisy on every sidecar that never allowed shell.
|
|
1473
|
-
|
|
1625
|
+
// A team member is shown none: the table holds other people's commands.
|
|
1626
|
+
if (!this.authed || this.commandRefusal()) {
|
|
1474
1627
|
this.ch.send({ t: 'proc-list-result', id: m.id, procs: [] });
|
|
1475
1628
|
return;
|
|
1476
1629
|
}
|
|
@@ -1570,6 +1723,74 @@ export class SidecarServer {
|
|
|
1570
1723
|
}
|
|
1571
1724
|
void this.onCrewDeploy(m);
|
|
1572
1725
|
return;
|
|
1726
|
+
case 'fleet-items':
|
|
1727
|
+
if (!this.authed) {
|
|
1728
|
+
this.ch.send({ t: 'fleet-items-result', id: m.id, reply: { ok: false, error: 'not authenticated' } });
|
|
1729
|
+
return;
|
|
1730
|
+
}
|
|
1731
|
+
void this.onFleetItems(m);
|
|
1732
|
+
return;
|
|
1733
|
+
case 'knowledge-list':
|
|
1734
|
+
if (!this.authed) {
|
|
1735
|
+
this.ch.send({ t: 'knowledge-list-result', id: m.id, reply: { ok: false, error: 'not authenticated' } });
|
|
1736
|
+
return;
|
|
1737
|
+
}
|
|
1738
|
+
this.onKnowledgeList(m);
|
|
1739
|
+
return;
|
|
1740
|
+
case 'knowledge-set':
|
|
1741
|
+
if (!this.authed) {
|
|
1742
|
+
this.ch.send({ t: 'knowledge-set-result', id: m.id, reply: { ok: false, error: 'not authenticated' } });
|
|
1743
|
+
return;
|
|
1744
|
+
}
|
|
1745
|
+
void this.onKnowledgeSet(m);
|
|
1746
|
+
return;
|
|
1747
|
+
case 'knowledge-files':
|
|
1748
|
+
if (!this.authed) {
|
|
1749
|
+
this.ch.send({ t: 'knowledge-files-result', id: m.id, reply: { ok: false, error: 'not authenticated' } });
|
|
1750
|
+
return;
|
|
1751
|
+
}
|
|
1752
|
+
void this.onKnowledgeFiles(m);
|
|
1753
|
+
return;
|
|
1754
|
+
case 'knowledge-file':
|
|
1755
|
+
if (!this.authed) {
|
|
1756
|
+
this.ch.send({ t: 'knowledge-file-result', id: m.id, reply: { ok: false, error: 'not authenticated' } });
|
|
1757
|
+
return;
|
|
1758
|
+
}
|
|
1759
|
+
void this.onKnowledgeFile(m);
|
|
1760
|
+
return;
|
|
1761
|
+
case 'knowledge-changes':
|
|
1762
|
+
if (!this.authed) {
|
|
1763
|
+
this.ch.send({ t: 'knowledge-changes-result', id: m.id, reply: { ok: false, error: 'not authenticated' } });
|
|
1764
|
+
return;
|
|
1765
|
+
}
|
|
1766
|
+
void this.onKnowledgeChanges(m);
|
|
1767
|
+
return;
|
|
1768
|
+
case 'fleet-skill-catalog':
|
|
1769
|
+
if (!this.authed) {
|
|
1770
|
+
this.ch.send({ t: 'fleet-skill-catalog-result', id: m.id, reply: { ok: false, error: 'not authenticated' } });
|
|
1771
|
+
return;
|
|
1772
|
+
}
|
|
1773
|
+
void this.onFleetSkillCatalog(m);
|
|
1774
|
+
return;
|
|
1775
|
+
case 'fleet-workflow-catalog':
|
|
1776
|
+
if (!this.authed) {
|
|
1777
|
+
this.ch.send({ t: 'fleet-workflow-catalog-result', id: m.id, reply: { ok: false, error: 'not authenticated' } });
|
|
1778
|
+
return;
|
|
1779
|
+
}
|
|
1780
|
+
void this.onFleetWorkflowCatalog(m);
|
|
1781
|
+
return;
|
|
1782
|
+
case 'fleet-workflow-call':
|
|
1783
|
+
if (!this.authed) {
|
|
1784
|
+
this.ch.send({ t: 'fleet-workflow-call-result', id: m.id, reply: { ok: false, error: 'not authenticated' } });
|
|
1785
|
+
return;
|
|
1786
|
+
}
|
|
1787
|
+
void this.onFleetWorkflowCall(m);
|
|
1788
|
+
return;
|
|
1789
|
+
case 'fleet-workflow-cancel':
|
|
1790
|
+
// No reply exists; an id this connection has no open call under is ignored.
|
|
1791
|
+
if (typeof m.id === 'number')
|
|
1792
|
+
this.fleetCalls.get(m.id)?.abort();
|
|
1793
|
+
return;
|
|
1573
1794
|
case 'workflow-package-put':
|
|
1574
1795
|
case 'workflow-results-list':
|
|
1575
1796
|
case 'workflow-results-take':
|
|
@@ -1766,6 +1987,7 @@ export class SidecarServer {
|
|
|
1766
1987
|
case 'mcp-remove':
|
|
1767
1988
|
case 'mcp-set-credential':
|
|
1768
1989
|
case 'mcp-login':
|
|
1990
|
+
case 'mcp-allow-set':
|
|
1769
1991
|
if (!this.authed) {
|
|
1770
1992
|
this.ch.send({ t: 'mcp-admin-error', id: m.id, error: 'not authenticated' });
|
|
1771
1993
|
return;
|
|
@@ -2495,48 +2717,13 @@ export class SidecarServer {
|
|
|
2495
2717
|
this.ch.send({ t: 'ask-user-resolved', id, by }); },
|
|
2496
2718
|
};
|
|
2497
2719
|
this.askSeat = seat;
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
/* A pending member (Part E) is never a recipient of anything: every
|
|
2502
|
-
* send below checks the status at the moment it is made, so a member
|
|
2503
|
-
* approved later starts receiving without reconnecting. */
|
|
2504
|
-
const attended = {
|
|
2505
|
-
// Shaped for THIS connection (E5.2): the requester's own copy names nobody.
|
|
2506
|
-
send: (ask) => { if (!this.memberPending())
|
|
2507
|
-
this.ch.send({ t: 'permission-ask', ...askForViewer(ask, this.verifiedUserId) }); },
|
|
2508
|
-
resolved: (id, decision, by, byClientId) => {
|
|
2509
|
-
if (!this.memberPending())
|
|
2510
|
-
this.ch.send({ t: 'permission-resolved', id, decision, by, ...(byClientId ? { byClientId } : {}) });
|
|
2511
|
-
},
|
|
2512
|
-
// Team mode: filtered by role (Task 16b). The registry calls this for
|
|
2513
|
-
// the live broadcast AND for the replay at attach, so both are covered.
|
|
2514
|
-
// An active member still gets only what their role allows.
|
|
2515
|
-
runActivity: (activity) => { if (!this.memberPending())
|
|
2516
|
-
this.sendRunActivity(activity); },
|
|
2517
|
-
registrations: (registrations) => { if (!this.memberPending())
|
|
2518
|
-
this.ch.send({ t: 'registrations', registrations }); },
|
|
2519
|
-
approvalSuggestion: (s) => { if (!this.memberPending())
|
|
2520
|
-
this.ch.send({ t: 'approvals-suggest', ...s }); },
|
|
2521
|
-
};
|
|
2522
|
-
// Retained so `permission-answer` can name THIS connection as the
|
|
2523
|
-
// answerer -- the registry identifies an answerer by the object it
|
|
2524
|
-
// attached, not by anything the answer message claims.
|
|
2525
|
-
this.attended = attended;
|
|
2526
|
-
/* The identity is what routes an ask under `--mode team`. `clientId` is
|
|
2527
|
-
* the key this connection was FILED under (mapped through `clientKey`
|
|
2528
|
-
* where the daemon supplies one), i.e. the same value its RunIdentity
|
|
2529
|
-
* carries -- which is what makes a run's owner and its ask's recipient
|
|
2530
|
-
* the same connection. `accounts` are CLAIMED, carried for display, and
|
|
2531
|
-
* widen nothing; only `verifiedUserId` (proven at pairing) widens a
|
|
2532
|
-
* delivery to the same user's other devices. See the registry's
|
|
2533
|
-
* `recipients`. */
|
|
2534
|
-
this.attendedDetach = this.opts.attendance.attach(attended, {
|
|
2535
|
-
clientId: this.clientId ?? '',
|
|
2536
|
-
accounts,
|
|
2537
|
-
...(this.verifiedUserId ? { verifiedUserId: this.verifiedUserId } : {}),
|
|
2538
|
-
});
|
|
2720
|
+
// Seated only while attending: a leave during the hello holds here too.
|
|
2721
|
+
if (!this.leftAttendance)
|
|
2722
|
+
this.askSeatLeave = this.askUsers().join(seat);
|
|
2539
2723
|
}
|
|
2724
|
+
// A leave that arrived while this hello was still being checked holds.
|
|
2725
|
+
if (!isPeerDaemon && !this.leftAttendance)
|
|
2726
|
+
this.attachAttendance();
|
|
2540
2727
|
/* The registry replays in-flight runs at attach, and the client relies on
|
|
2541
2728
|
* that replay landing BEFORE `welcome` (it prunes the runs it still holds
|
|
2542
2729
|
* as stale once welcomed; see pruneStaleDaemonRuns). Team mode filters the
|
|
@@ -2567,6 +2754,53 @@ export class SidecarServer {
|
|
|
2567
2754
|
}
|
|
2568
2755
|
this.sendWelcome(dirName);
|
|
2569
2756
|
}
|
|
2757
|
+
/** Attaches THIS connection to the attendance registry, which replays to it
|
|
2758
|
+
* every outstanding ask it may see, the registrations, the live runs and the
|
|
2759
|
+
* task runs that settled recently. Called from `hello`, and again by an
|
|
2760
|
+
* `attendance-join` after an `attendance-leave`. Never twice at once: an
|
|
2761
|
+
* attachment already held is kept as it is. */
|
|
2762
|
+
attachAttendance() {
|
|
2763
|
+
if (!this.opts.attendance || this.peerDaemon || this.attendedDetach)
|
|
2764
|
+
return;
|
|
2765
|
+
/* A pending member (Part E) is never a recipient of anything: every
|
|
2766
|
+
* send below checks the status at the moment it is made, so a member
|
|
2767
|
+
* approved later starts receiving without reconnecting. */
|
|
2768
|
+
const attended = {
|
|
2769
|
+
// Shaped for THIS connection (E5.2): the requester's own copy names nobody.
|
|
2770
|
+
send: (ask) => { if (!this.memberPending())
|
|
2771
|
+
this.ch.send({ t: 'permission-ask', ...askForViewer(ask, this.verifiedUserId) }); },
|
|
2772
|
+
resolved: (id, decision, by, byClientId) => {
|
|
2773
|
+
if (!this.memberPending())
|
|
2774
|
+
this.ch.send({ t: 'permission-resolved', id, decision, by, ...(byClientId ? { byClientId } : {}) });
|
|
2775
|
+
},
|
|
2776
|
+
// Team mode: filtered by role (Task 16b). The registry calls this for
|
|
2777
|
+
// the live broadcast AND for the replay at attach, so both are covered.
|
|
2778
|
+
// An active member still gets only what their role allows.
|
|
2779
|
+
runActivity: (activity) => { if (!this.memberPending())
|
|
2780
|
+
this.sendRunActivity(activity); },
|
|
2781
|
+
registrations: (registrations) => { if (!this.memberPending())
|
|
2782
|
+
this.ch.send({ t: 'registrations', registrations }); },
|
|
2783
|
+
approvalSuggestion: (s) => { if (!this.memberPending())
|
|
2784
|
+
this.ch.send({ t: 'approvals-suggest', ...s }); },
|
|
2785
|
+
};
|
|
2786
|
+
// Retained so `permission-answer` can name THIS connection as the
|
|
2787
|
+
// answerer -- the registry identifies an answerer by the object it
|
|
2788
|
+
// attached, not by anything the answer message claims.
|
|
2789
|
+
this.attended = attended;
|
|
2790
|
+
/* The identity is what routes an ask under `--mode team`. `clientId` is
|
|
2791
|
+
* the key this connection was FILED under (mapped through `clientKey`
|
|
2792
|
+
* where the daemon supplies one), i.e. the same value its RunIdentity
|
|
2793
|
+
* carries -- which is what makes a run's owner and its ask's recipient
|
|
2794
|
+
* the same connection. `accounts` are CLAIMED, carried for display, and
|
|
2795
|
+
* widen nothing; only `verifiedUserId` (proven at pairing) widens a
|
|
2796
|
+
* delivery to the same user's other devices. See the registry's
|
|
2797
|
+
* `recipients`. */
|
|
2798
|
+
this.attendedDetach = this.opts.attendance.attach(attended, {
|
|
2799
|
+
clientId: this.clientId ?? '',
|
|
2800
|
+
accounts: this.accounts,
|
|
2801
|
+
...(this.verifiedUserId ? { verifiedUserId: this.verifiedUserId } : {}),
|
|
2802
|
+
});
|
|
2803
|
+
}
|
|
2570
2804
|
/** Waits for this connection's `identity-assert`, with NO timeout of its
|
|
2571
2805
|
* own -- the overall bound is `awaitAndVerifyIdentity`'s, covering this wait
|
|
2572
2806
|
* AND the verify call together, so the whole leg is bounded at
|
|
@@ -2677,6 +2911,7 @@ export class SidecarServer {
|
|
|
2677
2911
|
}
|
|
2678
2912
|
}
|
|
2679
2913
|
sendWelcome(dirName) {
|
|
2914
|
+
this.welcomed = true;
|
|
2680
2915
|
/* Remembered BEFORE the welcome goes out, because `self` and the
|
|
2681
2916
|
* forget-your-own-tree refusal are both answered from it. A daemon too old
|
|
2682
2917
|
* to resolve one, or a client with no identity, leaves it undefined -- and
|
|
@@ -2742,6 +2977,8 @@ export class SidecarServer {
|
|
|
2742
2977
|
...(this.opts.runs ? [CHILD_EVENTS_FEATURE] : []),
|
|
2743
2978
|
// Whether a write another client makes reaches this one as fs-changed.
|
|
2744
2979
|
...(this.opts.fsChanges ? [FS_CHANGED_FEATURE] : []),
|
|
2980
|
+
// A connection may stop attending while it stays open (attendance-leave).
|
|
2981
|
+
...(this.opts.attendance ? [ATTENDANCE_LEAVE_FEATURE, ATTENDANCE_JOIN_FEATURE] : []),
|
|
2745
2982
|
...(this.opts.acp ? ['acp'] : []),
|
|
2746
2983
|
...(this.opts.roots?.areas.length ? ['add-roots'] : []),
|
|
2747
2984
|
...(this.opts.roots?.areas.length && this.opts.roots.create ? ['create-roots'] : []),
|
|
@@ -2771,6 +3008,14 @@ export class SidecarServer {
|
|
|
2771
3008
|
// Undeploy only where the daemon would record them -- anywhere else a
|
|
2772
3009
|
// client shows the old read-only view.
|
|
2773
3010
|
...(this.opts.crewDeploy ? [CREW_DEPLOY_FEATURE] : []),
|
|
3011
|
+
// Whether this sidecar lists its fleet items. Only with a real port.
|
|
3012
|
+
...(this.opts.fleetItems ? [FLEET_ITEMS_FEATURE] : []),
|
|
3013
|
+
...(this.opts.fleetUse ? [FLEET_USE_FEATURE] : []),
|
|
3014
|
+
// Whether this sidecar answers the knowledge tools and the knowledge-*
|
|
3015
|
+
// messages over its own folders. Only with a real port, which cli.ts sets.
|
|
3016
|
+
// While the fleet has turned knowledge off (knowledgeOffNow) it says
|
|
3017
|
+
// KNOWLEDGE_OFF_FEATURE instead, so a client does not read it as an old sidecar.
|
|
3018
|
+
...(this.opts.knowledge ? [this.knowledgeOffNow(this.opts.knowledge) === null ? KNOWLEDGE_FEATURE : KNOWLEDGE_OFF_FEATURE] : []),
|
|
2774
3019
|
// Whether this daemon runs deployed workflows. Only with a real port, so
|
|
2775
3020
|
// a client offers the Sidecar deploy target only where it would run.
|
|
2776
3021
|
...(this.opts.workflows ? [WORKFLOW_RUNNER_FEATURE] : []),
|
|
@@ -2803,6 +3048,7 @@ export class SidecarServer {
|
|
|
2803
3048
|
// gives regardless of this flag.
|
|
2804
3049
|
...(this.opts.mcp ? ['mcp'] : []),
|
|
2805
3050
|
...(this.opts.mcpAdmin ? ['mcp-admin'] : []),
|
|
3051
|
+
...(this.opts.mcpAdmin?.setAllow ? [MCP_ALLOW_FEATURE] : []),
|
|
2806
3052
|
// Whether this daemon serves its own Harness Config. Advertised only with
|
|
2807
3053
|
// a real (allowlisted) service behind it, so a client never shows a
|
|
2808
3054
|
// daemon's definitions as an empty workspace when what it actually has is
|
|
@@ -3133,6 +3379,41 @@ export class SidecarServer {
|
|
|
3133
3379
|
this.ch.send({ t: 'invoke-result', id: m.id, ok: false, error: `unknown tool ${m.name}` });
|
|
3134
3380
|
return;
|
|
3135
3381
|
}
|
|
3382
|
+
/* TEAM MODE: COMMANDS ARE THE ADMIN'S (security review 2026-11-12). The
|
|
3383
|
+
* agent-access gate below decides writes into agent folders only; a shell
|
|
3384
|
+
* command is not a path, so it never saw one, and any admitted member could
|
|
3385
|
+
* run one as the daemon's OS user and read the state dir with it. */
|
|
3386
|
+
if (COMMAND_TOOLS.has(m.name)) {
|
|
3387
|
+
const refusal = this.commandRefusal();
|
|
3388
|
+
if (refusal) {
|
|
3389
|
+
this.opts.log?.(`invoke ${m.name} refused: ${refusal}`);
|
|
3390
|
+
this.ch.send({ t: 'invoke-result', id: m.id, ok: false, error: refusal });
|
|
3391
|
+
return;
|
|
3392
|
+
}
|
|
3393
|
+
}
|
|
3394
|
+
/* TEAM MODE: THIS SIDECAR'S KNOWLEDGE IS THE ADMIN'S TO CHANGE (host
|
|
3395
|
+
* knowledge spec, section 3). Refused here, whatever the browser offered,
|
|
3396
|
+
* and shown to the model as the tool result, as the tool's own refusals are. */
|
|
3397
|
+
/* KNOWLEDGE THE FLEET TURNED OFF is refused on every connection, one
|
|
3398
|
+
* opened before the change included (its welcome still lists the feature),
|
|
3399
|
+
* and shown to the model as the tool result. */
|
|
3400
|
+
const knowledgeTools = SIDECAR_KNOWLEDGE_TOOLS;
|
|
3401
|
+
if (knowledgeTools.includes(m.name) && this.opts.knowledge) {
|
|
3402
|
+
const off = this.knowledgeOffNow(this.opts.knowledge);
|
|
3403
|
+
if (off !== null) {
|
|
3404
|
+
this.opts.log?.(`invoke ${m.name} refused: ${off}`);
|
|
3405
|
+
this.ch.send({ t: 'invoke-result', id: m.id, ok: true, result: off });
|
|
3406
|
+
return;
|
|
3407
|
+
}
|
|
3408
|
+
}
|
|
3409
|
+
if (m.name === SIDECAR_KNOWLEDGE_WRITE) {
|
|
3410
|
+
const refusal = this.knowledgeWriteRefusal();
|
|
3411
|
+
if (refusal) {
|
|
3412
|
+
this.opts.log?.(`invoke ${m.name} refused: ${refusal}`);
|
|
3413
|
+
this.ch.send({ t: 'invoke-result', id: m.id, ok: true, result: refusal });
|
|
3414
|
+
return;
|
|
3415
|
+
}
|
|
3416
|
+
}
|
|
3136
3417
|
/* MUTATIONS ARE LOUD, READS ARE NOT.
|
|
3137
3418
|
*
|
|
3138
3419
|
* A single agent run issues hundreds of `fs_read_file` / `fs_list_dir`
|
|
@@ -3423,6 +3704,11 @@ export class SidecarServer {
|
|
|
3423
3704
|
*/
|
|
3424
3705
|
async onRunHistory(m) {
|
|
3425
3706
|
try {
|
|
3707
|
+
const denied = await this.hostFolderDenial();
|
|
3708
|
+
if (denied !== null) {
|
|
3709
|
+
this.ch.send({ t: 'run-history-result', id: m.id, runs: [], error: denied });
|
|
3710
|
+
return;
|
|
3711
|
+
}
|
|
3426
3712
|
const history = this.opts.runner?.history?.bind(this.opts.runner);
|
|
3427
3713
|
const crew = runHistoryCrewOf(m.crew);
|
|
3428
3714
|
const identity = { clientId: this.clientId };
|
|
@@ -3445,6 +3731,11 @@ export class SidecarServer {
|
|
|
3445
3731
|
* case, and a state the caller renders rather than treats as a failure. */
|
|
3446
3732
|
async onRunTranscript(m) {
|
|
3447
3733
|
try {
|
|
3734
|
+
const denied = await this.hostFolderDenial();
|
|
3735
|
+
if (denied !== null) {
|
|
3736
|
+
this.ch.send({ t: 'run-transcript-result', id: m.id, lines: [], error: denied });
|
|
3737
|
+
return;
|
|
3738
|
+
}
|
|
3448
3739
|
const transcript = this.opts.runner?.transcript?.bind(this.opts.runner);
|
|
3449
3740
|
const identity = { clientId: this.clientId };
|
|
3450
3741
|
const lines = !transcript ? []
|
|
@@ -3463,6 +3754,11 @@ export class SidecarServer {
|
|
|
3463
3754
|
* a path. */
|
|
3464
3755
|
async onCrewMemory(m) {
|
|
3465
3756
|
try {
|
|
3757
|
+
const denied = await this.hostFolderDenial();
|
|
3758
|
+
if (denied !== null) {
|
|
3759
|
+
this.ch.send({ t: 'crew-memory-result', id: m.id, memories: [], error: denied });
|
|
3760
|
+
return;
|
|
3761
|
+
}
|
|
3466
3762
|
const runner = this.opts.runner;
|
|
3467
3763
|
// The viewer is THIS connection's verified user (team agent access).
|
|
3468
3764
|
const memories = !runner?.memories ? []
|
|
@@ -3490,6 +3786,13 @@ export class SidecarServer {
|
|
|
3490
3786
|
return;
|
|
3491
3787
|
}
|
|
3492
3788
|
}
|
|
3789
|
+
// The connection's host folder is the run's owner root (and the `via` of a daemon-crew run): it speaks only
|
|
3790
|
+
// for its bound verified user (hostFolderDenial).
|
|
3791
|
+
const folderDenied = await this.hostFolderDenial();
|
|
3792
|
+
if (folderDenied !== null) {
|
|
3793
|
+
this.ch.send({ t: 'run-error', id: m.id, error: folderDenied });
|
|
3794
|
+
return;
|
|
3795
|
+
}
|
|
3493
3796
|
/* WHERE the run happens, decided once, up front.
|
|
3494
3797
|
*
|
|
3495
3798
|
* A `sessionId` is a confinement request. A daemon that cannot honour it
|
|
@@ -3603,7 +3906,7 @@ export class SidecarServer {
|
|
|
3603
3906
|
* verified user and never a message field; with none it is refused. The
|
|
3604
3907
|
* events, `run-accepted` (with the conversation id), `run-done` and the hub
|
|
3605
3908
|
* publishing follow `onRun`. A refused or failed turn sends `run-error`. */
|
|
3606
|
-
onChatRun(m) {
|
|
3909
|
+
async onChatRun(m) {
|
|
3607
3910
|
const refuse = (error) => { this.ch.send({ t: 'run-error', id: m.id, error }); };
|
|
3608
3911
|
const port = this.opts.runner?.chat;
|
|
3609
3912
|
if (!port) {
|
|
@@ -3632,6 +3935,15 @@ export class SidecarServer {
|
|
|
3632
3935
|
refuse(spec);
|
|
3633
3936
|
return;
|
|
3634
3937
|
}
|
|
3938
|
+
// Team mode only: the chat's identity is this connection's host folder (hostFolderDenial). Solo mode reaches
|
|
3939
|
+
// the port without waiting, as before.
|
|
3940
|
+
if (this.opts.teamMode) {
|
|
3941
|
+
const denied = await this.hostFolderDenial();
|
|
3942
|
+
if (denied !== null) {
|
|
3943
|
+
refuse(denied);
|
|
3944
|
+
return;
|
|
3945
|
+
}
|
|
3946
|
+
}
|
|
3635
3947
|
const agent = m.agent;
|
|
3636
3948
|
const hub = this.opts.runs;
|
|
3637
3949
|
let runId;
|
|
@@ -3772,6 +4084,11 @@ export class SidecarServer {
|
|
|
3772
4084
|
}
|
|
3773
4085
|
const { port, ...rest } = q;
|
|
3774
4086
|
try {
|
|
4087
|
+
const denied = await this.hostFolderDenial();
|
|
4088
|
+
if (denied !== null) {
|
|
4089
|
+
this.ch.send({ t: 'chat-list-result', id: m.id, ok: false, error: denied });
|
|
4090
|
+
return;
|
|
4091
|
+
}
|
|
3775
4092
|
const chats = await port.list({ ...rest, identity: { clientId: this.clientId } });
|
|
3776
4093
|
this.ch.send({ t: 'chat-list-result', id: m.id, ok: true, chats: chats.map(chatSummaryOf) });
|
|
3777
4094
|
}
|
|
@@ -3795,6 +4112,11 @@ export class SidecarServer {
|
|
|
3795
4112
|
}
|
|
3796
4113
|
const { port, ...rest } = q;
|
|
3797
4114
|
try {
|
|
4115
|
+
const denied = await this.hostFolderDenial();
|
|
4116
|
+
if (denied !== null) {
|
|
4117
|
+
refuse(denied);
|
|
4118
|
+
return;
|
|
4119
|
+
}
|
|
3798
4120
|
const conversation = await port.get({ ...rest, identity: { clientId: this.clientId }, conversationId: m.conversationId });
|
|
3799
4121
|
this.ch.send({ t: 'chat-get-result', id: m.id, ok: true, conversation: storedChatOf(conversation) });
|
|
3800
4122
|
}
|
|
@@ -4104,6 +4426,287 @@ export class SidecarServer {
|
|
|
4104
4426
|
...(this.verifiedUserId !== undefined ? { verifiedUserId: this.verifiedUserId } : {}),
|
|
4105
4427
|
};
|
|
4106
4428
|
}
|
|
4429
|
+
/** What a knowledge message answers when the port throws. The text of an
|
|
4430
|
+
* unexpected throw may name a path on this machine, so only a caller who
|
|
4431
|
+
* manages the folders (and so sees their paths) gets it; anyone else gets
|
|
4432
|
+
* KNOWLEDGE_FAILED, and the real text goes to the log. */
|
|
4433
|
+
knowledgeThrowText(kind, err) {
|
|
4434
|
+
const text = errMessage(err);
|
|
4435
|
+
if (this.knowledgeCanManage())
|
|
4436
|
+
return text;
|
|
4437
|
+
this.opts.log?.(`${kind} failed: ${text}`);
|
|
4438
|
+
return KNOWLEDGE_FAILED;
|
|
4439
|
+
}
|
|
4440
|
+
/** Why this sidecar's knowledge is off now, or null. A port whose answer
|
|
4441
|
+
* throws is treated as off, with a fixed sentence: fail closed. */
|
|
4442
|
+
knowledgeOffNow(port) {
|
|
4443
|
+
try {
|
|
4444
|
+
return port.off?.() ?? null;
|
|
4445
|
+
}
|
|
4446
|
+
catch (err) {
|
|
4447
|
+
this.opts.log?.(`knowledge: could not tell whether knowledge is on (${err instanceof Error ? err.message : String(err)})`);
|
|
4448
|
+
return KNOWLEDGE_NOT_KEPT;
|
|
4449
|
+
}
|
|
4450
|
+
}
|
|
4451
|
+
knowledgeListReply(port) {
|
|
4452
|
+
const canManage = this.knowledgeCanManage();
|
|
4453
|
+
const folders = port.folders().map((f) => (canManage ? knowledgeViewForManager(f) : knowledgeViewForMember(f)));
|
|
4454
|
+
return { ok: true, folders, canManage };
|
|
4455
|
+
}
|
|
4456
|
+
onKnowledgeList(m) {
|
|
4457
|
+
if (typeof m.id !== 'number' || !Number.isSafeInteger(m.id))
|
|
4458
|
+
return;
|
|
4459
|
+
const answer = (reply) => this.ch.send({ t: 'knowledge-list-result', id: m.id, reply });
|
|
4460
|
+
const port = this.opts.knowledge;
|
|
4461
|
+
if (!port) {
|
|
4462
|
+
answer({ ok: false, error: KNOWLEDGE_NOT_KEPT });
|
|
4463
|
+
return;
|
|
4464
|
+
}
|
|
4465
|
+
const off = this.knowledgeOffNow(port);
|
|
4466
|
+
if (off !== null) {
|
|
4467
|
+
answer({ ok: false, error: off });
|
|
4468
|
+
return;
|
|
4469
|
+
}
|
|
4470
|
+
try {
|
|
4471
|
+
answer(this.knowledgeListReply(port));
|
|
4472
|
+
}
|
|
4473
|
+
catch (err) {
|
|
4474
|
+
answer({ ok: false, error: this.knowledgeThrowText('knowledge-list', err) });
|
|
4475
|
+
}
|
|
4476
|
+
}
|
|
4477
|
+
/** Add or remove one knowledge folder. Refused unless this connection
|
|
4478
|
+
* manages the folders, before anything else in the message is looked at. */
|
|
4479
|
+
async onKnowledgeSet(m) {
|
|
4480
|
+
if (typeof m.id !== 'number' || !Number.isSafeInteger(m.id))
|
|
4481
|
+
return;
|
|
4482
|
+
const answer = (reply) => this.ch.send({ t: 'knowledge-set-result', id: m.id, reply });
|
|
4483
|
+
const port = this.opts.knowledge;
|
|
4484
|
+
if (!port) {
|
|
4485
|
+
answer({ ok: false, error: KNOWLEDGE_NOT_KEPT });
|
|
4486
|
+
return;
|
|
4487
|
+
}
|
|
4488
|
+
const off = this.knowledgeOffNow(port);
|
|
4489
|
+
if (off !== null) {
|
|
4490
|
+
answer({ ok: false, error: off });
|
|
4491
|
+
return;
|
|
4492
|
+
}
|
|
4493
|
+
if (!this.knowledgeCanManage()) {
|
|
4494
|
+
this.opts.log?.(`knowledge-set refused: ${KNOWLEDGE_ADMIN_ONLY}`);
|
|
4495
|
+
answer({ ok: false, error: KNOWLEDGE_ADMIN_ONLY });
|
|
4496
|
+
return;
|
|
4497
|
+
}
|
|
4498
|
+
const add = m.add !== undefined ? knowledgeAddOf(m.add) : undefined;
|
|
4499
|
+
const remove = typeof m.remove === 'string' ? m.remove : undefined;
|
|
4500
|
+
const oneOf = (m.add !== undefined) !== (m.remove !== undefined);
|
|
4501
|
+
if (!oneOf || add === null || (m.remove !== undefined && remove === undefined)) {
|
|
4502
|
+
answer({ ok: false, error: KNOWLEDGE_SET_SHAPE });
|
|
4503
|
+
return;
|
|
4504
|
+
}
|
|
4505
|
+
try {
|
|
4506
|
+
const done = await port.set(add ? { add } : { remove });
|
|
4507
|
+
if (!done.ok) {
|
|
4508
|
+
answer(done);
|
|
4509
|
+
return;
|
|
4510
|
+
}
|
|
4511
|
+
this.opts.log?.(add ? `knowledge folder added: ${add.path}` : `knowledge folder removed: ${remove ?? ''}`);
|
|
4512
|
+
answer(this.knowledgeListReply(port));
|
|
4513
|
+
}
|
|
4514
|
+
catch (err) {
|
|
4515
|
+
answer({ ok: false, error: this.knowledgeThrowText('knowledge-set', err) });
|
|
4516
|
+
}
|
|
4517
|
+
}
|
|
4518
|
+
async onKnowledgeFiles(m) {
|
|
4519
|
+
if (typeof m.id !== 'number' || !Number.isSafeInteger(m.id))
|
|
4520
|
+
return;
|
|
4521
|
+
const answer = (reply) => this.ch.send({ t: 'knowledge-files-result', id: m.id, reply });
|
|
4522
|
+
const port = this.opts.knowledge;
|
|
4523
|
+
if (!port) {
|
|
4524
|
+
answer({ ok: false, error: KNOWLEDGE_NOT_KEPT });
|
|
4525
|
+
return;
|
|
4526
|
+
}
|
|
4527
|
+
const off = this.knowledgeOffNow(port);
|
|
4528
|
+
if (off !== null) {
|
|
4529
|
+
answer({ ok: false, error: off });
|
|
4530
|
+
return;
|
|
4531
|
+
}
|
|
4532
|
+
if (typeof m.folder !== 'string') {
|
|
4533
|
+
answer({ ok: false, error: 'Name a knowledge folder by its id.' });
|
|
4534
|
+
return;
|
|
4535
|
+
}
|
|
4536
|
+
try {
|
|
4537
|
+
answer(await port.files(m.folder));
|
|
4538
|
+
}
|
|
4539
|
+
catch (err) {
|
|
4540
|
+
answer({ ok: false, error: this.knowledgeThrowText('knowledge-files', err) });
|
|
4541
|
+
}
|
|
4542
|
+
}
|
|
4543
|
+
async onKnowledgeFile(m) {
|
|
4544
|
+
if (typeof m.id !== 'number' || !Number.isSafeInteger(m.id))
|
|
4545
|
+
return;
|
|
4546
|
+
const answer = (reply) => this.ch.send({ t: 'knowledge-file-result', id: m.id, reply });
|
|
4547
|
+
const port = this.opts.knowledge;
|
|
4548
|
+
if (!port) {
|
|
4549
|
+
answer({ ok: false, error: KNOWLEDGE_NOT_KEPT });
|
|
4550
|
+
return;
|
|
4551
|
+
}
|
|
4552
|
+
const off = this.knowledgeOffNow(port);
|
|
4553
|
+
if (off !== null) {
|
|
4554
|
+
answer({ ok: false, error: off });
|
|
4555
|
+
return;
|
|
4556
|
+
}
|
|
4557
|
+
if (typeof m.folder !== 'string' || typeof m.path !== 'string' || (m.stat !== undefined && typeof m.stat !== 'boolean')) {
|
|
4558
|
+
answer({ ok: false, error: 'Name a knowledge folder by its id and a file by its path in it.' });
|
|
4559
|
+
return;
|
|
4560
|
+
}
|
|
4561
|
+
try {
|
|
4562
|
+
answer(await port.file(m.folder, m.path, m.stat === true));
|
|
4563
|
+
}
|
|
4564
|
+
catch (err) {
|
|
4565
|
+
answer({ ok: false, error: this.knowledgeThrowText('knowledge-file', err) });
|
|
4566
|
+
}
|
|
4567
|
+
}
|
|
4568
|
+
async onKnowledgeChanges(m) {
|
|
4569
|
+
if (typeof m.id !== 'number' || !Number.isSafeInteger(m.id))
|
|
4570
|
+
return;
|
|
4571
|
+
const answer = (reply) => this.ch.send({ t: 'knowledge-changes-result', id: m.id, reply });
|
|
4572
|
+
const port = this.opts.knowledge;
|
|
4573
|
+
if (!port) {
|
|
4574
|
+
answer({ ok: false, error: KNOWLEDGE_NOT_KEPT });
|
|
4575
|
+
return;
|
|
4576
|
+
}
|
|
4577
|
+
const off = this.knowledgeOffNow(port);
|
|
4578
|
+
if (off !== null) {
|
|
4579
|
+
answer({ ok: false, error: off });
|
|
4580
|
+
return;
|
|
4581
|
+
}
|
|
4582
|
+
try {
|
|
4583
|
+
answer(await port.changes());
|
|
4584
|
+
}
|
|
4585
|
+
catch (err) {
|
|
4586
|
+
answer({ ok: false, error: this.knowledgeThrowText('knowledge-changes', err) });
|
|
4587
|
+
}
|
|
4588
|
+
}
|
|
4589
|
+
async onFleetItems(m) {
|
|
4590
|
+
if (typeof m.id !== 'number' || !Number.isSafeInteger(m.id))
|
|
4591
|
+
return;
|
|
4592
|
+
const answer = (reply) => this.ch.send({ t: 'fleet-items-result', id: m.id, reply });
|
|
4593
|
+
const port = this.opts.fleetItems;
|
|
4594
|
+
if (!port) {
|
|
4595
|
+
answer({ ok: false, error: 'this sidecar is not in a fleet' });
|
|
4596
|
+
return;
|
|
4597
|
+
}
|
|
4598
|
+
try {
|
|
4599
|
+
answer(await port.list(this.verifiedUserId ?? undefined));
|
|
4600
|
+
}
|
|
4601
|
+
catch (err) {
|
|
4602
|
+
answer({ ok: false, error: errMessage(err) });
|
|
4603
|
+
}
|
|
4604
|
+
}
|
|
4605
|
+
/** The caller a fleet workflow call runs as, from THIS connection only, or the refusal. Never the verified user
|
|
4606
|
+
* id (it would reach the workflow's prompts).
|
|
4607
|
+
*
|
|
4608
|
+
* Team mode: `host:<dirName>` only when that folder is bound to this connection's verified user (hostFolderDenial,
|
|
4609
|
+
* the rule the workflow-* messages use): the folder comes from the hello's clientId, which is not secret, so an
|
|
4610
|
+
* unbound name is refused with that check's denial. Solo mode: `host:<dirName>` for a
|
|
4611
|
+
* verified user whose folder was resolved, else `host:owner`. */
|
|
4612
|
+
async fleetCaller() {
|
|
4613
|
+
if (this.opts.teamMode) {
|
|
4614
|
+
if (this.verifiedUserId !== undefined && this.dirName === undefined)
|
|
4615
|
+
return { error: FLEET_CALLER_NO_FOLDER };
|
|
4616
|
+
const denied = await this.hostFolderDenial();
|
|
4617
|
+
if (denied !== null || this.dirName === undefined)
|
|
4618
|
+
return { error: denied ?? FLEET_CALLER_NO_FOLDER };
|
|
4619
|
+
return { caller: { name: `host:${this.dirName}`, trigger: 'host' } };
|
|
4620
|
+
}
|
|
4621
|
+
if (this.verifiedUserId !== undefined && this.dirName !== undefined)
|
|
4622
|
+
return { caller: { name: `host:${this.dirName}`, trigger: 'host' } };
|
|
4623
|
+
return { caller: { name: 'host:owner', trigger: 'host' } };
|
|
4624
|
+
}
|
|
4625
|
+
async onFleetSkillCatalog(m) {
|
|
4626
|
+
if (typeof m.id !== 'number' || !Number.isSafeInteger(m.id))
|
|
4627
|
+
return;
|
|
4628
|
+
const answer = (reply) => this.ch.send({ t: 'fleet-skill-catalog-result', id: m.id, reply });
|
|
4629
|
+
const port = this.opts.fleetUse;
|
|
4630
|
+
if (!port) {
|
|
4631
|
+
answer({ ok: false, error: 'this sidecar is not in a fleet' });
|
|
4632
|
+
return;
|
|
4633
|
+
}
|
|
4634
|
+
try {
|
|
4635
|
+
answer(await port.skills());
|
|
4636
|
+
}
|
|
4637
|
+
catch (err) {
|
|
4638
|
+
answer({ ok: false, error: errMessage(err) });
|
|
4639
|
+
}
|
|
4640
|
+
}
|
|
4641
|
+
async onFleetWorkflowCatalog(m) {
|
|
4642
|
+
if (typeof m.id !== 'number' || !Number.isSafeInteger(m.id))
|
|
4643
|
+
return;
|
|
4644
|
+
const answer = (reply) => this.ch.send({ t: 'fleet-workflow-catalog-result', id: m.id, reply });
|
|
4645
|
+
const port = this.opts.fleetUse;
|
|
4646
|
+
if (!port) {
|
|
4647
|
+
answer({ ok: false, error: 'this sidecar is not in a fleet' });
|
|
4648
|
+
return;
|
|
4649
|
+
}
|
|
4650
|
+
try {
|
|
4651
|
+
answer(await port.workflows());
|
|
4652
|
+
}
|
|
4653
|
+
catch (err) {
|
|
4654
|
+
answer({ ok: false, error: errMessage(err) });
|
|
4655
|
+
}
|
|
4656
|
+
}
|
|
4657
|
+
async onFleetWorkflowCall(m) {
|
|
4658
|
+
// A frame with no usable correlation id is dropped before the port: nothing runs that its sender could
|
|
4659
|
+
// never match to an answer or cancel.
|
|
4660
|
+
if (typeof m.id !== 'number' || !Number.isSafeInteger(m.id))
|
|
4661
|
+
return;
|
|
4662
|
+
const id = m.id;
|
|
4663
|
+
const answer = (reply) => {
|
|
4664
|
+
if (!this.closed)
|
|
4665
|
+
this.ch.send({ t: 'fleet-workflow-call-result', id, reply });
|
|
4666
|
+
};
|
|
4667
|
+
const port = this.opts.fleetUse;
|
|
4668
|
+
if (!port) {
|
|
4669
|
+
answer({ ok: false, error: 'this sidecar is not in a fleet' });
|
|
4670
|
+
return;
|
|
4671
|
+
}
|
|
4672
|
+
// Runtime-typed: the message arrived via JSON.parse(...) as SidecarMsg.
|
|
4673
|
+
const name = m.name;
|
|
4674
|
+
if (typeof name !== 'string' || name.length === 0) {
|
|
4675
|
+
answer({ ok: false, error: 'a fleet workflow call needs a workflow name' });
|
|
4676
|
+
return;
|
|
4677
|
+
}
|
|
4678
|
+
const inputs = m.inputs;
|
|
4679
|
+
if (!isWorkflowCallInputs(inputs)) {
|
|
4680
|
+
answer({ ok: false, error: FLEET_WORKFLOW_INPUTS_ERROR });
|
|
4681
|
+
return;
|
|
4682
|
+
}
|
|
4683
|
+
if (this.fleetCalls.has(id)) {
|
|
4684
|
+
answer({ ok: false, error: 'a fleet workflow call with that id is already open' });
|
|
4685
|
+
return;
|
|
4686
|
+
}
|
|
4687
|
+
if (this.fleetCalls.size >= FLEET_CALLS_OPEN_MAX) {
|
|
4688
|
+
answer({ ok: false, error: FLEET_CALLS_TOO_MANY });
|
|
4689
|
+
return;
|
|
4690
|
+
}
|
|
4691
|
+
// The id is held before the first await, so a second call under it, or a cancel, sees it.
|
|
4692
|
+
const control = new AbortController();
|
|
4693
|
+
this.fleetCalls.set(id, control);
|
|
4694
|
+
try {
|
|
4695
|
+
const named = await this.fleetCaller();
|
|
4696
|
+
if ('error' in named) {
|
|
4697
|
+
answer({ ok: false, error: named.error });
|
|
4698
|
+
return;
|
|
4699
|
+
}
|
|
4700
|
+
answer(await port.call(named.caller, name, inputs, control.signal));
|
|
4701
|
+
}
|
|
4702
|
+
catch (err) {
|
|
4703
|
+
answer({ ok: false, error: errMessage(err) });
|
|
4704
|
+
}
|
|
4705
|
+
finally {
|
|
4706
|
+
if (this.fleetCalls.get(id) === control)
|
|
4707
|
+
this.fleetCalls.delete(id);
|
|
4708
|
+
}
|
|
4709
|
+
}
|
|
4107
4710
|
async onCrewDeploy(m) {
|
|
4108
4711
|
// A frame with no usable correlation id is dropped before the port: no
|
|
4109
4712
|
// change is made that its sender could never match to an answer.
|
|
@@ -4145,25 +4748,49 @@ export class SidecarServer {
|
|
|
4145
4748
|
answer({ ok: false, error: errMessage(err) });
|
|
4146
4749
|
}
|
|
4147
4750
|
}
|
|
4148
|
-
/**
|
|
4149
|
-
*
|
|
4150
|
-
*
|
|
4151
|
-
|
|
4751
|
+
/** THE ONE RULE for whether this connection's synced folder speaks for it: 'bound' only when the folder's
|
|
4752
|
+
* bound owner (the deploy binding, `workflowsFolderOwner`) is this connection's verified user. A folder the
|
|
4753
|
+
* daemon did not resolve, no lookup, no binding, or a lookup that throws is 'unbound'. Every team-mode check of
|
|
4754
|
+
* a host folder (workflowsTeamDenial, hostFolderDenial) reads this and only words it differently. */
|
|
4755
|
+
async hostFolderStanding() {
|
|
4152
4756
|
if (this.verifiedUserId === undefined)
|
|
4153
|
-
return
|
|
4757
|
+
return 'unverified';
|
|
4154
4758
|
const lookup = this.opts.workflowsFolderOwner;
|
|
4155
4759
|
if (this.dirName === undefined || !lookup)
|
|
4156
|
-
return
|
|
4760
|
+
return 'unbound';
|
|
4157
4761
|
let bound;
|
|
4158
4762
|
try {
|
|
4159
4763
|
bound = await lookup(this.dirName);
|
|
4160
4764
|
}
|
|
4161
4765
|
catch {
|
|
4162
|
-
return
|
|
4766
|
+
return 'unbound';
|
|
4163
4767
|
}
|
|
4164
4768
|
if (bound === undefined)
|
|
4165
|
-
return
|
|
4166
|
-
return bound === this.verifiedUserId ?
|
|
4769
|
+
return 'unbound';
|
|
4770
|
+
return bound === this.verifiedUserId ? 'bound' : 'not-owner';
|
|
4771
|
+
}
|
|
4772
|
+
/** Team mode: why this connection may NOT use its synced folder's workflow
|
|
4773
|
+
* packages and results, or null when it may (hostFolderStanding). */
|
|
4774
|
+
async workflowsTeamDenial() {
|
|
4775
|
+
switch (await this.hostFolderStanding()) {
|
|
4776
|
+
case 'bound': return null;
|
|
4777
|
+
case 'unverified': return WORKFLOWS_VERIFY_FIRST;
|
|
4778
|
+
case 'unbound': return WORKFLOWS_FOLDER_UNBOUND;
|
|
4779
|
+
case 'not-owner': return WORKFLOWS_NOT_FOLDER_OWNER;
|
|
4780
|
+
}
|
|
4781
|
+
}
|
|
4782
|
+
/** Why this connection's host folder may NOT speak for it in a run, a chat, a history, transcript or memory
|
|
4783
|
+
* read, a fleet workflow call, or a host-sync write, or null when it may. Solo mode: always null (unchanged). Team mode:
|
|
4784
|
+
* hostFolderStanding, the rule the workflow-* messages use. */
|
|
4785
|
+
async hostFolderDenial() {
|
|
4786
|
+
if (!this.opts.teamMode)
|
|
4787
|
+
return null;
|
|
4788
|
+
switch (await this.hostFolderStanding()) {
|
|
4789
|
+
case 'bound': return null;
|
|
4790
|
+
case 'unverified': return HOST_FOLDER_VERIFY_FIRST;
|
|
4791
|
+
case 'unbound': return HOST_FOLDER_UNBOUND;
|
|
4792
|
+
case 'not-owner': return HOST_FOLDER_NOT_OWNER;
|
|
4793
|
+
}
|
|
4167
4794
|
}
|
|
4168
4795
|
async onWorkflows(m) {
|
|
4169
4796
|
// A frame with no usable correlation id is dropped before the port: no
|
|
@@ -4933,7 +5560,7 @@ export class SidecarServer {
|
|
|
4933
5560
|
if (m.t !== 'mcp-servers-list') {
|
|
4934
5561
|
const refusal = this.mcpRefusal();
|
|
4935
5562
|
if (refusal) {
|
|
4936
|
-
this.ch.send({ t: 'mcp-admin-error', id: m.id, error: refusal });
|
|
5563
|
+
this.ch.send({ t: 'mcp-admin-error', id: m.id, error: refusal, code: 'MCP_ADMIN_ONLY' });
|
|
4937
5564
|
return;
|
|
4938
5565
|
}
|
|
4939
5566
|
}
|
|
@@ -4954,8 +5581,9 @@ export class SidecarServer {
|
|
|
4954
5581
|
* branch that is not part of it runs setCredential too, with whatever
|
|
4955
5582
|
* `m.header`/`m.value` happen to be (undefined, for every other message).
|
|
4956
5583
|
* That is exactly what a stray `if` here did before this comment existed. */
|
|
5584
|
+
let applied;
|
|
4957
5585
|
if (m.t === 'mcp-add')
|
|
4958
|
-
await admin.addServer(m.name, m.url, m.hosted === true, m.authUrl);
|
|
5586
|
+
applied = await admin.addServer(m.name, m.url, m.hosted === true, m.authUrl);
|
|
4959
5587
|
else if (m.t === 'mcp-login') {
|
|
4960
5588
|
/* The consent URL is pushed the moment it exists, BEFORE the flow
|
|
4961
5589
|
* blocks waiting for a human. Holding it until the end would deliver it
|
|
@@ -4965,15 +5593,48 @@ export class SidecarServer {
|
|
|
4965
5593
|
this.ch.send({ t: 'mcp-admin-error', id: m.id, error: out.message, reason: out.reason });
|
|
4966
5594
|
return;
|
|
4967
5595
|
}
|
|
5596
|
+
applied = out.reloadError !== undefined ? { reloadError: out.reloadError } : undefined;
|
|
4968
5597
|
}
|
|
4969
5598
|
else if (m.t === 'mcp-remove')
|
|
4970
|
-
await admin.removeServer(m.name);
|
|
5599
|
+
applied = await admin.removeServer(m.name);
|
|
5600
|
+
else if (m.t === 'mcp-allow-set') {
|
|
5601
|
+
if (!admin.setAllow) {
|
|
5602
|
+
this.ch.send({ t: 'mcp-admin-error', id: m.id, error: 'this daemon cannot switch MCP tools -- update it and reconnect' });
|
|
5603
|
+
return;
|
|
5604
|
+
}
|
|
5605
|
+
// The shape is checked here, before the port: the message is a
|
|
5606
|
+
// browser's, and a non-string entry must never reach daemon.json.
|
|
5607
|
+
if (typeof m.name !== 'string' || !m.name || !Array.isArray(m.tools) || !m.tools.every((t) => typeof t === 'string')) {
|
|
5608
|
+
this.ch.send({ t: 'mcp-admin-error', id: m.id, code: 'BAD_REQUEST', error: 'mcp-allow-set needs a server name and a list of tool names.' });
|
|
5609
|
+
return;
|
|
5610
|
+
}
|
|
5611
|
+
applied = await admin.setAllow(m.name, m.tools);
|
|
5612
|
+
}
|
|
4971
5613
|
else
|
|
4972
|
-
await admin.setCredential(m.name, m.header, m.value);
|
|
4973
|
-
|
|
5614
|
+
applied = await admin.setCredential(m.name, m.header, m.value);
|
|
5615
|
+
const reloadError = applied ? applied.reloadError : undefined;
|
|
5616
|
+
/* A switch answers with the rows as they now stand, so the browser shows
|
|
5617
|
+
* what the daemon allows (a glob may keep a tool on), not what it asked
|
|
5618
|
+
* for. Only the admin reaches here (mcpRefusal above), so the full rows.
|
|
5619
|
+
* Best effort: the write is saved either way. */
|
|
5620
|
+
let servers;
|
|
5621
|
+
if (m.t === 'mcp-allow-set') {
|
|
5622
|
+
try {
|
|
5623
|
+
servers = await admin.listServers();
|
|
5624
|
+
}
|
|
5625
|
+
catch {
|
|
5626
|
+
servers = undefined;
|
|
5627
|
+
}
|
|
5628
|
+
}
|
|
5629
|
+
this.ch.send({
|
|
5630
|
+
t: 'mcp-admin-ok', id: m.id,
|
|
5631
|
+
...(typeof reloadError === 'string' ? { reloadError } : {}),
|
|
5632
|
+
...(servers ? { servers } : {}),
|
|
5633
|
+
});
|
|
4974
5634
|
}
|
|
4975
5635
|
catch (e) {
|
|
4976
|
-
|
|
5636
|
+
const code = mcpAdminCodeOf(e);
|
|
5637
|
+
this.ch.send({ t: 'mcp-admin-error', id: m.id, error: e instanceof Error ? e.message : String(e), ...(code ? { code } : {}) });
|
|
4977
5638
|
}
|
|
4978
5639
|
}
|
|
4979
5640
|
/** Part D credential messages. The caller is this connection's VERIFIED
|
|
@@ -5801,6 +6462,41 @@ export class SidecarServer {
|
|
|
5801
6462
|
return MCP_ADMIN_ONLY;
|
|
5802
6463
|
}
|
|
5803
6464
|
}
|
|
6465
|
+
/** Team mode: running a command on the daemon's machine, or reaching one
|
|
6466
|
+
* that is running, is the admin's. Null when THIS connection may. Unknown
|
|
6467
|
+
* admin and any throw while deciding are refusals. Solo mode: always null. */
|
|
6468
|
+
commandRefusal() {
|
|
6469
|
+
try {
|
|
6470
|
+
const access = this.teamAccess();
|
|
6471
|
+
if (!access)
|
|
6472
|
+
return null;
|
|
6473
|
+
return sameAdmin(this.verifiedUserId, access.adminId()) ? null : COMMANDS_ADMIN_ONLY;
|
|
6474
|
+
}
|
|
6475
|
+
catch {
|
|
6476
|
+
return COMMANDS_ADMIN_ONLY;
|
|
6477
|
+
}
|
|
6478
|
+
}
|
|
6479
|
+
/** Team mode: writing into this sidecar's knowledge folders is the admin's.
|
|
6480
|
+
* Null when THIS connection may. Unknown admin and any throw while deciding
|
|
6481
|
+
* are refusals. Solo mode: always null. */
|
|
6482
|
+
knowledgeWriteRefusal() {
|
|
6483
|
+
return this.knowledgeCanManage() ? null : KNOWLEDGE_WRITE_ADMIN_ONLY;
|
|
6484
|
+
}
|
|
6485
|
+
/** Whether THIS connection manages this sidecar's knowledge folders (sees
|
|
6486
|
+
* their paths, adds and removes them, writes into the writable one): always
|
|
6487
|
+
* on a solo daemon; on a team daemon only its admin. Unknown admin and any
|
|
6488
|
+
* throw while deciding are a no. */
|
|
6489
|
+
knowledgeCanManage() {
|
|
6490
|
+
try {
|
|
6491
|
+
const access = this.teamAccess();
|
|
6492
|
+
if (!access)
|
|
6493
|
+
return true;
|
|
6494
|
+
return sameAdmin(this.verifiedUserId, access.adminId());
|
|
6495
|
+
}
|
|
6496
|
+
catch {
|
|
6497
|
+
return false;
|
|
6498
|
+
}
|
|
6499
|
+
}
|
|
5804
6500
|
async onRoomFindings(m) {
|
|
5805
6501
|
if (!this.opts.rooms) {
|
|
5806
6502
|
// A daemon without the capability hosts no rooms, so it holds no
|
|
@@ -5853,8 +6549,12 @@ export class SidecarServer {
|
|
|
5853
6549
|
this.ch.send({ t: 'config-result', id: m.id, ok: false, error: gate.refusal });
|
|
5854
6550
|
return;
|
|
5855
6551
|
}
|
|
6552
|
+
/* WHOSE global memory (`memory/`) this call reaches: the service maps
|
|
6553
|
+
* it, after its own allowlist check, from this scope. Every call passes
|
|
6554
|
+
* it, so no route reaches `memory/` without the decision. */
|
|
6555
|
+
const scope = this.configMemoryScope();
|
|
5856
6556
|
if (m.t === 'config-list') {
|
|
5857
|
-
const entries = await svc.list(m.path, m.depth);
|
|
6557
|
+
const entries = await svc.list(m.path, m.depth, scope);
|
|
5858
6558
|
const shown = await this.visibleConfigEntries(svc, m.path, entries);
|
|
5859
6559
|
this.ch.send(shown
|
|
5860
6560
|
? { t: 'config-result', id: m.id, ok: true, entries: shown }
|
|
@@ -5862,18 +6562,18 @@ export class SidecarServer {
|
|
|
5862
6562
|
return;
|
|
5863
6563
|
}
|
|
5864
6564
|
if (m.t === 'config-read') {
|
|
5865
|
-
this.ch.send({ t: 'config-result', id: m.id, ok: true, content: await svc.read(m.path) });
|
|
6565
|
+
this.ch.send({ t: 'config-result', id: m.id, ok: true, content: await svc.read(m.path, scope) });
|
|
5866
6566
|
return;
|
|
5867
6567
|
}
|
|
5868
6568
|
if (m.t === 'config-write')
|
|
5869
|
-
await svc.write(m.path, m.content);
|
|
6569
|
+
await svc.write(m.path, m.content, scope);
|
|
5870
6570
|
else if (m.t === 'config-append') {
|
|
5871
6571
|
if (!svc.append)
|
|
5872
6572
|
throw new Error('this sidecar cannot append to its harness config');
|
|
5873
|
-
await svc.append(m.path, m.content);
|
|
6573
|
+
await svc.append(m.path, m.content, scope);
|
|
5874
6574
|
}
|
|
5875
6575
|
else
|
|
5876
|
-
await svc.remove(m.path);
|
|
6576
|
+
await svc.remove(m.path, scope);
|
|
5877
6577
|
await this.afterConfigChange(gate);
|
|
5878
6578
|
this.ch.send({ t: 'config-result', id: m.id, ok: true });
|
|
5879
6579
|
}
|
|
@@ -5881,6 +6581,20 @@ export class SidecarServer {
|
|
|
5881
6581
|
this.ch.send({ t: 'config-result', id: m.id, ok: false, error: errMessage(e) });
|
|
5882
6582
|
}
|
|
5883
6583
|
}
|
|
6584
|
+
/** Whose global memory a config call on THIS connection reaches. A
|
|
6585
|
+
* personal daemon, and a team daemon's admin: the shared `memory/` itself
|
|
6586
|
+
* (undefined). Any other team connection: its verified user's own partition,
|
|
6587
|
+
* or none at all (null) when no user is verified. Throws only when the
|
|
6588
|
+
* access port throws; the caller then refuses the call. */
|
|
6589
|
+
configMemoryScope() {
|
|
6590
|
+
const access = this.teamAccess();
|
|
6591
|
+
if (!access)
|
|
6592
|
+
return undefined;
|
|
6593
|
+
const user = this.verifiedUserId;
|
|
6594
|
+
if (sameAdmin(user, access.adminId()))
|
|
6595
|
+
return undefined;
|
|
6596
|
+
return { memoryUser: typeof user === 'string' && user !== '' ? user : null };
|
|
6597
|
+
}
|
|
5884
6598
|
/** The config path as the filesystem would reach it, for classification:
|
|
5885
6599
|
* separators, `.`, empty and `..` segments resolved; the `crew` segment, the
|
|
5886
6600
|
* agent folder (to its on-disk name, read from the service's own listing of
|
|
@@ -5969,6 +6683,13 @@ export class SidecarServer {
|
|
|
5969
6683
|
this.ch.send({ t: 'host-sync-result', id: m.id, ok: false, error: 'this sidecar cannot sync host config' });
|
|
5970
6684
|
return;
|
|
5971
6685
|
}
|
|
6686
|
+
// Team mode: the clientId names the folder written to, and it is not secret, so it writes only for the
|
|
6687
|
+
// verified user that folder is bound to (hostFolderDenial). Solo mode is unchanged.
|
|
6688
|
+
const denied = await this.hostFolderDenial();
|
|
6689
|
+
if (denied !== null) {
|
|
6690
|
+
this.ch.send({ t: 'host-sync-result', id: m.id, ok: false, error: denied });
|
|
6691
|
+
return;
|
|
6692
|
+
}
|
|
5972
6693
|
try {
|
|
5973
6694
|
await this.opts.hostSync.put(identity.clientId, m.path, m.content);
|
|
5974
6695
|
this.ch.send({ t: 'host-sync-result', id: m.id, ok: true });
|
|
@@ -5987,6 +6708,13 @@ export class SidecarServer {
|
|
|
5987
6708
|
this.ch.send({ t: 'host-sync-result', id: m.id, ok: false, error: 'this sidecar cannot sync host config' });
|
|
5988
6709
|
return;
|
|
5989
6710
|
}
|
|
6711
|
+
// Team mode: the clientId names the folder written to, and it is not secret, so it writes only for the
|
|
6712
|
+
// verified user that folder is bound to (hostFolderDenial). Solo mode is unchanged.
|
|
6713
|
+
const denied = await this.hostFolderDenial();
|
|
6714
|
+
if (denied !== null) {
|
|
6715
|
+
this.ch.send({ t: 'host-sync-result', id: m.id, ok: false, error: denied });
|
|
6716
|
+
return;
|
|
6717
|
+
}
|
|
5990
6718
|
try {
|
|
5991
6719
|
await this.opts.hostSync.remove(identity.clientId, m.path);
|
|
5992
6720
|
this.ch.send({ t: 'host-sync-result', id: m.id, ok: true });
|