@corenel/harness 0.21.0 → 0.23.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/chat/engine.d.ts +9 -0
- package/dist/chat/engine.d.ts.map +1 -1
- package/dist/chat/engine.js +90 -6
- package/dist/chat/engine.js.map +1 -1
- package/dist/chat/persist.d.ts +8 -0
- package/dist/chat/persist.d.ts.map +1 -1
- package/dist/chat/persist.js +11 -0
- package/dist/chat/persist.js.map +1 -1
- package/dist/chat/runWatchdog.d.ts +1 -1
- package/dist/chat/runWatchdog.d.ts.map +1 -1
- package/dist/chat/runWatchdog.js +1 -1
- package/dist/chat/runWatchdog.js.map +1 -1
- package/dist/core/loop.d.ts.map +1 -1
- package/dist/core/loop.js +134 -78
- package/dist/core/loop.js.map +1 -1
- package/dist/dag/executeNode.js +3 -3
- 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 +17 -3
- package/dist/guardrail/permission-service.d.ts.map +1 -1
- package/dist/guardrail/permission-service.js +11 -6
- 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.js +2 -2
- 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.map +1 -1
- package/dist/sidecar/fsChanges.d.ts +23 -0
- package/dist/sidecar/fsChanges.d.ts.map +1 -0
- package/dist/sidecar/fsChanges.js +77 -0
- package/dist/sidecar/fsChanges.js.map +1 -0
- package/dist/sidecar/host.d.ts +62 -9
- package/dist/sidecar/host.d.ts.map +1 -1
- package/dist/sidecar/host.js +243 -6
- package/dist/sidecar/host.js.map +1 -1
- package/dist/sidecar/memberGate.d.ts.map +1 -1
- package/dist/sidecar/memberGate.js +9 -0
- package/dist/sidecar/memberGate.js.map +1 -1
- package/dist/sidecar/server.d.ts +98 -6
- package/dist/sidecar/server.d.ts.map +1 -1
- package/dist/sidecar/server.js +408 -19
- 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/tools/builtins.d.ts +6 -1
- package/dist/tools/builtins.d.ts.map +1 -1
- package/dist/tools/builtins.js +13 -2
- 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/sessions.d.ts.map +1 -1
- package/dist/tools/sessions.js +1 -0
- package/dist/tools/sessions.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,16 +1,24 @@
|
|
|
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';
|
|
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, 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, 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';
|
|
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
7
|
import { AskUserBroker } from './askUserBroker.js';
|
|
8
|
+
import { fsChangesFor } from './fsChanges.js';
|
|
8
9
|
/** A workflow reply's refusal, cut to the length a host accepts
|
|
9
10
|
* (MAX_WORKFLOW_REPLY_ERROR): a longer one is refused whole by the host's
|
|
10
11
|
* reply check, and the person would lose the reason. */
|
|
11
12
|
function clipReplyError(error) {
|
|
12
13
|
return error.length > MAX_WORKFLOW_REPLY_ERROR ? error.slice(0, MAX_WORKFLOW_REPLY_ERROR) : error;
|
|
13
14
|
}
|
|
15
|
+
/** The refusal for workflow inputs that fail isWorkflowCallInputs. */
|
|
16
|
+
export const FLEET_WORKFLOW_INPUTS_ERROR = `workflow inputs must be an object of at most ${FLEET_USE_LIMITS.inputsBytes / 1024} KiB`;
|
|
17
|
+
/** At most this many fleet workflow calls open at once on one connection (each loads and verifies its archive). */
|
|
18
|
+
export const FLEET_CALLS_OPEN_MAX = 16;
|
|
19
|
+
export const FLEET_CALLS_TOO_MANY = 'too many fleet workflow calls open';
|
|
20
|
+
/** A team connection whose host folder the daemon has not resolved (yet): it cannot be named. */
|
|
21
|
+
export const FLEET_CALLER_NO_FOLDER = 'this connection has no host folder yet; reconnect and try again';
|
|
14
22
|
/** A deliberate refusal from the approvals port, written for the person who
|
|
15
23
|
* asked: its message goes on the wire as is. Recognized by `name`, not by
|
|
16
24
|
* `instanceof`, so a second copy of this module (a test alias, a bundled
|
|
@@ -33,6 +41,13 @@ export function isApprovalsRefusal(e) {
|
|
|
33
41
|
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';
|
|
34
42
|
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';
|
|
35
43
|
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';
|
|
44
|
+
/* Team mode, everything a connection's host folder speaks for besides workflow-*: its runs (the folder is the
|
|
45
|
+
* owner root and the `via` a daemon-crew run is recorded under), chat turns and reads, run history, transcripts,
|
|
46
|
+
* crew memory, fleet workflow calls and host-sync writes into the folder. The same rule as the workflow-* messages (hostFolderStanding): the folder
|
|
47
|
+
* comes from the hello's clientId, which is not secret, so it speaks only for the verified user it is bound to. */
|
|
48
|
+
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';
|
|
49
|
+
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';
|
|
50
|
+
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';
|
|
36
51
|
export const APPROVALS_FAILED = 'Standing approvals could not be read or changed. The daemon log has the reason.';
|
|
37
52
|
export const APPROVALS_NO_USER = 'In team mode standing approvals need a verified user on this connection.';
|
|
38
53
|
export const APPROVALS_NAME_AGENT = 'Name the agent: in team mode standing approvals belong to one agent, and only its owner can revoke them.';
|
|
@@ -99,6 +114,13 @@ function providedMcpUrls(v) {
|
|
|
99
114
|
const PEER_DAEMON_MESSAGES = new Set([
|
|
100
115
|
'hello', 'client-proof', 'identity-assert', 'host-call-result',
|
|
101
116
|
]);
|
|
117
|
+
/** The file tools whose success is news for the daemon's other clients (the
|
|
118
|
+
* `fs-changed` broadcast): every tool that writes or removes a file under a
|
|
119
|
+
* root. The fs tools have no rename or mkdir; a write makes its parents. */
|
|
120
|
+
const FS_CHANGE_TOOLS = new Set(['sidecar_fs_write_file', 'sidecar_fs_write_bytes', 'sidecar_fs_delete']);
|
|
121
|
+
/** Gives each connection the id it publishes file changes under, so the bus
|
|
122
|
+
* can tell the writer apart from everyone else. Per process, like the bus. */
|
|
123
|
+
let fsConnectionSeq = 0;
|
|
102
124
|
/** Server names a credential message may never carry: auth.json is keyed by
|
|
103
125
|
* server name, and these are Object.prototype's own. */
|
|
104
126
|
const AGENT_CRED_RESERVED_SERVERS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
@@ -107,6 +129,11 @@ const AGENT_CRED_RESERVED_SERVERS = new Set(['__proto__', 'constructor', 'protot
|
|
|
107
129
|
function logSafe(s) {
|
|
108
130
|
return s.replace(/[\x00-\x1f\x7f-\x9f]/g, '');
|
|
109
131
|
}
|
|
132
|
+
/** The MCP admin code a thrown refusal carries, when it is one a reply may name. */
|
|
133
|
+
function mcpAdminCodeOf(err) {
|
|
134
|
+
const code = typeof err === 'object' && err !== null ? err.code : undefined;
|
|
135
|
+
return typeof code === 'string' && MCP_ADMIN_ERROR_CODES.includes(code) ? code : null;
|
|
136
|
+
}
|
|
110
137
|
/** The code a thrown refusal carries, when it is one a reply may name. */
|
|
111
138
|
function agentCredCodeOf(err) {
|
|
112
139
|
const code = typeof err === 'object' && err !== null ? err.code : undefined;
|
|
@@ -779,6 +806,14 @@ export class SidecarServer {
|
|
|
779
806
|
writable: true,
|
|
780
807
|
value: void 0
|
|
781
808
|
});
|
|
809
|
+
/** This connection sent `attendance-leave`: it is detached for good and
|
|
810
|
+
* never attached again, and an answer it sends is dropped. */
|
|
811
|
+
Object.defineProperty(this, "leftAttendance", {
|
|
812
|
+
enumerable: true,
|
|
813
|
+
configurable: true,
|
|
814
|
+
writable: true,
|
|
815
|
+
value: false
|
|
816
|
+
});
|
|
782
817
|
/** This connection's seat for chat ask_user (verified connections only),
|
|
783
818
|
* and its leave function, called once on close. */
|
|
784
819
|
Object.defineProperty(this, "askSeat", {
|
|
@@ -868,6 +903,14 @@ export class SidecarServer {
|
|
|
868
903
|
writable: true,
|
|
869
904
|
value: new Set()
|
|
870
905
|
});
|
|
906
|
+
/** This connection's open fleet workflow calls by request id: aborted by `fleet-workflow-cancel` and,
|
|
907
|
+
* all of them, when the connection closes. */
|
|
908
|
+
Object.defineProperty(this, "fleetCalls", {
|
|
909
|
+
enumerable: true,
|
|
910
|
+
configurable: true,
|
|
911
|
+
writable: true,
|
|
912
|
+
value: new Map()
|
|
913
|
+
});
|
|
871
914
|
/** Processes THIS connection is watching. A shared process table means two
|
|
872
915
|
* browsers can be paired to one daemon, and a client that never asked about a
|
|
873
916
|
* process should not be streamed its output - so output is fanned out per
|
|
@@ -885,6 +928,14 @@ export class SidecarServer {
|
|
|
885
928
|
writable: true,
|
|
886
929
|
value: void 0
|
|
887
930
|
});
|
|
931
|
+
/** What this connection publishes its file changes under on the shared bus,
|
|
932
|
+
* and how it recognises its own changes there (never sent back to it). */
|
|
933
|
+
Object.defineProperty(this, "fsConnectionId", {
|
|
934
|
+
enumerable: true,
|
|
935
|
+
configurable: true,
|
|
936
|
+
writable: true,
|
|
937
|
+
value: `c${++fsConnectionSeq}`
|
|
938
|
+
});
|
|
888
939
|
/* ---- sessions ---- */
|
|
889
940
|
/** Per-session stream stoppers for THIS connection.
|
|
890
941
|
*
|
|
@@ -1031,6 +1082,19 @@ export class SidecarServer {
|
|
|
1031
1082
|
return;
|
|
1032
1083
|
this.ch.send({ t: 'room-progress', room });
|
|
1033
1084
|
});
|
|
1085
|
+
/* Same gating as roots and rooms, plus: never the writer itself (it knows
|
|
1086
|
+
* what it wrote, and its own listeners already fired). Only the root and
|
|
1087
|
+
* the path cross, never content. No team rule narrows fs READS for a
|
|
1088
|
+
* member (onInvoke gates writes only, and every active member is served
|
|
1089
|
+
* the same file tools over the same roots), so any connection this would
|
|
1090
|
+
* reach could list the same path itself. */
|
|
1091
|
+
const offFsChanges = opts.fsChanges?.subscribe((c) => {
|
|
1092
|
+
if (c.by === this.fsConnectionId)
|
|
1093
|
+
return;
|
|
1094
|
+
if (!this.authed || this.closed || this.peerDaemon || this.memberPending())
|
|
1095
|
+
return;
|
|
1096
|
+
this.ch.send({ t: 'fs-changed', root: c.root, path: c.path });
|
|
1097
|
+
});
|
|
1034
1098
|
/* A member approved while connected is told so at once (Part E), so the
|
|
1035
1099
|
* waiting screen can move on; the gate itself re-reads the status on the
|
|
1036
1100
|
* next message. Only THIS connection's verified user is ever told.
|
|
@@ -1113,8 +1177,13 @@ export class SidecarServer {
|
|
|
1113
1177
|
for (const detach of this.runDetachers)
|
|
1114
1178
|
detach();
|
|
1115
1179
|
this.runDetachers.clear();
|
|
1180
|
+
// A fleet workflow call has no one left to answer: stop every open one.
|
|
1181
|
+
for (const call of this.fleetCalls.values())
|
|
1182
|
+
call.abort();
|
|
1183
|
+
this.fleetCalls.clear();
|
|
1116
1184
|
offRoots?.();
|
|
1117
1185
|
offRooms?.();
|
|
1186
|
+
offFsChanges?.();
|
|
1118
1187
|
offMembers?.();
|
|
1119
1188
|
offAdmission?.();
|
|
1120
1189
|
// Stop streaming process output into a dead socket. The PROCESSES keep
|
|
@@ -1235,7 +1304,7 @@ export class SidecarServer {
|
|
|
1235
1304
|
}
|
|
1236
1305
|
// A chat turn (Harness Chat Plan 2) takes its own path: the daemon's chat port, never runner.run.
|
|
1237
1306
|
if (m.chat !== undefined) {
|
|
1238
|
-
this.onChatRun(m);
|
|
1307
|
+
void this.onChatRun(m);
|
|
1239
1308
|
return;
|
|
1240
1309
|
}
|
|
1241
1310
|
void this.onRun(m);
|
|
@@ -1318,10 +1387,25 @@ export class SidecarServer {
|
|
|
1318
1387
|
// answer is dropped silently rather than forwarded into the registry.
|
|
1319
1388
|
if (!this.authed)
|
|
1320
1389
|
return;
|
|
1390
|
+
// A connection that left attendance is shown no asks, so answers none.
|
|
1391
|
+
if (this.leftAttendance)
|
|
1392
|
+
return;
|
|
1321
1393
|
// The note is the client's text: cleaned HERE, at the daemon's edge,
|
|
1322
1394
|
// and only a deny carries one.
|
|
1323
1395
|
this.opts.attendance?.answer(m.id, m.decision, this.attended, m.decision === 'deny' ? denialNote(m.note) : undefined);
|
|
1324
1396
|
return;
|
|
1397
|
+
case 'attendance-leave':
|
|
1398
|
+
/* A client keeping this connection open only for its own calls (a
|
|
1399
|
+
* browser that selected another machine but still has work running
|
|
1400
|
+
* on this one). Detached now, so an ask it would never show falls to
|
|
1401
|
+
* the unattended policy as if it had disconnected, instead of
|
|
1402
|
+
* waiting on it. Auth-gated like permission-answer; no reply. */
|
|
1403
|
+
if (!this.authed)
|
|
1404
|
+
return;
|
|
1405
|
+
this.leftAttendance = true;
|
|
1406
|
+
this.attendedDetach?.();
|
|
1407
|
+
this.attendedDetach = undefined;
|
|
1408
|
+
return;
|
|
1325
1409
|
case 'roots-browse':
|
|
1326
1410
|
// Auth-gated like every other request: directory enumeration must never
|
|
1327
1411
|
// precede authentication. Failures answer ok:false rather than throwing
|
|
@@ -1540,6 +1624,39 @@ export class SidecarServer {
|
|
|
1540
1624
|
}
|
|
1541
1625
|
void this.onCrewDeploy(m);
|
|
1542
1626
|
return;
|
|
1627
|
+
case 'fleet-items':
|
|
1628
|
+
if (!this.authed) {
|
|
1629
|
+
this.ch.send({ t: 'fleet-items-result', id: m.id, reply: { ok: false, error: 'not authenticated' } });
|
|
1630
|
+
return;
|
|
1631
|
+
}
|
|
1632
|
+
void this.onFleetItems(m);
|
|
1633
|
+
return;
|
|
1634
|
+
case 'fleet-skill-catalog':
|
|
1635
|
+
if (!this.authed) {
|
|
1636
|
+
this.ch.send({ t: 'fleet-skill-catalog-result', id: m.id, reply: { ok: false, error: 'not authenticated' } });
|
|
1637
|
+
return;
|
|
1638
|
+
}
|
|
1639
|
+
void this.onFleetSkillCatalog(m);
|
|
1640
|
+
return;
|
|
1641
|
+
case 'fleet-workflow-catalog':
|
|
1642
|
+
if (!this.authed) {
|
|
1643
|
+
this.ch.send({ t: 'fleet-workflow-catalog-result', id: m.id, reply: { ok: false, error: 'not authenticated' } });
|
|
1644
|
+
return;
|
|
1645
|
+
}
|
|
1646
|
+
void this.onFleetWorkflowCatalog(m);
|
|
1647
|
+
return;
|
|
1648
|
+
case 'fleet-workflow-call':
|
|
1649
|
+
if (!this.authed) {
|
|
1650
|
+
this.ch.send({ t: 'fleet-workflow-call-result', id: m.id, reply: { ok: false, error: 'not authenticated' } });
|
|
1651
|
+
return;
|
|
1652
|
+
}
|
|
1653
|
+
void this.onFleetWorkflowCall(m);
|
|
1654
|
+
return;
|
|
1655
|
+
case 'fleet-workflow-cancel':
|
|
1656
|
+
// No reply exists; an id this connection has no open call under is ignored.
|
|
1657
|
+
if (typeof m.id === 'number')
|
|
1658
|
+
this.fleetCalls.get(m.id)?.abort();
|
|
1659
|
+
return;
|
|
1543
1660
|
case 'workflow-package-put':
|
|
1544
1661
|
case 'workflow-results-list':
|
|
1545
1662
|
case 'workflow-results-take':
|
|
@@ -1736,6 +1853,7 @@ export class SidecarServer {
|
|
|
1736
1853
|
case 'mcp-remove':
|
|
1737
1854
|
case 'mcp-set-credential':
|
|
1738
1855
|
case 'mcp-login':
|
|
1856
|
+
case 'mcp-allow-set':
|
|
1739
1857
|
if (!this.authed) {
|
|
1740
1858
|
this.ch.send({ t: 'mcp-admin-error', id: m.id, error: 'not authenticated' });
|
|
1741
1859
|
return;
|
|
@@ -2467,7 +2585,8 @@ export class SidecarServer {
|
|
|
2467
2585
|
this.askSeat = seat;
|
|
2468
2586
|
this.askSeatLeave = this.askUsers().join(seat);
|
|
2469
2587
|
}
|
|
2470
|
-
|
|
2588
|
+
// A leave that arrived while this hello was still being checked holds.
|
|
2589
|
+
if (this.opts.attendance && !isPeerDaemon && !this.leftAttendance) {
|
|
2471
2590
|
/* A pending member (Part E) is never a recipient of anything: every
|
|
2472
2591
|
* send below checks the status at the moment it is made, so a member
|
|
2473
2592
|
* approved later starts receiving without reconnecting. */
|
|
@@ -2710,6 +2829,10 @@ export class SidecarServer {
|
|
|
2710
2829
|
// watchers of the parent run (the running-pills card). Only with a live
|
|
2711
2830
|
// hub behind it -- there is otherwise nothing to fan out from.
|
|
2712
2831
|
...(this.opts.runs ? [CHILD_EVENTS_FEATURE] : []),
|
|
2832
|
+
// Whether a write another client makes reaches this one as fs-changed.
|
|
2833
|
+
...(this.opts.fsChanges ? [FS_CHANGED_FEATURE] : []),
|
|
2834
|
+
// A connection may stop attending while it stays open (attendance-leave).
|
|
2835
|
+
...(this.opts.attendance ? [ATTENDANCE_LEAVE_FEATURE] : []),
|
|
2713
2836
|
...(this.opts.acp ? ['acp'] : []),
|
|
2714
2837
|
...(this.opts.roots?.areas.length ? ['add-roots'] : []),
|
|
2715
2838
|
...(this.opts.roots?.areas.length && this.opts.roots.create ? ['create-roots'] : []),
|
|
@@ -2739,6 +2862,9 @@ export class SidecarServer {
|
|
|
2739
2862
|
// Undeploy only where the daemon would record them -- anywhere else a
|
|
2740
2863
|
// client shows the old read-only view.
|
|
2741
2864
|
...(this.opts.crewDeploy ? [CREW_DEPLOY_FEATURE] : []),
|
|
2865
|
+
// Whether this sidecar lists its fleet items. Only with a real port.
|
|
2866
|
+
...(this.opts.fleetItems ? [FLEET_ITEMS_FEATURE] : []),
|
|
2867
|
+
...(this.opts.fleetUse ? [FLEET_USE_FEATURE] : []),
|
|
2742
2868
|
// Whether this daemon runs deployed workflows. Only with a real port, so
|
|
2743
2869
|
// a client offers the Sidecar deploy target only where it would run.
|
|
2744
2870
|
...(this.opts.workflows ? [WORKFLOW_RUNNER_FEATURE] : []),
|
|
@@ -2771,6 +2897,7 @@ export class SidecarServer {
|
|
|
2771
2897
|
// gives regardless of this flag.
|
|
2772
2898
|
...(this.opts.mcp ? ['mcp'] : []),
|
|
2773
2899
|
...(this.opts.mcpAdmin ? ['mcp-admin'] : []),
|
|
2900
|
+
...(this.opts.mcpAdmin?.setAllow ? [MCP_ALLOW_FEATURE] : []),
|
|
2774
2901
|
// Whether this daemon serves its own Harness Config. Advertised only with
|
|
2775
2902
|
// a real (allowlisted) service behind it, so a client never shows a
|
|
2776
2903
|
// daemon's definitions as an empty workspace when what it actually has is
|
|
@@ -3165,6 +3292,9 @@ export class SidecarServer {
|
|
|
3165
3292
|
}
|
|
3166
3293
|
}
|
|
3167
3294
|
this.ch.send({ t: 'invoke-result', id: m.id, ok: true, result });
|
|
3295
|
+
// After the answer, and only for a change that happened.
|
|
3296
|
+
if (FS_CHANGE_TOOLS.has(m.name))
|
|
3297
|
+
this.publishFsChange(m.args);
|
|
3168
3298
|
}
|
|
3169
3299
|
catch (e) {
|
|
3170
3300
|
// A read that finds nothing there (ENOENT) answered the question -- the
|
|
@@ -3176,6 +3306,31 @@ export class SidecarServer {
|
|
|
3176
3306
|
this.ch.send({ t: 'invoke-result', id: m.id, ok: false, error: errMessage(e) });
|
|
3177
3307
|
}
|
|
3178
3308
|
}
|
|
3309
|
+
/** Announce a file tool's change to the daemon's other clients. The root is
|
|
3310
|
+
* the one the tool resolved against, spelled as the daemon reports its
|
|
3311
|
+
* roots (that is what every client names it by), and the path is relative
|
|
3312
|
+
* to it, resolved as the tool confined it (fsChangesFor). Never throws:
|
|
3313
|
+
* the change already happened and its caller already has its answer. */
|
|
3314
|
+
publishFsChange(args) {
|
|
3315
|
+
const bus = this.opts.fsChanges;
|
|
3316
|
+
if (!bus)
|
|
3317
|
+
return;
|
|
3318
|
+
try {
|
|
3319
|
+
const path = typeof args.path === 'string' ? args.path : '';
|
|
3320
|
+
if (!path)
|
|
3321
|
+
return;
|
|
3322
|
+
const snap = this.opts.roots?.snapshot();
|
|
3323
|
+
const info = this.opts.info;
|
|
3324
|
+
const roots = snap?.roots ?? info?.roots ?? (info?.root ? [info.root] : []);
|
|
3325
|
+
const primary = snap?.root ?? info?.root ?? roots[0];
|
|
3326
|
+
// Where the tool actually wrote (a `../` or absolute path can land in another root).
|
|
3327
|
+
for (const change of fsChangesFor(roots, primary, args.root, path))
|
|
3328
|
+
bus.publish({ ...change, by: this.fsConnectionId });
|
|
3329
|
+
}
|
|
3330
|
+
catch (err) {
|
|
3331
|
+
this.opts.log?.(`fs change not announced: ${errMessage(err)}`);
|
|
3332
|
+
}
|
|
3333
|
+
}
|
|
3179
3334
|
/** Runs a crew agent via the configured `runner` and streams its lifecycle back
|
|
3180
3335
|
* to the host: `run-accepted` (with the daemon-assigned runId) once the runner
|
|
3181
3336
|
* has started, one `event` per `onEvent` callback, then exactly one of
|
|
@@ -3363,6 +3518,11 @@ export class SidecarServer {
|
|
|
3363
3518
|
*/
|
|
3364
3519
|
async onRunHistory(m) {
|
|
3365
3520
|
try {
|
|
3521
|
+
const denied = await this.hostFolderDenial();
|
|
3522
|
+
if (denied !== null) {
|
|
3523
|
+
this.ch.send({ t: 'run-history-result', id: m.id, runs: [], error: denied });
|
|
3524
|
+
return;
|
|
3525
|
+
}
|
|
3366
3526
|
const history = this.opts.runner?.history?.bind(this.opts.runner);
|
|
3367
3527
|
const crew = runHistoryCrewOf(m.crew);
|
|
3368
3528
|
const identity = { clientId: this.clientId };
|
|
@@ -3385,6 +3545,11 @@ export class SidecarServer {
|
|
|
3385
3545
|
* case, and a state the caller renders rather than treats as a failure. */
|
|
3386
3546
|
async onRunTranscript(m) {
|
|
3387
3547
|
try {
|
|
3548
|
+
const denied = await this.hostFolderDenial();
|
|
3549
|
+
if (denied !== null) {
|
|
3550
|
+
this.ch.send({ t: 'run-transcript-result', id: m.id, lines: [], error: denied });
|
|
3551
|
+
return;
|
|
3552
|
+
}
|
|
3388
3553
|
const transcript = this.opts.runner?.transcript?.bind(this.opts.runner);
|
|
3389
3554
|
const identity = { clientId: this.clientId };
|
|
3390
3555
|
const lines = !transcript ? []
|
|
@@ -3403,6 +3568,11 @@ export class SidecarServer {
|
|
|
3403
3568
|
* a path. */
|
|
3404
3569
|
async onCrewMemory(m) {
|
|
3405
3570
|
try {
|
|
3571
|
+
const denied = await this.hostFolderDenial();
|
|
3572
|
+
if (denied !== null) {
|
|
3573
|
+
this.ch.send({ t: 'crew-memory-result', id: m.id, memories: [], error: denied });
|
|
3574
|
+
return;
|
|
3575
|
+
}
|
|
3406
3576
|
const runner = this.opts.runner;
|
|
3407
3577
|
// The viewer is THIS connection's verified user (team agent access).
|
|
3408
3578
|
const memories = !runner?.memories ? []
|
|
@@ -3430,6 +3600,13 @@ export class SidecarServer {
|
|
|
3430
3600
|
return;
|
|
3431
3601
|
}
|
|
3432
3602
|
}
|
|
3603
|
+
// The connection's host folder is the run's owner root (and the `via` of a daemon-crew run): it speaks only
|
|
3604
|
+
// for its bound verified user (hostFolderDenial).
|
|
3605
|
+
const folderDenied = await this.hostFolderDenial();
|
|
3606
|
+
if (folderDenied !== null) {
|
|
3607
|
+
this.ch.send({ t: 'run-error', id: m.id, error: folderDenied });
|
|
3608
|
+
return;
|
|
3609
|
+
}
|
|
3433
3610
|
/* WHERE the run happens, decided once, up front.
|
|
3434
3611
|
*
|
|
3435
3612
|
* A `sessionId` is a confinement request. A daemon that cannot honour it
|
|
@@ -3543,7 +3720,7 @@ export class SidecarServer {
|
|
|
3543
3720
|
* verified user and never a message field; with none it is refused. The
|
|
3544
3721
|
* events, `run-accepted` (with the conversation id), `run-done` and the hub
|
|
3545
3722
|
* publishing follow `onRun`. A refused or failed turn sends `run-error`. */
|
|
3546
|
-
onChatRun(m) {
|
|
3723
|
+
async onChatRun(m) {
|
|
3547
3724
|
const refuse = (error) => { this.ch.send({ t: 'run-error', id: m.id, error }); };
|
|
3548
3725
|
const port = this.opts.runner?.chat;
|
|
3549
3726
|
if (!port) {
|
|
@@ -3572,6 +3749,15 @@ export class SidecarServer {
|
|
|
3572
3749
|
refuse(spec);
|
|
3573
3750
|
return;
|
|
3574
3751
|
}
|
|
3752
|
+
// Team mode only: the chat's identity is this connection's host folder (hostFolderDenial). Solo mode reaches
|
|
3753
|
+
// the port without waiting, as before.
|
|
3754
|
+
if (this.opts.teamMode) {
|
|
3755
|
+
const denied = await this.hostFolderDenial();
|
|
3756
|
+
if (denied !== null) {
|
|
3757
|
+
refuse(denied);
|
|
3758
|
+
return;
|
|
3759
|
+
}
|
|
3760
|
+
}
|
|
3575
3761
|
const agent = m.agent;
|
|
3576
3762
|
const hub = this.opts.runs;
|
|
3577
3763
|
let runId;
|
|
@@ -3712,6 +3898,11 @@ export class SidecarServer {
|
|
|
3712
3898
|
}
|
|
3713
3899
|
const { port, ...rest } = q;
|
|
3714
3900
|
try {
|
|
3901
|
+
const denied = await this.hostFolderDenial();
|
|
3902
|
+
if (denied !== null) {
|
|
3903
|
+
this.ch.send({ t: 'chat-list-result', id: m.id, ok: false, error: denied });
|
|
3904
|
+
return;
|
|
3905
|
+
}
|
|
3715
3906
|
const chats = await port.list({ ...rest, identity: { clientId: this.clientId } });
|
|
3716
3907
|
this.ch.send({ t: 'chat-list-result', id: m.id, ok: true, chats: chats.map(chatSummaryOf) });
|
|
3717
3908
|
}
|
|
@@ -3735,6 +3926,11 @@ export class SidecarServer {
|
|
|
3735
3926
|
}
|
|
3736
3927
|
const { port, ...rest } = q;
|
|
3737
3928
|
try {
|
|
3929
|
+
const denied = await this.hostFolderDenial();
|
|
3930
|
+
if (denied !== null) {
|
|
3931
|
+
refuse(denied);
|
|
3932
|
+
return;
|
|
3933
|
+
}
|
|
3738
3934
|
const conversation = await port.get({ ...rest, identity: { clientId: this.clientId }, conversationId: m.conversationId });
|
|
3739
3935
|
this.ch.send({ t: 'chat-get-result', id: m.id, ok: true, conversation: storedChatOf(conversation) });
|
|
3740
3936
|
}
|
|
@@ -4044,6 +4240,127 @@ export class SidecarServer {
|
|
|
4044
4240
|
...(this.verifiedUserId !== undefined ? { verifiedUserId: this.verifiedUserId } : {}),
|
|
4045
4241
|
};
|
|
4046
4242
|
}
|
|
4243
|
+
async onFleetItems(m) {
|
|
4244
|
+
if (typeof m.id !== 'number' || !Number.isSafeInteger(m.id))
|
|
4245
|
+
return;
|
|
4246
|
+
const answer = (reply) => this.ch.send({ t: 'fleet-items-result', id: m.id, reply });
|
|
4247
|
+
const port = this.opts.fleetItems;
|
|
4248
|
+
if (!port) {
|
|
4249
|
+
answer({ ok: false, error: 'this sidecar is not in a fleet' });
|
|
4250
|
+
return;
|
|
4251
|
+
}
|
|
4252
|
+
try {
|
|
4253
|
+
answer(await port.list(this.verifiedUserId ?? undefined));
|
|
4254
|
+
}
|
|
4255
|
+
catch (err) {
|
|
4256
|
+
answer({ ok: false, error: errMessage(err) });
|
|
4257
|
+
}
|
|
4258
|
+
}
|
|
4259
|
+
/** The caller a fleet workflow call runs as, from THIS connection only, or the refusal. Never the verified user
|
|
4260
|
+
* id (it would reach the workflow's prompts).
|
|
4261
|
+
*
|
|
4262
|
+
* Team mode: `host:<dirName>` only when that folder is bound to this connection's verified user (hostFolderDenial,
|
|
4263
|
+
* the rule the workflow-* messages use): the folder comes from the hello's clientId, which is not secret, so an
|
|
4264
|
+
* unbound name is refused with that check's denial. Solo mode: `host:<dirName>` for a
|
|
4265
|
+
* verified user whose folder was resolved, else `host:owner`. */
|
|
4266
|
+
async fleetCaller() {
|
|
4267
|
+
if (this.opts.teamMode) {
|
|
4268
|
+
if (this.verifiedUserId !== undefined && this.dirName === undefined)
|
|
4269
|
+
return { error: FLEET_CALLER_NO_FOLDER };
|
|
4270
|
+
const denied = await this.hostFolderDenial();
|
|
4271
|
+
if (denied !== null || this.dirName === undefined)
|
|
4272
|
+
return { error: denied ?? FLEET_CALLER_NO_FOLDER };
|
|
4273
|
+
return { caller: { name: `host:${this.dirName}`, trigger: 'host' } };
|
|
4274
|
+
}
|
|
4275
|
+
if (this.verifiedUserId !== undefined && this.dirName !== undefined)
|
|
4276
|
+
return { caller: { name: `host:${this.dirName}`, trigger: 'host' } };
|
|
4277
|
+
return { caller: { name: 'host:owner', trigger: 'host' } };
|
|
4278
|
+
}
|
|
4279
|
+
async onFleetSkillCatalog(m) {
|
|
4280
|
+
if (typeof m.id !== 'number' || !Number.isSafeInteger(m.id))
|
|
4281
|
+
return;
|
|
4282
|
+
const answer = (reply) => this.ch.send({ t: 'fleet-skill-catalog-result', id: m.id, reply });
|
|
4283
|
+
const port = this.opts.fleetUse;
|
|
4284
|
+
if (!port) {
|
|
4285
|
+
answer({ ok: false, error: 'this sidecar is not in a fleet' });
|
|
4286
|
+
return;
|
|
4287
|
+
}
|
|
4288
|
+
try {
|
|
4289
|
+
answer(await port.skills());
|
|
4290
|
+
}
|
|
4291
|
+
catch (err) {
|
|
4292
|
+
answer({ ok: false, error: errMessage(err) });
|
|
4293
|
+
}
|
|
4294
|
+
}
|
|
4295
|
+
async onFleetWorkflowCatalog(m) {
|
|
4296
|
+
if (typeof m.id !== 'number' || !Number.isSafeInteger(m.id))
|
|
4297
|
+
return;
|
|
4298
|
+
const answer = (reply) => this.ch.send({ t: 'fleet-workflow-catalog-result', id: m.id, reply });
|
|
4299
|
+
const port = this.opts.fleetUse;
|
|
4300
|
+
if (!port) {
|
|
4301
|
+
answer({ ok: false, error: 'this sidecar is not in a fleet' });
|
|
4302
|
+
return;
|
|
4303
|
+
}
|
|
4304
|
+
try {
|
|
4305
|
+
answer(await port.workflows());
|
|
4306
|
+
}
|
|
4307
|
+
catch (err) {
|
|
4308
|
+
answer({ ok: false, error: errMessage(err) });
|
|
4309
|
+
}
|
|
4310
|
+
}
|
|
4311
|
+
async onFleetWorkflowCall(m) {
|
|
4312
|
+
// A frame with no usable correlation id is dropped before the port: nothing runs that its sender could
|
|
4313
|
+
// never match to an answer or cancel.
|
|
4314
|
+
if (typeof m.id !== 'number' || !Number.isSafeInteger(m.id))
|
|
4315
|
+
return;
|
|
4316
|
+
const id = m.id;
|
|
4317
|
+
const answer = (reply) => {
|
|
4318
|
+
if (!this.closed)
|
|
4319
|
+
this.ch.send({ t: 'fleet-workflow-call-result', id, reply });
|
|
4320
|
+
};
|
|
4321
|
+
const port = this.opts.fleetUse;
|
|
4322
|
+
if (!port) {
|
|
4323
|
+
answer({ ok: false, error: 'this sidecar is not in a fleet' });
|
|
4324
|
+
return;
|
|
4325
|
+
}
|
|
4326
|
+
// Runtime-typed: the message arrived via JSON.parse(...) as SidecarMsg.
|
|
4327
|
+
const name = m.name;
|
|
4328
|
+
if (typeof name !== 'string' || name.length === 0) {
|
|
4329
|
+
answer({ ok: false, error: 'a fleet workflow call needs a workflow name' });
|
|
4330
|
+
return;
|
|
4331
|
+
}
|
|
4332
|
+
const inputs = m.inputs;
|
|
4333
|
+
if (!isWorkflowCallInputs(inputs)) {
|
|
4334
|
+
answer({ ok: false, error: FLEET_WORKFLOW_INPUTS_ERROR });
|
|
4335
|
+
return;
|
|
4336
|
+
}
|
|
4337
|
+
if (this.fleetCalls.has(id)) {
|
|
4338
|
+
answer({ ok: false, error: 'a fleet workflow call with that id is already open' });
|
|
4339
|
+
return;
|
|
4340
|
+
}
|
|
4341
|
+
if (this.fleetCalls.size >= FLEET_CALLS_OPEN_MAX) {
|
|
4342
|
+
answer({ ok: false, error: FLEET_CALLS_TOO_MANY });
|
|
4343
|
+
return;
|
|
4344
|
+
}
|
|
4345
|
+
// The id is held before the first await, so a second call under it, or a cancel, sees it.
|
|
4346
|
+
const control = new AbortController();
|
|
4347
|
+
this.fleetCalls.set(id, control);
|
|
4348
|
+
try {
|
|
4349
|
+
const named = await this.fleetCaller();
|
|
4350
|
+
if ('error' in named) {
|
|
4351
|
+
answer({ ok: false, error: named.error });
|
|
4352
|
+
return;
|
|
4353
|
+
}
|
|
4354
|
+
answer(await port.call(named.caller, name, inputs, control.signal));
|
|
4355
|
+
}
|
|
4356
|
+
catch (err) {
|
|
4357
|
+
answer({ ok: false, error: errMessage(err) });
|
|
4358
|
+
}
|
|
4359
|
+
finally {
|
|
4360
|
+
if (this.fleetCalls.get(id) === control)
|
|
4361
|
+
this.fleetCalls.delete(id);
|
|
4362
|
+
}
|
|
4363
|
+
}
|
|
4047
4364
|
async onCrewDeploy(m) {
|
|
4048
4365
|
// A frame with no usable correlation id is dropped before the port: no
|
|
4049
4366
|
// change is made that its sender could never match to an answer.
|
|
@@ -4085,25 +4402,49 @@ export class SidecarServer {
|
|
|
4085
4402
|
answer({ ok: false, error: errMessage(err) });
|
|
4086
4403
|
}
|
|
4087
4404
|
}
|
|
4088
|
-
/**
|
|
4089
|
-
*
|
|
4090
|
-
*
|
|
4091
|
-
|
|
4405
|
+
/** THE ONE RULE for whether this connection's synced folder speaks for it: 'bound' only when the folder's
|
|
4406
|
+
* bound owner (the deploy binding, `workflowsFolderOwner`) is this connection's verified user. A folder the
|
|
4407
|
+
* daemon did not resolve, no lookup, no binding, or a lookup that throws is 'unbound'. Every team-mode check of
|
|
4408
|
+
* a host folder (workflowsTeamDenial, hostFolderDenial) reads this and only words it differently. */
|
|
4409
|
+
async hostFolderStanding() {
|
|
4092
4410
|
if (this.verifiedUserId === undefined)
|
|
4093
|
-
return
|
|
4411
|
+
return 'unverified';
|
|
4094
4412
|
const lookup = this.opts.workflowsFolderOwner;
|
|
4095
4413
|
if (this.dirName === undefined || !lookup)
|
|
4096
|
-
return
|
|
4414
|
+
return 'unbound';
|
|
4097
4415
|
let bound;
|
|
4098
4416
|
try {
|
|
4099
4417
|
bound = await lookup(this.dirName);
|
|
4100
4418
|
}
|
|
4101
4419
|
catch {
|
|
4102
|
-
return
|
|
4420
|
+
return 'unbound';
|
|
4103
4421
|
}
|
|
4104
4422
|
if (bound === undefined)
|
|
4105
|
-
return
|
|
4106
|
-
return bound === this.verifiedUserId ?
|
|
4423
|
+
return 'unbound';
|
|
4424
|
+
return bound === this.verifiedUserId ? 'bound' : 'not-owner';
|
|
4425
|
+
}
|
|
4426
|
+
/** Team mode: why this connection may NOT use its synced folder's workflow
|
|
4427
|
+
* packages and results, or null when it may (hostFolderStanding). */
|
|
4428
|
+
async workflowsTeamDenial() {
|
|
4429
|
+
switch (await this.hostFolderStanding()) {
|
|
4430
|
+
case 'bound': return null;
|
|
4431
|
+
case 'unverified': return WORKFLOWS_VERIFY_FIRST;
|
|
4432
|
+
case 'unbound': return WORKFLOWS_FOLDER_UNBOUND;
|
|
4433
|
+
case 'not-owner': return WORKFLOWS_NOT_FOLDER_OWNER;
|
|
4434
|
+
}
|
|
4435
|
+
}
|
|
4436
|
+
/** Why this connection's host folder may NOT speak for it in a run, a chat, a history, transcript or memory
|
|
4437
|
+
* read, a fleet workflow call, or a host-sync write, or null when it may. Solo mode: always null (unchanged). Team mode:
|
|
4438
|
+
* hostFolderStanding, the rule the workflow-* messages use. */
|
|
4439
|
+
async hostFolderDenial() {
|
|
4440
|
+
if (!this.opts.teamMode)
|
|
4441
|
+
return null;
|
|
4442
|
+
switch (await this.hostFolderStanding()) {
|
|
4443
|
+
case 'bound': return null;
|
|
4444
|
+
case 'unverified': return HOST_FOLDER_VERIFY_FIRST;
|
|
4445
|
+
case 'unbound': return HOST_FOLDER_UNBOUND;
|
|
4446
|
+
case 'not-owner': return HOST_FOLDER_NOT_OWNER;
|
|
4447
|
+
}
|
|
4107
4448
|
}
|
|
4108
4449
|
async onWorkflows(m) {
|
|
4109
4450
|
// A frame with no usable correlation id is dropped before the port: no
|
|
@@ -4873,7 +5214,7 @@ export class SidecarServer {
|
|
|
4873
5214
|
if (m.t !== 'mcp-servers-list') {
|
|
4874
5215
|
const refusal = this.mcpRefusal();
|
|
4875
5216
|
if (refusal) {
|
|
4876
|
-
this.ch.send({ t: 'mcp-admin-error', id: m.id, error: refusal });
|
|
5217
|
+
this.ch.send({ t: 'mcp-admin-error', id: m.id, error: refusal, code: 'MCP_ADMIN_ONLY' });
|
|
4877
5218
|
return;
|
|
4878
5219
|
}
|
|
4879
5220
|
}
|
|
@@ -4894,8 +5235,9 @@ export class SidecarServer {
|
|
|
4894
5235
|
* branch that is not part of it runs setCredential too, with whatever
|
|
4895
5236
|
* `m.header`/`m.value` happen to be (undefined, for every other message).
|
|
4896
5237
|
* That is exactly what a stray `if` here did before this comment existed. */
|
|
5238
|
+
let applied;
|
|
4897
5239
|
if (m.t === 'mcp-add')
|
|
4898
|
-
await admin.addServer(m.name, m.url, m.hosted === true, m.authUrl);
|
|
5240
|
+
applied = await admin.addServer(m.name, m.url, m.hosted === true, m.authUrl);
|
|
4899
5241
|
else if (m.t === 'mcp-login') {
|
|
4900
5242
|
/* The consent URL is pushed the moment it exists, BEFORE the flow
|
|
4901
5243
|
* blocks waiting for a human. Holding it until the end would deliver it
|
|
@@ -4905,15 +5247,48 @@ export class SidecarServer {
|
|
|
4905
5247
|
this.ch.send({ t: 'mcp-admin-error', id: m.id, error: out.message, reason: out.reason });
|
|
4906
5248
|
return;
|
|
4907
5249
|
}
|
|
5250
|
+
applied = out.reloadError !== undefined ? { reloadError: out.reloadError } : undefined;
|
|
4908
5251
|
}
|
|
4909
5252
|
else if (m.t === 'mcp-remove')
|
|
4910
|
-
await admin.removeServer(m.name);
|
|
5253
|
+
applied = await admin.removeServer(m.name);
|
|
5254
|
+
else if (m.t === 'mcp-allow-set') {
|
|
5255
|
+
if (!admin.setAllow) {
|
|
5256
|
+
this.ch.send({ t: 'mcp-admin-error', id: m.id, error: 'this daemon cannot switch MCP tools -- update it and reconnect' });
|
|
5257
|
+
return;
|
|
5258
|
+
}
|
|
5259
|
+
// The shape is checked here, before the port: the message is a
|
|
5260
|
+
// browser's, and a non-string entry must never reach daemon.json.
|
|
5261
|
+
if (typeof m.name !== 'string' || !m.name || !Array.isArray(m.tools) || !m.tools.every((t) => typeof t === 'string')) {
|
|
5262
|
+
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.' });
|
|
5263
|
+
return;
|
|
5264
|
+
}
|
|
5265
|
+
applied = await admin.setAllow(m.name, m.tools);
|
|
5266
|
+
}
|
|
4911
5267
|
else
|
|
4912
|
-
await admin.setCredential(m.name, m.header, m.value);
|
|
4913
|
-
|
|
5268
|
+
applied = await admin.setCredential(m.name, m.header, m.value);
|
|
5269
|
+
const reloadError = applied ? applied.reloadError : undefined;
|
|
5270
|
+
/* A switch answers with the rows as they now stand, so the browser shows
|
|
5271
|
+
* what the daemon allows (a glob may keep a tool on), not what it asked
|
|
5272
|
+
* for. Only the admin reaches here (mcpRefusal above), so the full rows.
|
|
5273
|
+
* Best effort: the write is saved either way. */
|
|
5274
|
+
let servers;
|
|
5275
|
+
if (m.t === 'mcp-allow-set') {
|
|
5276
|
+
try {
|
|
5277
|
+
servers = await admin.listServers();
|
|
5278
|
+
}
|
|
5279
|
+
catch {
|
|
5280
|
+
servers = undefined;
|
|
5281
|
+
}
|
|
5282
|
+
}
|
|
5283
|
+
this.ch.send({
|
|
5284
|
+
t: 'mcp-admin-ok', id: m.id,
|
|
5285
|
+
...(typeof reloadError === 'string' ? { reloadError } : {}),
|
|
5286
|
+
...(servers ? { servers } : {}),
|
|
5287
|
+
});
|
|
4914
5288
|
}
|
|
4915
5289
|
catch (e) {
|
|
4916
|
-
|
|
5290
|
+
const code = mcpAdminCodeOf(e);
|
|
5291
|
+
this.ch.send({ t: 'mcp-admin-error', id: m.id, error: e instanceof Error ? e.message : String(e), ...(code ? { code } : {}) });
|
|
4917
5292
|
}
|
|
4918
5293
|
}
|
|
4919
5294
|
/** Part D credential messages. The caller is this connection's VERIFIED
|
|
@@ -5909,6 +6284,13 @@ export class SidecarServer {
|
|
|
5909
6284
|
this.ch.send({ t: 'host-sync-result', id: m.id, ok: false, error: 'this sidecar cannot sync host config' });
|
|
5910
6285
|
return;
|
|
5911
6286
|
}
|
|
6287
|
+
// Team mode: the clientId names the folder written to, and it is not secret, so it writes only for the
|
|
6288
|
+
// verified user that folder is bound to (hostFolderDenial). Solo mode is unchanged.
|
|
6289
|
+
const denied = await this.hostFolderDenial();
|
|
6290
|
+
if (denied !== null) {
|
|
6291
|
+
this.ch.send({ t: 'host-sync-result', id: m.id, ok: false, error: denied });
|
|
6292
|
+
return;
|
|
6293
|
+
}
|
|
5912
6294
|
try {
|
|
5913
6295
|
await this.opts.hostSync.put(identity.clientId, m.path, m.content);
|
|
5914
6296
|
this.ch.send({ t: 'host-sync-result', id: m.id, ok: true });
|
|
@@ -5927,6 +6309,13 @@ export class SidecarServer {
|
|
|
5927
6309
|
this.ch.send({ t: 'host-sync-result', id: m.id, ok: false, error: 'this sidecar cannot sync host config' });
|
|
5928
6310
|
return;
|
|
5929
6311
|
}
|
|
6312
|
+
// Team mode: the clientId names the folder written to, and it is not secret, so it writes only for the
|
|
6313
|
+
// verified user that folder is bound to (hostFolderDenial). Solo mode is unchanged.
|
|
6314
|
+
const denied = await this.hostFolderDenial();
|
|
6315
|
+
if (denied !== null) {
|
|
6316
|
+
this.ch.send({ t: 'host-sync-result', id: m.id, ok: false, error: denied });
|
|
6317
|
+
return;
|
|
6318
|
+
}
|
|
5930
6319
|
try {
|
|
5931
6320
|
await this.opts.hostSync.remove(identity.clientId, m.path);
|
|
5932
6321
|
this.ch.send({ t: 'host-sync-result', id: m.id, ok: true });
|