@corenel/harness 0.22.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 +3 -0
- package/dist/chat/engine.d.ts.map +1 -1
- package/dist/chat/engine.js +5 -3
- package/dist/chat/engine.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/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/sidecar/host.d.ts +49 -10
- package/dist/sidecar/host.d.ts.map +1 -1
- package/dist/sidecar/host.js +194 -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 +83 -6
- package/dist/sidecar/server.d.ts.map +1 -1
- package/dist/sidecar/server.js +348 -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.map +1 -1
- package/dist/tools/builtins.js +7 -0
- 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,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, 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';
|
|
@@ -12,6 +12,13 @@ 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
|
+
/** 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';
|
|
15
22
|
/** A deliberate refusal from the approvals port, written for the person who
|
|
16
23
|
* asked: its message goes on the wire as is. Recognized by `name`, not by
|
|
17
24
|
* `instanceof`, so a second copy of this module (a test alias, a bundled
|
|
@@ -34,6 +41,13 @@ export function isApprovalsRefusal(e) {
|
|
|
34
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';
|
|
35
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';
|
|
36
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';
|
|
37
51
|
export const APPROVALS_FAILED = 'Standing approvals could not be read or changed. The daemon log has the reason.';
|
|
38
52
|
export const APPROVALS_NO_USER = 'In team mode standing approvals need a verified user on this connection.';
|
|
39
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.';
|
|
@@ -115,6 +129,11 @@ const AGENT_CRED_RESERVED_SERVERS = new Set(['__proto__', 'constructor', 'protot
|
|
|
115
129
|
function logSafe(s) {
|
|
116
130
|
return s.replace(/[\x00-\x1f\x7f-\x9f]/g, '');
|
|
117
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
|
+
}
|
|
118
137
|
/** The code a thrown refusal carries, when it is one a reply may name. */
|
|
119
138
|
function agentCredCodeOf(err) {
|
|
120
139
|
const code = typeof err === 'object' && err !== null ? err.code : undefined;
|
|
@@ -787,6 +806,14 @@ export class SidecarServer {
|
|
|
787
806
|
writable: true,
|
|
788
807
|
value: void 0
|
|
789
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
|
+
});
|
|
790
817
|
/** This connection's seat for chat ask_user (verified connections only),
|
|
791
818
|
* and its leave function, called once on close. */
|
|
792
819
|
Object.defineProperty(this, "askSeat", {
|
|
@@ -876,6 +903,14 @@ export class SidecarServer {
|
|
|
876
903
|
writable: true,
|
|
877
904
|
value: new Set()
|
|
878
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
|
+
});
|
|
879
914
|
/** Processes THIS connection is watching. A shared process table means two
|
|
880
915
|
* browsers can be paired to one daemon, and a client that never asked about a
|
|
881
916
|
* process should not be streamed its output - so output is fanned out per
|
|
@@ -1142,6 +1177,10 @@ export class SidecarServer {
|
|
|
1142
1177
|
for (const detach of this.runDetachers)
|
|
1143
1178
|
detach();
|
|
1144
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();
|
|
1145
1184
|
offRoots?.();
|
|
1146
1185
|
offRooms?.();
|
|
1147
1186
|
offFsChanges?.();
|
|
@@ -1265,7 +1304,7 @@ export class SidecarServer {
|
|
|
1265
1304
|
}
|
|
1266
1305
|
// A chat turn (Harness Chat Plan 2) takes its own path: the daemon's chat port, never runner.run.
|
|
1267
1306
|
if (m.chat !== undefined) {
|
|
1268
|
-
this.onChatRun(m);
|
|
1307
|
+
void this.onChatRun(m);
|
|
1269
1308
|
return;
|
|
1270
1309
|
}
|
|
1271
1310
|
void this.onRun(m);
|
|
@@ -1348,10 +1387,25 @@ export class SidecarServer {
|
|
|
1348
1387
|
// answer is dropped silently rather than forwarded into the registry.
|
|
1349
1388
|
if (!this.authed)
|
|
1350
1389
|
return;
|
|
1390
|
+
// A connection that left attendance is shown no asks, so answers none.
|
|
1391
|
+
if (this.leftAttendance)
|
|
1392
|
+
return;
|
|
1351
1393
|
// The note is the client's text: cleaned HERE, at the daemon's edge,
|
|
1352
1394
|
// and only a deny carries one.
|
|
1353
1395
|
this.opts.attendance?.answer(m.id, m.decision, this.attended, m.decision === 'deny' ? denialNote(m.note) : undefined);
|
|
1354
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;
|
|
1355
1409
|
case 'roots-browse':
|
|
1356
1410
|
// Auth-gated like every other request: directory enumeration must never
|
|
1357
1411
|
// precede authentication. Failures answer ok:false rather than throwing
|
|
@@ -1570,6 +1624,39 @@ export class SidecarServer {
|
|
|
1570
1624
|
}
|
|
1571
1625
|
void this.onCrewDeploy(m);
|
|
1572
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;
|
|
1573
1660
|
case 'workflow-package-put':
|
|
1574
1661
|
case 'workflow-results-list':
|
|
1575
1662
|
case 'workflow-results-take':
|
|
@@ -1766,6 +1853,7 @@ export class SidecarServer {
|
|
|
1766
1853
|
case 'mcp-remove':
|
|
1767
1854
|
case 'mcp-set-credential':
|
|
1768
1855
|
case 'mcp-login':
|
|
1856
|
+
case 'mcp-allow-set':
|
|
1769
1857
|
if (!this.authed) {
|
|
1770
1858
|
this.ch.send({ t: 'mcp-admin-error', id: m.id, error: 'not authenticated' });
|
|
1771
1859
|
return;
|
|
@@ -2497,7 +2585,8 @@ export class SidecarServer {
|
|
|
2497
2585
|
this.askSeat = seat;
|
|
2498
2586
|
this.askSeatLeave = this.askUsers().join(seat);
|
|
2499
2587
|
}
|
|
2500
|
-
|
|
2588
|
+
// A leave that arrived while this hello was still being checked holds.
|
|
2589
|
+
if (this.opts.attendance && !isPeerDaemon && !this.leftAttendance) {
|
|
2501
2590
|
/* A pending member (Part E) is never a recipient of anything: every
|
|
2502
2591
|
* send below checks the status at the moment it is made, so a member
|
|
2503
2592
|
* approved later starts receiving without reconnecting. */
|
|
@@ -2742,6 +2831,8 @@ export class SidecarServer {
|
|
|
2742
2831
|
...(this.opts.runs ? [CHILD_EVENTS_FEATURE] : []),
|
|
2743
2832
|
// Whether a write another client makes reaches this one as fs-changed.
|
|
2744
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] : []),
|
|
2745
2836
|
...(this.opts.acp ? ['acp'] : []),
|
|
2746
2837
|
...(this.opts.roots?.areas.length ? ['add-roots'] : []),
|
|
2747
2838
|
...(this.opts.roots?.areas.length && this.opts.roots.create ? ['create-roots'] : []),
|
|
@@ -2771,6 +2862,9 @@ export class SidecarServer {
|
|
|
2771
2862
|
// Undeploy only where the daemon would record them -- anywhere else a
|
|
2772
2863
|
// client shows the old read-only view.
|
|
2773
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] : []),
|
|
2774
2868
|
// Whether this daemon runs deployed workflows. Only with a real port, so
|
|
2775
2869
|
// a client offers the Sidecar deploy target only where it would run.
|
|
2776
2870
|
...(this.opts.workflows ? [WORKFLOW_RUNNER_FEATURE] : []),
|
|
@@ -2803,6 +2897,7 @@ export class SidecarServer {
|
|
|
2803
2897
|
// gives regardless of this flag.
|
|
2804
2898
|
...(this.opts.mcp ? ['mcp'] : []),
|
|
2805
2899
|
...(this.opts.mcpAdmin ? ['mcp-admin'] : []),
|
|
2900
|
+
...(this.opts.mcpAdmin?.setAllow ? [MCP_ALLOW_FEATURE] : []),
|
|
2806
2901
|
// Whether this daemon serves its own Harness Config. Advertised only with
|
|
2807
2902
|
// a real (allowlisted) service behind it, so a client never shows a
|
|
2808
2903
|
// daemon's definitions as an empty workspace when what it actually has is
|
|
@@ -3423,6 +3518,11 @@ export class SidecarServer {
|
|
|
3423
3518
|
*/
|
|
3424
3519
|
async onRunHistory(m) {
|
|
3425
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
|
+
}
|
|
3426
3526
|
const history = this.opts.runner?.history?.bind(this.opts.runner);
|
|
3427
3527
|
const crew = runHistoryCrewOf(m.crew);
|
|
3428
3528
|
const identity = { clientId: this.clientId };
|
|
@@ -3445,6 +3545,11 @@ export class SidecarServer {
|
|
|
3445
3545
|
* case, and a state the caller renders rather than treats as a failure. */
|
|
3446
3546
|
async onRunTranscript(m) {
|
|
3447
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
|
+
}
|
|
3448
3553
|
const transcript = this.opts.runner?.transcript?.bind(this.opts.runner);
|
|
3449
3554
|
const identity = { clientId: this.clientId };
|
|
3450
3555
|
const lines = !transcript ? []
|
|
@@ -3463,6 +3568,11 @@ export class SidecarServer {
|
|
|
3463
3568
|
* a path. */
|
|
3464
3569
|
async onCrewMemory(m) {
|
|
3465
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
|
+
}
|
|
3466
3576
|
const runner = this.opts.runner;
|
|
3467
3577
|
// The viewer is THIS connection's verified user (team agent access).
|
|
3468
3578
|
const memories = !runner?.memories ? []
|
|
@@ -3490,6 +3600,13 @@ export class SidecarServer {
|
|
|
3490
3600
|
return;
|
|
3491
3601
|
}
|
|
3492
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
|
+
}
|
|
3493
3610
|
/* WHERE the run happens, decided once, up front.
|
|
3494
3611
|
*
|
|
3495
3612
|
* A `sessionId` is a confinement request. A daemon that cannot honour it
|
|
@@ -3603,7 +3720,7 @@ export class SidecarServer {
|
|
|
3603
3720
|
* verified user and never a message field; with none it is refused. The
|
|
3604
3721
|
* events, `run-accepted` (with the conversation id), `run-done` and the hub
|
|
3605
3722
|
* publishing follow `onRun`. A refused or failed turn sends `run-error`. */
|
|
3606
|
-
onChatRun(m) {
|
|
3723
|
+
async onChatRun(m) {
|
|
3607
3724
|
const refuse = (error) => { this.ch.send({ t: 'run-error', id: m.id, error }); };
|
|
3608
3725
|
const port = this.opts.runner?.chat;
|
|
3609
3726
|
if (!port) {
|
|
@@ -3632,6 +3749,15 @@ export class SidecarServer {
|
|
|
3632
3749
|
refuse(spec);
|
|
3633
3750
|
return;
|
|
3634
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
|
+
}
|
|
3635
3761
|
const agent = m.agent;
|
|
3636
3762
|
const hub = this.opts.runs;
|
|
3637
3763
|
let runId;
|
|
@@ -3772,6 +3898,11 @@ export class SidecarServer {
|
|
|
3772
3898
|
}
|
|
3773
3899
|
const { port, ...rest } = q;
|
|
3774
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
|
+
}
|
|
3775
3906
|
const chats = await port.list({ ...rest, identity: { clientId: this.clientId } });
|
|
3776
3907
|
this.ch.send({ t: 'chat-list-result', id: m.id, ok: true, chats: chats.map(chatSummaryOf) });
|
|
3777
3908
|
}
|
|
@@ -3795,6 +3926,11 @@ export class SidecarServer {
|
|
|
3795
3926
|
}
|
|
3796
3927
|
const { port, ...rest } = q;
|
|
3797
3928
|
try {
|
|
3929
|
+
const denied = await this.hostFolderDenial();
|
|
3930
|
+
if (denied !== null) {
|
|
3931
|
+
refuse(denied);
|
|
3932
|
+
return;
|
|
3933
|
+
}
|
|
3798
3934
|
const conversation = await port.get({ ...rest, identity: { clientId: this.clientId }, conversationId: m.conversationId });
|
|
3799
3935
|
this.ch.send({ t: 'chat-get-result', id: m.id, ok: true, conversation: storedChatOf(conversation) });
|
|
3800
3936
|
}
|
|
@@ -4104,6 +4240,127 @@ export class SidecarServer {
|
|
|
4104
4240
|
...(this.verifiedUserId !== undefined ? { verifiedUserId: this.verifiedUserId } : {}),
|
|
4105
4241
|
};
|
|
4106
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
|
+
}
|
|
4107
4364
|
async onCrewDeploy(m) {
|
|
4108
4365
|
// A frame with no usable correlation id is dropped before the port: no
|
|
4109
4366
|
// change is made that its sender could never match to an answer.
|
|
@@ -4145,25 +4402,49 @@ export class SidecarServer {
|
|
|
4145
4402
|
answer({ ok: false, error: errMessage(err) });
|
|
4146
4403
|
}
|
|
4147
4404
|
}
|
|
4148
|
-
/**
|
|
4149
|
-
*
|
|
4150
|
-
*
|
|
4151
|
-
|
|
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() {
|
|
4152
4410
|
if (this.verifiedUserId === undefined)
|
|
4153
|
-
return
|
|
4411
|
+
return 'unverified';
|
|
4154
4412
|
const lookup = this.opts.workflowsFolderOwner;
|
|
4155
4413
|
if (this.dirName === undefined || !lookup)
|
|
4156
|
-
return
|
|
4414
|
+
return 'unbound';
|
|
4157
4415
|
let bound;
|
|
4158
4416
|
try {
|
|
4159
4417
|
bound = await lookup(this.dirName);
|
|
4160
4418
|
}
|
|
4161
4419
|
catch {
|
|
4162
|
-
return
|
|
4420
|
+
return 'unbound';
|
|
4163
4421
|
}
|
|
4164
4422
|
if (bound === undefined)
|
|
4165
|
-
return
|
|
4166
|
-
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
|
+
}
|
|
4167
4448
|
}
|
|
4168
4449
|
async onWorkflows(m) {
|
|
4169
4450
|
// A frame with no usable correlation id is dropped before the port: no
|
|
@@ -4933,7 +5214,7 @@ export class SidecarServer {
|
|
|
4933
5214
|
if (m.t !== 'mcp-servers-list') {
|
|
4934
5215
|
const refusal = this.mcpRefusal();
|
|
4935
5216
|
if (refusal) {
|
|
4936
|
-
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' });
|
|
4937
5218
|
return;
|
|
4938
5219
|
}
|
|
4939
5220
|
}
|
|
@@ -4954,8 +5235,9 @@ export class SidecarServer {
|
|
|
4954
5235
|
* branch that is not part of it runs setCredential too, with whatever
|
|
4955
5236
|
* `m.header`/`m.value` happen to be (undefined, for every other message).
|
|
4956
5237
|
* That is exactly what a stray `if` here did before this comment existed. */
|
|
5238
|
+
let applied;
|
|
4957
5239
|
if (m.t === 'mcp-add')
|
|
4958
|
-
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);
|
|
4959
5241
|
else if (m.t === 'mcp-login') {
|
|
4960
5242
|
/* The consent URL is pushed the moment it exists, BEFORE the flow
|
|
4961
5243
|
* blocks waiting for a human. Holding it until the end would deliver it
|
|
@@ -4965,15 +5247,48 @@ export class SidecarServer {
|
|
|
4965
5247
|
this.ch.send({ t: 'mcp-admin-error', id: m.id, error: out.message, reason: out.reason });
|
|
4966
5248
|
return;
|
|
4967
5249
|
}
|
|
5250
|
+
applied = out.reloadError !== undefined ? { reloadError: out.reloadError } : undefined;
|
|
4968
5251
|
}
|
|
4969
5252
|
else if (m.t === 'mcp-remove')
|
|
4970
|
-
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
|
+
}
|
|
4971
5267
|
else
|
|
4972
|
-
await admin.setCredential(m.name, m.header, m.value);
|
|
4973
|
-
|
|
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
|
+
});
|
|
4974
5288
|
}
|
|
4975
5289
|
catch (e) {
|
|
4976
|
-
|
|
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 } : {}) });
|
|
4977
5292
|
}
|
|
4978
5293
|
}
|
|
4979
5294
|
/** Part D credential messages. The caller is this connection's VERIFIED
|
|
@@ -5969,6 +6284,13 @@ export class SidecarServer {
|
|
|
5969
6284
|
this.ch.send({ t: 'host-sync-result', id: m.id, ok: false, error: 'this sidecar cannot sync host config' });
|
|
5970
6285
|
return;
|
|
5971
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
|
+
}
|
|
5972
6294
|
try {
|
|
5973
6295
|
await this.opts.hostSync.put(identity.clientId, m.path, m.content);
|
|
5974
6296
|
this.ch.send({ t: 'host-sync-result', id: m.id, ok: true });
|
|
@@ -5987,6 +6309,13 @@ export class SidecarServer {
|
|
|
5987
6309
|
this.ch.send({ t: 'host-sync-result', id: m.id, ok: false, error: 'this sidecar cannot sync host config' });
|
|
5988
6310
|
return;
|
|
5989
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
|
+
}
|
|
5990
6319
|
try {
|
|
5991
6320
|
await this.opts.hostSync.remove(identity.clientId, m.path);
|
|
5992
6321
|
this.ch.send({ t: 'host-sync-result', id: m.id, ok: true });
|