@corenel/harness 0.14.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/approvals/service.d.ts.map +1 -1
- package/dist/approvals/service.js +4 -1
- package/dist/approvals/service.js.map +1 -1
- package/dist/audit/log.d.ts.map +1 -1
- package/dist/audit/log.js +12 -1
- package/dist/audit/log.js.map +1 -1
- package/dist/audit/recorder.d.ts +7 -1
- package/dist/audit/recorder.d.ts.map +1 -1
- package/dist/audit/recorder.js +28 -4
- package/dist/audit/recorder.js.map +1 -1
- package/dist/audit/runAudit.d.ts +2 -1
- package/dist/audit/runAudit.d.ts.map +1 -1
- package/dist/audit/runAudit.js +2 -0
- package/dist/audit/runAudit.js.map +1 -1
- package/dist/core/dontsRecent.d.ts +5 -0
- package/dist/core/dontsRecent.d.ts.map +1 -0
- package/dist/core/dontsRecent.js +23 -0
- package/dist/core/dontsRecent.js.map +1 -0
- package/dist/core/loop.d.ts +7 -0
- package/dist/core/loop.d.ts.map +1 -1
- package/dist/core/loop.js +134 -5
- package/dist/core/loop.js.map +1 -1
- package/dist/core/spawn.d.ts +13 -1
- package/dist/core/spawn.d.ts.map +1 -1
- package/dist/core/spawn.js +10 -1
- package/dist/core/spawn.js.map +1 -1
- package/dist/dag/executeNode.d.ts +3 -1
- package/dist/dag/executeNode.d.ts.map +1 -1
- package/dist/dag/executeNode.js +1 -0
- package/dist/dag/executeNode.js.map +1 -1
- package/dist/guardrail/permission-service.d.ts.map +1 -1
- package/dist/guardrail/permission-service.js +21 -10
- package/dist/guardrail/permission-service.js.map +1 -1
- package/dist/guardrail/serialize.d.ts.map +1 -1
- package/dist/guardrail/serialize.js +5 -0
- package/dist/guardrail/serialize.js.map +1 -1
- package/dist/guardrail/validate.d.ts.map +1 -1
- package/dist/guardrail/validate.js +21 -6
- package/dist/guardrail/validate.js.map +1 -1
- package/dist/memory/store.d.ts +3 -0
- package/dist/memory/store.d.ts.map +1 -1
- package/dist/memory/store.js +16 -1
- package/dist/memory/store.js.map +1 -1
- package/dist/orchestrator/record.d.ts +7 -0
- package/dist/orchestrator/record.d.ts.map +1 -1
- package/dist/orchestrator/record.js +14 -2
- package/dist/orchestrator/record.js.map +1 -1
- package/dist/prompts/library/defaults.generated.d.ts +2 -0
- package/dist/prompts/library/defaults.generated.d.ts.map +1 -1
- package/dist/prompts/library/defaults.generated.js +6 -0
- package/dist/prompts/library/defaults.generated.js.map +1 -1
- package/dist/prompts/library/index.d.ts +3 -0
- package/dist/prompts/library/index.d.ts.map +1 -1
- package/dist/prompts/library/index.js +3 -0
- package/dist/prompts/library/index.js.map +1 -1
- package/dist/sessions/fileSessionStore.d.ts +1 -0
- package/dist/sessions/fileSessionStore.d.ts.map +1 -1
- package/dist/sessions/fileSessionStore.js +11 -1
- package/dist/sessions/fileSessionStore.js.map +1 -1
- package/dist/sidecar/accessTestRig.d.ts +32 -0
- package/dist/sidecar/accessTestRig.d.ts.map +1 -0
- package/dist/sidecar/accessTestRig.js +107 -0
- package/dist/sidecar/accessTestRig.js.map +1 -0
- package/dist/sidecar/askView.d.ts +20 -0
- package/dist/sidecar/askView.d.ts.map +1 -0
- package/dist/sidecar/askView.js +38 -0
- package/dist/sidecar/askView.js.map +1 -0
- package/dist/sidecar/host.d.ts +139 -11
- package/dist/sidecar/host.d.ts.map +1 -1
- package/dist/sidecar/host.js +394 -13
- package/dist/sidecar/host.js.map +1 -1
- package/dist/sidecar/hostCapability.d.ts +6 -0
- package/dist/sidecar/hostCapability.d.ts.map +1 -0
- package/dist/sidecar/hostCapability.js +7 -0
- package/dist/sidecar/hostCapability.js.map +1 -0
- package/dist/sidecar/identity.d.ts +5 -0
- package/dist/sidecar/identity.d.ts.map +1 -1
- package/dist/sidecar/memberGate.d.ts +5 -0
- package/dist/sidecar/memberGate.d.ts.map +1 -0
- package/dist/sidecar/memberGate.js +128 -0
- package/dist/sidecar/memberGate.js.map +1 -0
- package/dist/sidecar/server.d.ts +425 -11
- package/dist/sidecar/server.d.ts.map +1 -1
- package/dist/sidecar/server.js +1809 -52
- package/dist/sidecar/server.js.map +1 -1
- package/dist/skills.d.ts +7 -1
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +18 -5
- package/dist/skills.js.map +1 -1
- package/dist/tools/builtins.d.ts +17 -0
- package/dist/tools/builtins.d.ts.map +1 -1
- package/dist/tools/builtins.js +22 -2
- package/dist/tools/builtins.js.map +1 -1
- package/dist/tools/callAgent.d.ts +14 -1
- package/dist/tools/callAgent.d.ts.map +1 -1
- package/dist/tools/callAgent.js +18 -2
- package/dist/tools/callAgent.js.map +1 -1
- package/dist/tools/createCrewAgent.d.ts +39 -0
- package/dist/tools/createCrewAgent.d.ts.map +1 -0
- package/dist/tools/createCrewAgent.js +121 -0
- package/dist/tools/createCrewAgent.js.map +1 -0
- package/package.json +2 -2
package/dist/sidecar/server.js
CHANGED
|
@@ -1,7 +1,220 @@
|
|
|
1
|
-
import { DEFAULT_STATE_DIR, RUN_TASK_FEATURE, RUN_PLAN_FEATURE, CREW_DEPLOY_FEATURE, DEVICES_FEATURE, denialNote } from '@corenel/protocol';
|
|
1
|
+
import { decodePcm16, SPEECH_FEATURE, AGENT_CRED_ERROR_CODES, agentCredHeadersProblem, DEFAULT_STATE_DIR, PEER_DAEMON_FEATURE, TEAM_MEMBERS_FEATURE, RUN_TASK_FEATURE, RUN_PLAN_FEATURE, CREW_DEPLOY_FEATURE, DEVICES_FEATURE, AGENT_ACCESS_FEATURE, AGENT_RUNS_DEFAULT_LIMIT, AGENT_RUNS_FEATURE, AGENT_RUNS_MAX_LIMIT, PEER_LINKS_FEATURE, CHILD_EVENTS_FEATURE, CREW_DEFINITION_FILES, crewConfigTarget, denialNote, isRunOrigin, cleanPerson } from '@corenel/protocol';
|
|
2
2
|
import { SIDECAR_PROTOCOL, errMessage, isLoopbackHost, isLoopbackAddress, parseMcpToolAddress, isWebPushSubscription, MAX_ASSERTION_LEN, isClerkOrgId, } from '@corenel/protocol';
|
|
3
|
+
import { activityForViewer, askForViewer, withoutViewerClaims } from './askView.js';
|
|
4
|
+
import { capabilityMissingMessage } from './hostCapability.js';
|
|
3
5
|
import { asEcPublicJwk, jwkThumbprint, verifySignature, randomToken, serverProofPayload, clientProofPayload, bytesToB64url, codeProof, sameB64, B64URL_32, } from './deviceKeys.js';
|
|
6
|
+
import { MEMBER_GATE_ALLOWED, pendingRefusal } from './memberGate.js';
|
|
7
|
+
/** A deliberate refusal from the approvals port, written for the person who
|
|
8
|
+
* asked: its message goes on the wire as is. Recognized by `name`, not by
|
|
9
|
+
* `instanceof`, so a second copy of this module (a test alias, a bundled
|
|
10
|
+
* build) cannot turn a refusal into APPROVALS_FAILED. */
|
|
11
|
+
export class ApprovalsRefusal extends Error {
|
|
12
|
+
constructor(message) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = 'ApprovalsRefusal';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export function isApprovalsRefusal(e) {
|
|
18
|
+
return e instanceof Error && e.name === 'ApprovalsRefusal';
|
|
19
|
+
}
|
|
20
|
+
/** Team-mode approvals refusals and the fixed text for an unexpected error
|
|
21
|
+
* (Part C). Exported for the tests. */
|
|
22
|
+
export const APPROVALS_FAILED = 'Standing approvals could not be read or changed. The daemon log has the reason.';
|
|
23
|
+
export const APPROVALS_NO_USER = 'In team mode standing approvals need a verified user on this connection.';
|
|
24
|
+
export const APPROVALS_NAME_AGENT = 'Name the agent: in team mode standing approvals belong to one agent, and only its owner can revoke them.';
|
|
4
25
|
const NO_CHECKS = 'this sidecar does not run checks (start it with --allow-shell)';
|
|
26
|
+
/** Team agent access refusals (Part B). Exported for the tests. */
|
|
27
|
+
export const WATCH_REFUSED = 'You can watch only runs you started, or unattended runs of an agent you own.';
|
|
28
|
+
export const ACCESS_NO_ROLE = 'NO_ACCESS: you have no role on this agent, or too low a role for this.';
|
|
29
|
+
export const ACCESS_NOT_OWNER = "NOT_OWNER: only this agent's owner or the daemon admin can change it.";
|
|
30
|
+
export const ACCESS_ADMIN_ONLY = 'NOT_OWNER: only the daemon admin can change the crew folder itself.';
|
|
31
|
+
export const ACCESS_STORE_REFUSED = 'The access store is never writable.';
|
|
32
|
+
export const ACCESS_CHECK_FAILED = 'NOT_OWNER: could not check access.';
|
|
33
|
+
export const ACCESS_FAILED = 'Access could not be checked or changed. The daemon log has the reason.';
|
|
34
|
+
/** An uncoded peer-* failure; the detail goes to the log. One source: the peer admin port reuses it. */
|
|
35
|
+
export const PEER_FAILED = 'Your team daemon links could not be read or changed. The daemon log has the reason.';
|
|
36
|
+
/** Team run history (spec 2026-10-01 E5.6): an uncoded failure. Fixed text; the daemon log has the detail. */
|
|
37
|
+
export const AGENT_RUNS_FAILED = 'Team runs could not be read. The daemon log has the reason.';
|
|
38
|
+
/** A ref the request carried that names no agent this daemon could hold. */
|
|
39
|
+
const AGENT_RUNS_BAD_REF = 'That agent reference is not valid.';
|
|
40
|
+
/** Part E: a member request the member list could not answer. Fixed text; the
|
|
41
|
+
* daemon log has the detail. */
|
|
42
|
+
export const MEMBER_FAILED = 'The member request could not be answered. The daemon log has the reason.';
|
|
43
|
+
/** A device store failure. Fixed text; the daemon log has the detail. */
|
|
44
|
+
export const DEVICES_FAILED = 'The paired devices could not be read or changed. The daemon log has the reason.';
|
|
45
|
+
/** Part D credential refusals (exported for the tests). A refusal with no
|
|
46
|
+
* reply code of its own goes out as fixed text; the daemon log has the detail. */
|
|
47
|
+
export const AGENT_CRED_FAILED = 'Agent credentials could not be checked or changed. The daemon log has the reason.';
|
|
48
|
+
export const AGENT_CRED_LOGIN_FAILED = 'The sign-in did not finish. The daemon log has the reason.';
|
|
49
|
+
export const AGENT_CRED_SOLO = 'This daemon does not keep agent access.';
|
|
50
|
+
export const MCP_ADMIN_ONLY = "In team mode only the daemon admin can use or change this machine's MCP servers directly.";
|
|
51
|
+
export const SESSION_REFUSED = 'In team mode a session is reachable only by the member who created it, or the daemon admin.';
|
|
52
|
+
export const SNAPSHOT_REFUSED = 'In team mode you can restore only from a snapshot of a session you created.';
|
|
53
|
+
export const SNAPSHOT_NAME_REFUSED = 'In team mode a snapshot name is chosen by the daemon; only the daemon admin can name one.';
|
|
54
|
+
export const SNAPSHOT_USAGE_ADMIN_ONLY = "In team mode only the daemon admin can see this machine's snapshot usage.";
|
|
55
|
+
/** An agent in the daemon's own crew, as a ref: `crew/<agent>` on the config channel. */
|
|
56
|
+
const daemonAgent = (agent) => ({ owner: { kind: 'daemon' }, agent });
|
|
57
|
+
const sameConfigName = (a, b) => a.toLowerCase() === b.toLowerCase();
|
|
58
|
+
/** Team mode: a room runs its agents for the person who opens or drives it. */
|
|
59
|
+
export const ROOM_RUN_REFUSED = 'You need contributor or higher on every agent in this room to open or drive it.';
|
|
60
|
+
export const OUTPUT_REFUSED = 'You can see the output only of runs you started, or of unattended runs of an agent you own.';
|
|
61
|
+
/** Bounds on a hello's `provides.mcp.urls`. A list outside them is carried as
|
|
62
|
+
* NOTHING (not truncated): the fail-safe reading is "serves nothing". */
|
|
63
|
+
const MAX_PROVIDED_URLS = 32;
|
|
64
|
+
const MAX_PROVIDED_URL_LEN = 2048;
|
|
65
|
+
function providedMcpUrls(v) {
|
|
66
|
+
if (typeof v !== 'object' || v === null)
|
|
67
|
+
return [];
|
|
68
|
+
const mcp = v.mcp;
|
|
69
|
+
if (typeof mcp !== 'object' || mcp === null)
|
|
70
|
+
return [];
|
|
71
|
+
const urls = mcp.urls;
|
|
72
|
+
if (!Array.isArray(urls) || urls.length > MAX_PROVIDED_URLS)
|
|
73
|
+
return [];
|
|
74
|
+
const list = urls;
|
|
75
|
+
return list.every((u) => typeof u === 'string' && u.length > 0 && u.length <= MAX_PROVIDED_URL_LEN) ? list : [];
|
|
76
|
+
}
|
|
77
|
+
/** The only messages a peer daemon's connection is served (spec 2026-10-01
|
|
78
|
+
* Part D): the handshake, and the answers to this daemon's own host-calls. A
|
|
79
|
+
* peer is a VERIFIED connection of its member but not that member's app, so
|
|
80
|
+
* nothing a person's app may ask for (runs, run events, approvals, access,
|
|
81
|
+
* permission answers, crew deploys, config, files) reaches it. */
|
|
82
|
+
const PEER_DAEMON_MESSAGES = new Set([
|
|
83
|
+
'hello', 'client-proof', 'identity-assert', 'host-call-result',
|
|
84
|
+
]);
|
|
85
|
+
/** Server names a credential message may never carry: auth.json is keyed by
|
|
86
|
+
* server name, and these are Object.prototype's own. */
|
|
87
|
+
const AGENT_CRED_RESERVED_SERVERS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
88
|
+
/** A message field made safe to put in a log line: control characters
|
|
89
|
+
* (C0, DEL, C1) removed, so it cannot forge or split a line. */
|
|
90
|
+
function logSafe(s) {
|
|
91
|
+
return s.replace(/[\x00-\x1f\x7f-\x9f]/g, '');
|
|
92
|
+
}
|
|
93
|
+
/** The code a thrown refusal carries, when it is one a reply may name. */
|
|
94
|
+
function agentCredCodeOf(err) {
|
|
95
|
+
const code = typeof err === 'object' && err !== null ? err.code : undefined;
|
|
96
|
+
return typeof code === 'string' && AGENT_CRED_ERROR_CODES.includes(code) ? code : null;
|
|
97
|
+
}
|
|
98
|
+
const ACCESS_ERROR_CODES = new Set([
|
|
99
|
+
'NOT_TEAM_AGENT', 'NOT_OWNER', 'NO_ACCESS', 'UNKNOWN_AGENT', 'UNKNOWN_USER',
|
|
100
|
+
'OWNER_NOT_GRANTABLE', 'BAD_ROLE', 'STORE_SEALED_BAD', 'MEMBER_PENDING',
|
|
101
|
+
]);
|
|
102
|
+
/** A crew agent ref rebuilt from its own fields. Throws on anything else, so a
|
|
103
|
+
* malformed port row becomes the handler's fixed-text failure. */
|
|
104
|
+
function crewAgentRefOf(ref) {
|
|
105
|
+
const o = ref.owner;
|
|
106
|
+
const owner = o.kind === 'daemon' ? { kind: 'daemon' }
|
|
107
|
+
: o.kind === 'host' ? { kind: 'host', dirName: String(o.dirName) }
|
|
108
|
+
: o.kind === 'workspace' ? { kind: 'workspace', dirName: String(o.dirName), workspaceId: String(o.workspaceId) }
|
|
109
|
+
: o.kind === 'workdir' ? { kind: 'workdir', root: String(o.root), ...(o.project !== undefined ? { project: String(o.project) } : {}) }
|
|
110
|
+
: (() => { throw new Error('access overview: a row has an unknown owner kind'); })();
|
|
111
|
+
return { owner, agent: String(ref.agent) };
|
|
112
|
+
}
|
|
113
|
+
/** One overview row rebuilt from named fields only (Part E, E2): whatever
|
|
114
|
+
* else a port's row, grant or binding map carries never reaches the wire. */
|
|
115
|
+
function accessOverviewRowOf(r) {
|
|
116
|
+
const mcp = Object.create(null);
|
|
117
|
+
for (const [server, binding] of Object.entries(r.mcp)) {
|
|
118
|
+
if (server === '__proto__')
|
|
119
|
+
continue;
|
|
120
|
+
if (binding === 'asker' || binding === 'agent')
|
|
121
|
+
mcp[server] = binding;
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
ref: crewAgentRefOf(r.ref),
|
|
125
|
+
owner: String(r.owner),
|
|
126
|
+
you: r.you,
|
|
127
|
+
grants: r.grants.map((g) => ({ userId: String(g.userId), role: g.role, grantedBy: String(g.grantedBy), at: String(g.at) })),
|
|
128
|
+
mcp,
|
|
129
|
+
agentCredMissing: r.agentCredMissing.map(String),
|
|
130
|
+
ownerInactive: r.ownerInactive === true,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
/** One team run row rebuilt from its own fields, each held to its type
|
|
134
|
+
* whatever the runner handed back: strings stay strings (capped), numbers
|
|
135
|
+
* finite, `canOpen` and `mine` true only when exactly true. Nothing else
|
|
136
|
+
* the runner returned reaches the wire. */
|
|
137
|
+
function agentRunRowOf(r) {
|
|
138
|
+
const str = (v, max) => (typeof v === 'string' ? v.slice(0, max) : '');
|
|
139
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
|
|
140
|
+
const origin = isRunOrigin(r.origin) ? r.origin : 'user';
|
|
141
|
+
return {
|
|
142
|
+
runId: str(r.runId, 200),
|
|
143
|
+
// Opaque paging cursor, passed back as `before` (clamped to 200 there too).
|
|
144
|
+
cursor: str(r.cursor, 200),
|
|
145
|
+
...(typeof r.requesterLabel === 'string' ? { requesterLabel: r.requesterLabel.slice(0, 200) } : {}),
|
|
146
|
+
origin, startedAt: num(r.startedAt), outcome: str(r.outcome, 40), durationMs: Math.max(0, num(r.durationMs)),
|
|
147
|
+
canOpen: r.canOpen === true,
|
|
148
|
+
...(r.mine === true ? { mine: true } : {}),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
/** An inactive deployment rebuilt from its one field: a display name, never a
|
|
152
|
+
* user id or a path, whatever the port handed back. */
|
|
153
|
+
function inactiveDeploymentOf(e) {
|
|
154
|
+
return { agent: String(e.agent) };
|
|
155
|
+
}
|
|
156
|
+
const AGENT_RUNS_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
|
|
157
|
+
const AGENT_RUNS_DIR_RE = /^[a-z0-9_-]{1,32}$/;
|
|
158
|
+
function hasControlChar(s) {
|
|
159
|
+
for (let i = 0; i < s.length; i++) {
|
|
160
|
+
const c = s.charCodeAt(i);
|
|
161
|
+
if (c < 0x20 || c === 0x7f)
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
/** Same shape rules as @corenel/crew's validateRef, which this package cannot
|
|
167
|
+
* import (it depends on protocol only). The runner validates and canonicalizes
|
|
168
|
+
* again; this only keeps a malformed ref away from the access port. Returns a
|
|
169
|
+
* ref rebuilt from its own fields, or null. */
|
|
170
|
+
function agentRunsRefOf(x) {
|
|
171
|
+
if (typeof x !== 'object' || x === null || Array.isArray(x))
|
|
172
|
+
return null;
|
|
173
|
+
const r = x;
|
|
174
|
+
if (typeof r.agent !== 'string' || !AGENT_RUNS_NAME_RE.test(r.agent))
|
|
175
|
+
return null;
|
|
176
|
+
const o = r.owner;
|
|
177
|
+
if (typeof o !== 'object' || o === null || Array.isArray(o))
|
|
178
|
+
return null;
|
|
179
|
+
const f = o;
|
|
180
|
+
switch (f.kind) {
|
|
181
|
+
case 'daemon': return { owner: { kind: 'daemon' }, agent: r.agent };
|
|
182
|
+
case 'host':
|
|
183
|
+
return typeof f.dirName === 'string' && AGENT_RUNS_DIR_RE.test(f.dirName)
|
|
184
|
+
? { owner: { kind: 'host', dirName: f.dirName }, agent: r.agent } : null;
|
|
185
|
+
case 'workspace':
|
|
186
|
+
return typeof f.dirName === 'string' && AGENT_RUNS_DIR_RE.test(f.dirName)
|
|
187
|
+
&& typeof f.workspaceId === 'string' && AGENT_RUNS_DIR_RE.test(f.workspaceId)
|
|
188
|
+
? { owner: { kind: 'workspace', dirName: f.dirName, workspaceId: f.workspaceId }, agent: r.agent } : null;
|
|
189
|
+
case 'workdir': {
|
|
190
|
+
if (typeof f.root !== 'string' || !f.root || f.root.length > 4096 || hasControlChar(f.root))
|
|
191
|
+
return null;
|
|
192
|
+
if (f.project === undefined)
|
|
193
|
+
return { owner: { kind: 'workdir', root: f.root }, agent: r.agent };
|
|
194
|
+
if (typeof f.project !== 'string' || !f.project || f.project.length > 4096 || hasControlChar(f.project))
|
|
195
|
+
return null;
|
|
196
|
+
return { owner: { kind: 'workdir', root: f.root, project: f.project }, agent: r.agent };
|
|
197
|
+
}
|
|
198
|
+
default: return null;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
/** The access code a thrown refusal carries, when it is one a reply may name. */
|
|
202
|
+
function accessCodeOf(err) {
|
|
203
|
+
const code = typeof err === 'object' && err !== null ? err.code : undefined;
|
|
204
|
+
return typeof code === 'string' && ACCESS_ERROR_CODES.has(code) ? code : null;
|
|
205
|
+
}
|
|
206
|
+
/** How long one run-activity item may take to be decided for one team-mode
|
|
207
|
+
* connection (Task 16b fix round 1). A store that hangs must not leave a
|
|
208
|
+
* client unwelcomed or stall every later item: a timed-out item is dropped
|
|
209
|
+
* for that connection (fail closed). */
|
|
210
|
+
export const RUN_ACTIVITY_DECIDE_MS = 3000;
|
|
211
|
+
/** How many per-item deadlines (RUN_ACTIVITY_DECIDE_MS, or its test
|
|
212
|
+
* override) the whole pre-welcome replay may hold the welcome in team mode
|
|
213
|
+
* (final review F14). A replay of many items that each hang would otherwise
|
|
214
|
+
* hold it for one deadline per item. What is not decided by then is dropped
|
|
215
|
+
* for this connection (fail closed). */
|
|
216
|
+
export const WELCOME_REPLAY_DEADLINES = 3;
|
|
217
|
+
export const WELCOME_REPLAY_MAX_MS = RUN_ACTIVITY_DECIDE_MS * WELCOME_REPLAY_DEADLINES;
|
|
5
218
|
/** Default pre-pairing deadline: a real client sends `hello` immediately.
|
|
6
219
|
* It covers the proof exchange AND the identity exchange (IDENTITY_TIMEOUT_MS),
|
|
7
220
|
* which now happens before a connection counts as authenticated. */
|
|
@@ -31,6 +244,33 @@ export const RECHECK_TIMEOUT_MS = 30000;
|
|
|
31
244
|
function isThenable(v) {
|
|
32
245
|
return typeof v === 'object' && v !== null && typeof v.then === 'function';
|
|
33
246
|
}
|
|
247
|
+
export class HostCallError extends Error {
|
|
248
|
+
constructor(kind, message) {
|
|
249
|
+
super(message);
|
|
250
|
+
Object.defineProperty(this, "kind", {
|
|
251
|
+
enumerable: true,
|
|
252
|
+
configurable: true,
|
|
253
|
+
writable: true,
|
|
254
|
+
value: void 0
|
|
255
|
+
});
|
|
256
|
+
this.name = 'HostCallError';
|
|
257
|
+
this.kind = kind;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
/** The kind of a host-call rejection, or null for any other error. Structural
|
|
261
|
+
* rather than `instanceof`, so a caller in another package does not depend on
|
|
262
|
+
* sharing this module's copy of the class. */
|
|
263
|
+
export function hostCallFailure(e) {
|
|
264
|
+
if (typeof e !== 'object' || e === null)
|
|
265
|
+
return null;
|
|
266
|
+
const k = e.kind;
|
|
267
|
+
return k === 'undelivered' || k === 'timeout' || k === 'closed' || k === 'remote' ? k : null;
|
|
268
|
+
}
|
|
269
|
+
/** Default deadline for one host-call (daemon.json `peerCallTimeoutMs`). */
|
|
270
|
+
export const DEFAULT_HOST_CALL_TIMEOUT_MS = 60000;
|
|
271
|
+
/** Host-calls that wait on a PERSON. The deadline does not apply to them: a
|
|
272
|
+
* slow human is not a dead host. A dropped connection still settles them. */
|
|
273
|
+
const HUMAN_WAIT_PROVIDERS = new Set(['confirm', 'ask']);
|
|
34
274
|
/** Cap on `clientId`/`clientName` before they are forwarded to `onClient` (and,
|
|
35
275
|
* downstream, used as a manifest key / dirName seed) — the wire message is
|
|
36
276
|
* declared `string` but arrives via `JSON.parse(...) as SidecarMsg` with no
|
|
@@ -118,6 +358,7 @@ function normalizeVerdict(got) {
|
|
|
118
358
|
const userId = r.identity?.userId;
|
|
119
359
|
const issuer = r.identity?.issuer;
|
|
120
360
|
const orgId = r.identity?.orgId;
|
|
361
|
+
const raw = r.identity;
|
|
121
362
|
if (typeof userId === 'string' && userId && userId.length <= MAX_IDENTITY_FIELD_LEN) {
|
|
122
363
|
return {
|
|
123
364
|
ok: true,
|
|
@@ -126,6 +367,9 @@ function normalizeVerdict(got) {
|
|
|
126
367
|
// Carried only in a usable shape; a malformed org is no org, which
|
|
127
368
|
// the team policy then refuses.
|
|
128
369
|
...(isClerkOrgId(orgId) ? { orgId } : {}),
|
|
370
|
+
// The person is re-cleaned here: the harness does not trust the
|
|
371
|
+
// verifier to have bounded or typed them.
|
|
372
|
+
...cleanPerson({ name: raw?.name, email: raw?.email }),
|
|
129
373
|
},
|
|
130
374
|
};
|
|
131
375
|
}
|
|
@@ -137,6 +381,8 @@ function normalizeVerdict(got) {
|
|
|
137
381
|
}
|
|
138
382
|
return { ok: false, reason: 'malformed verdict' };
|
|
139
383
|
}
|
|
384
|
+
/** A dictation stream id: 1-64 of [A-Za-z0-9_-]. Anything else is ignored. */
|
|
385
|
+
const STT_SID = /^[A-Za-z0-9_-]{1,64}$/;
|
|
140
386
|
export class SidecarServer {
|
|
141
387
|
constructor(opts) {
|
|
142
388
|
Object.defineProperty(this, "ch", {
|
|
@@ -163,6 +409,13 @@ export class SidecarServer {
|
|
|
163
409
|
writable: true,
|
|
164
410
|
value: false
|
|
165
411
|
});
|
|
412
|
+
/** This connection's one live dictation stream, if any. */
|
|
413
|
+
Object.defineProperty(this, "stt", {
|
|
414
|
+
enumerable: true,
|
|
415
|
+
configurable: true,
|
|
416
|
+
writable: true,
|
|
417
|
+
value: null
|
|
418
|
+
});
|
|
166
419
|
/** Denied hellos on this connection (see MAX_FAILED_HELLOS). */
|
|
167
420
|
Object.defineProperty(this, "failedHellos", {
|
|
168
421
|
enumerable: true,
|
|
@@ -300,6 +553,13 @@ export class SidecarServer {
|
|
|
300
553
|
writable: true,
|
|
301
554
|
value: undefined
|
|
302
555
|
});
|
|
556
|
+
/** The verified person's display name/email, set with verifiedUserId. */
|
|
557
|
+
Object.defineProperty(this, "verifiedPerson", {
|
|
558
|
+
enumerable: true,
|
|
559
|
+
configurable: true,
|
|
560
|
+
writable: true,
|
|
561
|
+
value: {}
|
|
562
|
+
});
|
|
303
563
|
/** The verdict echoed on the welcome, when a verifier is wired. Dead by the
|
|
304
564
|
* time a welcome is ever sent -- `onHello`'s identity block always
|
|
305
565
|
* overwrites it with a real reason ('not requested', 'no daemon id', or the
|
|
@@ -355,6 +615,49 @@ export class SidecarServer {
|
|
|
355
615
|
writable: true,
|
|
356
616
|
value: void 0
|
|
357
617
|
});
|
|
618
|
+
/** Detach from the peer routes, when this connection serves MCP. */
|
|
619
|
+
Object.defineProperty(this, "peersDetach", {
|
|
620
|
+
enumerable: true,
|
|
621
|
+
configurable: true,
|
|
622
|
+
writable: true,
|
|
623
|
+
value: void 0
|
|
624
|
+
});
|
|
625
|
+
/** This connection's hello announced PEER_DAEMON_FEATURE. Set from the
|
|
626
|
+
* claim, before anything is verified: it only ever TAKES privileges away
|
|
627
|
+
* (no attendance, no broadcasts, only PEER_DAEMON_MESSAGES served, and a
|
|
628
|
+
* welcome with no tools and no root/roots), so a client that claims it
|
|
629
|
+
* falsely only loses its own. It never lifts the member gate's welcome
|
|
630
|
+
* strip (Part E): a pending user who claims it is still told nothing. */
|
|
631
|
+
Object.defineProperty(this, "peerDaemon", {
|
|
632
|
+
enumerable: true,
|
|
633
|
+
configurable: true,
|
|
634
|
+
writable: true,
|
|
635
|
+
value: false
|
|
636
|
+
});
|
|
637
|
+
/** One log line per peer connection for refused messages, not one each. */
|
|
638
|
+
Object.defineProperty(this, "peerRefusalLogged", {
|
|
639
|
+
enumerable: true,
|
|
640
|
+
configurable: true,
|
|
641
|
+
writable: true,
|
|
642
|
+
value: false
|
|
643
|
+
});
|
|
644
|
+
/** The tail of this connection's run-activity filter (team agent access,
|
|
645
|
+
* Task 16b). Items are decided one at a time so a run's start, updates and
|
|
646
|
+
* settle reach the client in the order the daemon sent them, however long
|
|
647
|
+
* each one's access check takes. */
|
|
648
|
+
Object.defineProperty(this, "runActivityChain", {
|
|
649
|
+
enumerable: true,
|
|
650
|
+
configurable: true,
|
|
651
|
+
writable: true,
|
|
652
|
+
value: Promise.resolve()
|
|
653
|
+
});
|
|
654
|
+
/** Bumped when the pre-welcome replay is cut off: items queued before it are dropped. */
|
|
655
|
+
Object.defineProperty(this, "runActivityEpoch", {
|
|
656
|
+
enumerable: true,
|
|
657
|
+
configurable: true,
|
|
658
|
+
writable: true,
|
|
659
|
+
value: 0
|
|
660
|
+
});
|
|
358
661
|
/** The synced directory THIS connection belongs to, once resolved -- the same
|
|
359
662
|
* value echoed in `welcome.dirName`. Retained so `hosts-list` can stamp
|
|
360
663
|
* `self` and `hosts-forget` can refuse the caller's own tree from the
|
|
@@ -476,11 +779,15 @@ export class SidecarServer {
|
|
|
476
779
|
onChange: () => () => { },
|
|
477
780
|
}
|
|
478
781
|
});
|
|
782
|
+
/* A host that ANSWERED that it has no confirm capability keeps the old
|
|
783
|
+
* default, allow: the host gated the call before dispatch. Every other
|
|
784
|
+
* failure denies: a timeout, a dropped connection, a send that never left,
|
|
785
|
+
* and a host whose confirm threw or refused. Nobody approved it. */
|
|
479
786
|
Object.defineProperty(this, "confirm", {
|
|
480
787
|
enumerable: true,
|
|
481
788
|
configurable: true,
|
|
482
789
|
writable: true,
|
|
483
|
-
value: (req) => this.hostCall('confirm', 'confirm', [req]).then((v) => v, () =>
|
|
790
|
+
value: (req) => this.hostCall('confirm', 'confirm', [req]).then((v) => v === true, (e) => hostCallFailure(e) === 'remote' && errMessage(e) === capabilityMissingMessage('confirm'))
|
|
484
791
|
});
|
|
485
792
|
Object.defineProperty(this, "ask", {
|
|
486
793
|
enumerable: true,
|
|
@@ -515,7 +822,8 @@ export class SidecarServer {
|
|
|
515
822
|
* reasons. The subscription itself is kept (not deferred to the hello) so
|
|
516
823
|
* there is exactly one place it is set up and torn down. */
|
|
517
824
|
const offRoots = opts.roots?.subscribe((s) => {
|
|
518
|
-
|
|
825
|
+
// A pending member (Part E) is told nothing about this machine.
|
|
826
|
+
if (!this.authed || this.peerDaemon || this.memberPending())
|
|
519
827
|
return;
|
|
520
828
|
this.ch.send({ t: 'roots', root: s.root, roots: s.roots });
|
|
521
829
|
});
|
|
@@ -523,12 +831,45 @@ export class SidecarServer {
|
|
|
523
831
|
// not just the one that started it -- ONE room service is shared across
|
|
524
832
|
// every per-connection SidecarServer.
|
|
525
833
|
const offRooms = opts.rooms?.subscribe((room) => {
|
|
526
|
-
if (!this.authed)
|
|
834
|
+
if (!this.authed || this.peerDaemon || this.memberPending())
|
|
527
835
|
return;
|
|
528
836
|
this.ch.send({ t: 'room-progress', room });
|
|
529
837
|
});
|
|
530
|
-
|
|
838
|
+
/* A member approved while connected is told so at once (Part E), so the
|
|
839
|
+
* waiting screen can move on; the gate itself re-reads the status on the
|
|
840
|
+
* next message. Only THIS connection's verified user is ever told.
|
|
841
|
+
* A member REMOVED while connected is disconnected at once, peer daemon
|
|
842
|
+
* connections of theirs included: every stream this connection holds
|
|
843
|
+
* (run events, session logs, process output, broadcasts) stops here, not
|
|
844
|
+
* whenever the transport gets round to reporting the close. */
|
|
845
|
+
const offMembers = opts.members?.subscribe((userId, event) => {
|
|
846
|
+
if (!this.authed || this.closed || userId !== this.verifiedUserId)
|
|
847
|
+
return;
|
|
848
|
+
if (event === 'removed') {
|
|
849
|
+
this.opts.log?.('member removed; closing their connection');
|
|
850
|
+
teardown();
|
|
851
|
+
this.ch.close();
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
if (event === 'active' && !this.peerDaemon)
|
|
855
|
+
this.ch.send({ t: 'member-status-changed', status: 'active' });
|
|
856
|
+
});
|
|
857
|
+
/* Runs once, from the transport's close or from a member removal above. */
|
|
858
|
+
let tornDown = false;
|
|
859
|
+
const teardown = () => {
|
|
860
|
+
if (tornDown)
|
|
861
|
+
return;
|
|
862
|
+
tornDown = true;
|
|
531
863
|
this.closed = true;
|
|
864
|
+
// Every host-call still waiting on this connection settles now: nothing
|
|
865
|
+
// will ever answer it (spec 2026-10-01 Part D). `closed`, not
|
|
866
|
+
// `undelivered`: these were sent, and may have run on the other end.
|
|
867
|
+
for (const p of this.hostPending.values()) {
|
|
868
|
+
if (p.timer !== undefined)
|
|
869
|
+
clearTimeout(p.timer);
|
|
870
|
+
p.reject(new HostCallError('closed', `host call ${p.label} failed: the connection closed`));
|
|
871
|
+
}
|
|
872
|
+
this.hostPending.clear();
|
|
532
873
|
this.clearHelloDeadline();
|
|
533
874
|
this.unwatchRevocation();
|
|
534
875
|
if (this.recheckTimer !== undefined)
|
|
@@ -544,6 +885,8 @@ export class SidecarServer {
|
|
|
544
885
|
this.identityAssertResolve = undefined;
|
|
545
886
|
this.attendedDetach?.();
|
|
546
887
|
this.attendedDetach = undefined;
|
|
888
|
+
this.peersDetach?.();
|
|
889
|
+
this.peersDetach = undefined;
|
|
547
890
|
// Leave the live set, or this directory reads as connected for ever and
|
|
548
891
|
// could never be reclaimed -- the refusal would outlive the connection.
|
|
549
892
|
this.hostsDetach?.();
|
|
@@ -555,20 +898,52 @@ export class SidecarServer {
|
|
|
555
898
|
this.runDetachers.clear();
|
|
556
899
|
offRoots?.();
|
|
557
900
|
offRooms?.();
|
|
901
|
+
offMembers?.();
|
|
558
902
|
// Stop streaming process output into a dead socket. The PROCESSES keep
|
|
559
903
|
// running - that is the point of the table living on the daemon - but this
|
|
560
904
|
// connection stops hearing about them until something re-attaches.
|
|
561
905
|
this.procUnsub?.();
|
|
562
906
|
this.procUnsub = undefined;
|
|
563
907
|
this.attachedProcs.clear();
|
|
908
|
+
// Dictation audio dies with the connection: drop the buffer, never keep it.
|
|
909
|
+
if (this.stt) {
|
|
910
|
+
const { stream } = this.stt;
|
|
911
|
+
this.stt = null;
|
|
912
|
+
stream.cancel();
|
|
913
|
+
}
|
|
564
914
|
// Same reasoning for sessions: the SESSIONS keep running — that is what
|
|
565
915
|
// makes resume cheap — but this connection stops being sent their
|
|
566
916
|
// metrics and logs.
|
|
567
917
|
for (const sid of [...this.sessionWatch.keys()])
|
|
568
918
|
this.stopWatching(sid);
|
|
569
|
-
}
|
|
919
|
+
};
|
|
920
|
+
this.ch.onClose?.(teardown);
|
|
570
921
|
}
|
|
571
922
|
onMessage(m) {
|
|
923
|
+
/* A PEER DAEMON IS NOT A PERSON (spec 2026-10-01 Part D). Its connection
|
|
924
|
+
* is verified as its member, so every per-user gate below would pass it as
|
|
925
|
+
* that member; it is refused here instead, before any of them. Dropped
|
|
926
|
+
* without a reply: a peer never sends these, and every reply shape here is
|
|
927
|
+
* a browser user's. */
|
|
928
|
+
if (this.peerDaemon && !PEER_DAEMON_MESSAGES.has(m.t)) {
|
|
929
|
+
if (!this.peerRefusalLogged) {
|
|
930
|
+
this.peerRefusalLogged = true;
|
|
931
|
+
this.opts.log?.(`peer daemon sent ${logSafe(String(m.t)).slice(0, 64)}, which a peer is never served; ignoring it and the rest`);
|
|
932
|
+
}
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
/* THE MEMBER GATE (spec 2026-10-01 Part E, E1). After authentication,
|
|
936
|
+
* after the peer block above (a peer daemon is never a member and never
|
|
937
|
+
* gated), and before anything is served: on a team daemon a connection
|
|
938
|
+
* whose verified user is not an active member is refused every message but
|
|
939
|
+
* the allowlist, each with its own reply shape (memberGate.ts). Before
|
|
940
|
+
* authentication every case below refuses on its own, unchanged. */
|
|
941
|
+
if (this.authed && !this.peerDaemon && !MEMBER_GATE_ALLOWED.has(m.t) && this.memberPending()) {
|
|
942
|
+
const refusal = pendingRefusal(m);
|
|
943
|
+
if (refusal)
|
|
944
|
+
this.ch.send(refusal);
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
572
947
|
switch (m.t) {
|
|
573
948
|
case 'hello':
|
|
574
949
|
/* Identity is fixed at first hello; a second must not re-own the
|
|
@@ -620,10 +995,12 @@ export class SidecarServer {
|
|
|
620
995
|
if (!p)
|
|
621
996
|
return;
|
|
622
997
|
this.hostPending.delete(m.id);
|
|
998
|
+
if (p.timer !== undefined)
|
|
999
|
+
clearTimeout(p.timer);
|
|
623
1000
|
if (m.ok)
|
|
624
1001
|
p.resolve(m.result);
|
|
625
1002
|
else
|
|
626
|
-
p.reject(new
|
|
1003
|
+
p.reject(new HostCallError('remote', typeof m.error === 'string' && m.error ? m.error : 'host call failed'));
|
|
627
1004
|
return;
|
|
628
1005
|
}
|
|
629
1006
|
case 'http-req':
|
|
@@ -740,6 +1117,68 @@ export class SidecarServer {
|
|
|
740
1117
|
}
|
|
741
1118
|
this.onProcSimple(m.id, 'proc-kill-result', () => this.requireProcs().kill(m.procId));
|
|
742
1119
|
return;
|
|
1120
|
+
case 'stt-start': {
|
|
1121
|
+
if (typeof m.sid !== 'string' || !STT_SID.test(m.sid))
|
|
1122
|
+
return;
|
|
1123
|
+
const sid = m.sid;
|
|
1124
|
+
const say = (msg) => this.ch.send({ t: 'stt', sid, msg });
|
|
1125
|
+
if (!this.authed) {
|
|
1126
|
+
say({ kind: 'error', message: 'not authenticated' });
|
|
1127
|
+
return;
|
|
1128
|
+
}
|
|
1129
|
+
if (!this.opts.speech) {
|
|
1130
|
+
say({ kind: 'error', message: 'this sidecar does not offer dictation' });
|
|
1131
|
+
return;
|
|
1132
|
+
}
|
|
1133
|
+
if (this.stt) {
|
|
1134
|
+
say({ kind: 'error', message: 'one dictation at a time on this connection' });
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
const stream = this.opts.speech.open((msg) => {
|
|
1138
|
+
this.ch.send({ t: 'stt', sid, msg });
|
|
1139
|
+
if ((msg.kind === 'final' || msg.kind === 'error') && this.stt?.sid === sid)
|
|
1140
|
+
this.stt = null;
|
|
1141
|
+
});
|
|
1142
|
+
this.stt = { sid, stream };
|
|
1143
|
+
return;
|
|
1144
|
+
}
|
|
1145
|
+
case 'stt-audio': {
|
|
1146
|
+
if (typeof m.sid !== 'string' || !STT_SID.test(m.sid))
|
|
1147
|
+
return;
|
|
1148
|
+
if (!this.authed || this.stt?.sid !== m.sid)
|
|
1149
|
+
return;
|
|
1150
|
+
let pcm;
|
|
1151
|
+
try {
|
|
1152
|
+
pcm = decodePcm16(m.pcm);
|
|
1153
|
+
}
|
|
1154
|
+
catch {
|
|
1155
|
+
const { stream } = this.stt;
|
|
1156
|
+
this.stt = null;
|
|
1157
|
+
stream.cancel();
|
|
1158
|
+
this.ch.send({ t: 'stt', sid: m.sid, msg: { kind: 'error', message: 'unreadable audio' } });
|
|
1159
|
+
return;
|
|
1160
|
+
}
|
|
1161
|
+
this.stt.stream.audio(pcm);
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
case 'stt-flush':
|
|
1165
|
+
if (typeof m.sid !== 'string' || !STT_SID.test(m.sid))
|
|
1166
|
+
return;
|
|
1167
|
+
if (!this.authed || this.stt?.sid !== m.sid)
|
|
1168
|
+
return;
|
|
1169
|
+
this.stt.stream.flush();
|
|
1170
|
+
return;
|
|
1171
|
+
case 'stt-cancel':
|
|
1172
|
+
if (typeof m.sid !== 'string' || !STT_SID.test(m.sid))
|
|
1173
|
+
return;
|
|
1174
|
+
if (!this.authed || this.stt?.sid !== m.sid)
|
|
1175
|
+
return;
|
|
1176
|
+
{
|
|
1177
|
+
const { stream } = this.stt;
|
|
1178
|
+
this.stt = null;
|
|
1179
|
+
stream.cancel();
|
|
1180
|
+
}
|
|
1181
|
+
return;
|
|
743
1182
|
case 'proc-list':
|
|
744
1183
|
// An empty list is the honest answer for a daemon without the capability:
|
|
745
1184
|
// it is running nothing, and an error here would make the common "what is
|
|
@@ -758,7 +1197,7 @@ export class SidecarServer {
|
|
|
758
1197
|
this.ch.send({ t: 'session-list-result', id: m.id, sessions: [] });
|
|
759
1198
|
return;
|
|
760
1199
|
}
|
|
761
|
-
this.ch.send({ t: 'session-list-result', id: m.id, sessions: this.
|
|
1200
|
+
this.ch.send({ t: 'session-list-result', id: m.id, sessions: this.visibleSessions() });
|
|
762
1201
|
return;
|
|
763
1202
|
case 'session-create':
|
|
764
1203
|
case 'session-pause':
|
|
@@ -1011,7 +1450,9 @@ export class SidecarServer {
|
|
|
1011
1450
|
this.ch.send({ t: 'mcp-tools', id: m.id, tools: [] });
|
|
1012
1451
|
return;
|
|
1013
1452
|
}
|
|
1014
|
-
|
|
1453
|
+
// Team mode: a direct call uses the daemon's own credentials, which
|
|
1454
|
+
// are the admin's (Part B); a member is offered none.
|
|
1455
|
+
this.ch.send({ t: 'mcp-tools', id: m.id, tools: this.mcpRefusal() === null ? this.opts.mcp?.tools() ?? [] : [] });
|
|
1015
1456
|
return;
|
|
1016
1457
|
case 'mcp-call':
|
|
1017
1458
|
if (!this.authed) {
|
|
@@ -1035,6 +1476,68 @@ export class SidecarServer {
|
|
|
1035
1476
|
}
|
|
1036
1477
|
void this.onMcpAdmin(m);
|
|
1037
1478
|
return;
|
|
1479
|
+
case 'access-get':
|
|
1480
|
+
case 'access-grant':
|
|
1481
|
+
case 'access-revoke':
|
|
1482
|
+
case 'access-transfer':
|
|
1483
|
+
case 'access-members':
|
|
1484
|
+
if (!this.authed) {
|
|
1485
|
+
this.ch.send({ t: 'access-result', id: m.id, ok: false, code: 'NO_ACCESS', error: 'not authenticated' });
|
|
1486
|
+
return;
|
|
1487
|
+
}
|
|
1488
|
+
void this.onAccess(m);
|
|
1489
|
+
return;
|
|
1490
|
+
case 'access-bind':
|
|
1491
|
+
case 'agent-cred-set':
|
|
1492
|
+
case 'agent-mcp-login':
|
|
1493
|
+
case 'agent-cred-clear':
|
|
1494
|
+
if (!this.authed) {
|
|
1495
|
+
this.ch.send({ t: 'agent-cred-result', id: m.id, ok: false, code: 'NOT_OWNER', error: 'not authenticated' });
|
|
1496
|
+
return;
|
|
1497
|
+
}
|
|
1498
|
+
void this.onAgentCred(m);
|
|
1499
|
+
return;
|
|
1500
|
+
case 'member-status':
|
|
1501
|
+
case 'members-list':
|
|
1502
|
+
case 'member-approve':
|
|
1503
|
+
case 'member-remove':
|
|
1504
|
+
if (!this.authed) {
|
|
1505
|
+
this.ch.send({ t: 'member-result', id: m.id, ok: false, code: 'MEMBER_PENDING', error: 'not authenticated' });
|
|
1506
|
+
return;
|
|
1507
|
+
}
|
|
1508
|
+
void this.onMember(m);
|
|
1509
|
+
return;
|
|
1510
|
+
case 'access-overview':
|
|
1511
|
+
if (!this.authed) {
|
|
1512
|
+
this.ch.send({ t: 'access-overview-result', id: m.id, ok: false, code: 'NO_ACCESS', error: 'not authenticated' });
|
|
1513
|
+
return;
|
|
1514
|
+
}
|
|
1515
|
+
void this.onAccessOverview(m);
|
|
1516
|
+
return;
|
|
1517
|
+
case 'agent-cred-status':
|
|
1518
|
+
if (!this.authed) {
|
|
1519
|
+
this.ch.send({ t: 'agent-cred-status-result', id: m.id, ok: false, code: 'NOT_OWNER', error: 'not authenticated' });
|
|
1520
|
+
return;
|
|
1521
|
+
}
|
|
1522
|
+
void this.onAgentCredStatus(m);
|
|
1523
|
+
return;
|
|
1524
|
+
case 'agent-runs':
|
|
1525
|
+
if (!this.authed) {
|
|
1526
|
+
this.ch.send({ t: 'agent-runs-result', id: m.id, ok: false, code: 'NO_ACCESS', error: 'not authenticated' });
|
|
1527
|
+
return;
|
|
1528
|
+
}
|
|
1529
|
+
void this.onAgentRuns(m);
|
|
1530
|
+
return;
|
|
1531
|
+
case 'peer-list':
|
|
1532
|
+
case 'peer-add':
|
|
1533
|
+
case 'peer-remove':
|
|
1534
|
+
case 'peer-allow-set':
|
|
1535
|
+
if (!this.authed) {
|
|
1536
|
+
this.ch.send({ t: 'peer-result', id: m.id, ok: false, code: 'NOT_ADMIN', error: 'not authenticated' });
|
|
1537
|
+
return;
|
|
1538
|
+
}
|
|
1539
|
+
void this.onPeer(m);
|
|
1540
|
+
return;
|
|
1038
1541
|
default:
|
|
1039
1542
|
// welcome / denied / invoke-result / host-call / event / run-accepted /
|
|
1040
1543
|
// run-done / permission-ask / permission-resolved are host-side.
|
|
@@ -1125,6 +1628,8 @@ export class SidecarServer {
|
|
|
1125
1628
|
return false;
|
|
1126
1629
|
}
|
|
1127
1630
|
this.verifiedUserId = verdict.identity.userId;
|
|
1631
|
+
const { name, email } = verdict.identity;
|
|
1632
|
+
this.verifiedPerson = { ...(name ? { name } : {}), ...(email ? { email } : {}) };
|
|
1128
1633
|
this.identityResult = { verified: true };
|
|
1129
1634
|
this.opts.log?.('identity: verified');
|
|
1130
1635
|
return true;
|
|
@@ -1381,9 +1886,14 @@ export class SidecarServer {
|
|
|
1381
1886
|
this.countFailedHello();
|
|
1382
1887
|
return;
|
|
1383
1888
|
}
|
|
1889
|
+
/* Decided from the hello itself, synchronously, before the first await:
|
|
1890
|
+
* see `peerDaemon`. */
|
|
1891
|
+
this.peerDaemon = announcedCapabilities(m.capabilities).includes(PEER_DAEMON_FEATURE);
|
|
1384
1892
|
const deviceName = typeof m.clientName === 'string' && m.clientName
|
|
1385
1893
|
? m.clientName.slice(0, MAX_IDENTITY_FIELD_LEN)
|
|
1386
|
-
|
|
1894
|
+
// A peer daemon's hello carries no clientName by design (host.ts), so
|
|
1895
|
+
// its device is named by what it is.
|
|
1896
|
+
: this.peerDaemon ? 'Peer daemon' : 'unnamed device';
|
|
1387
1897
|
/* NO CREDENTIAL, NO CONNECTION. There is no shared pairing token: a hello
|
|
1388
1898
|
* that names neither a device nor an enrollment is refused like any other
|
|
1389
1899
|
* failure (a token sent by an older client included). */
|
|
@@ -1406,6 +1916,18 @@ export class SidecarServer {
|
|
|
1406
1916
|
this.unwatchRevocation();
|
|
1407
1917
|
return;
|
|
1408
1918
|
}
|
|
1919
|
+
/* A PEER DAEMON SERVES ITS VERIFIED USER, and in team mode there is no
|
|
1920
|
+
* other kind: one with no verified identity is refused before it is
|
|
1921
|
+
* authenticated. (The node daemon always wires a verifier, so this is the
|
|
1922
|
+
* harness's own backstop.) */
|
|
1923
|
+
if (this.peerDaemon && this.opts.teamMode && !this.verifiedUserId) {
|
|
1924
|
+
this.opts.log?.('denied: a peer daemon with no verified identity');
|
|
1925
|
+
this.ch.send({ t: 'denied', reason: 'a peer daemon needs a verified identity' });
|
|
1926
|
+
this.noteOutcome('denied');
|
|
1927
|
+
this.unwatchRevocation();
|
|
1928
|
+
this.ch.close();
|
|
1929
|
+
return;
|
|
1930
|
+
}
|
|
1409
1931
|
/** What every pairing assertion on this connection binds: the thumbprint
|
|
1410
1932
|
* of the device key it proved. */
|
|
1411
1933
|
this.deviceJkt = cred.deviceJkt;
|
|
@@ -1553,17 +2075,70 @@ export class SidecarServer {
|
|
|
1553
2075
|
this.opts.log?.(`noteOlderClient threw: ${errMessage(err)}`);
|
|
1554
2076
|
}
|
|
1555
2077
|
}
|
|
2078
|
+
/* WHO THEY ARE (spec 2026-10-07). Before first sight, so the admin's
|
|
2079
|
+
* waiting push can already carry the name. Display only; never fails the
|
|
2080
|
+
* handshake. A peer daemon is not a person. */
|
|
2081
|
+
if (this.opts.people && this.verifiedUserId && !this.peerDaemon && (this.verifiedPerson.name || this.verifiedPerson.email)) {
|
|
2082
|
+
try {
|
|
2083
|
+
await this.opts.people.note(this.verifiedUserId, this.verifiedPerson);
|
|
2084
|
+
}
|
|
2085
|
+
catch (err) {
|
|
2086
|
+
this.opts.log?.(`recording who ${this.verifiedUserId} is failed: ${errMessage(err)}`);
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
/* FIRST SIGHT (Part E, E1). A verified user with no member record is
|
|
2090
|
+
* recorded as pending and the admin is told, once per user. Awaited so
|
|
2091
|
+
* the record exists before the welcome; never fails the handshake. A peer
|
|
2092
|
+
* daemon is not a person: it never creates a record or pushes the admin. */
|
|
2093
|
+
if (this.opts.members && this.verifiedUserId && !this.peerDaemon) {
|
|
2094
|
+
try {
|
|
2095
|
+
await this.opts.members.noteAdmitted(this.verifiedUserId, deviceName);
|
|
2096
|
+
}
|
|
2097
|
+
catch (err) {
|
|
2098
|
+
this.opts.log?.(`member first sight failed: ${errMessage(err)}`);
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
1556
2101
|
// Dropped while verifying: attaching now would leave a dead client in the
|
|
1557
2102
|
// registry that the (already-run) close handler can never detach.
|
|
1558
2103
|
if (this.closed)
|
|
1559
2104
|
return;
|
|
1560
|
-
|
|
2105
|
+
/* PEER DAEMON (spec 2026-10-01 Part D). Never attended: attached, it would
|
|
2106
|
+
* count as its user being present and receive their permission asks, which
|
|
2107
|
+
* it cannot answer, so an ask would neither reach a person nor be pushed. */
|
|
2108
|
+
const isPeerDaemon = announcedCapabilities(m.capabilities).includes(PEER_DAEMON_FEATURE);
|
|
2109
|
+
/* What it serves joins the peer routes under the VERIFIED user only. An
|
|
2110
|
+
* unverified connection serves nobody, and neither does one that did not
|
|
2111
|
+
* announce PEER_DAEMON_FEATURE: a person's app is never a route, whatever
|
|
2112
|
+
* `provides` it sends. */
|
|
2113
|
+
const provided = providedMcpUrls(m.provides);
|
|
2114
|
+
if (this.opts.peers && isPeerDaemon && this.verifiedUserId && provided.length) {
|
|
2115
|
+
this.peersDetach = this.opts.peers.attach({
|
|
2116
|
+
userId: this.verifiedUserId,
|
|
2117
|
+
urls: provided,
|
|
2118
|
+
call: (method, args) => this.hostCall('mcp', method, args),
|
|
2119
|
+
});
|
|
2120
|
+
}
|
|
2121
|
+
if (this.opts.attendance && !isPeerDaemon) {
|
|
2122
|
+
/* A pending member (Part E) is never a recipient of anything: every
|
|
2123
|
+
* send below checks the status at the moment it is made, so a member
|
|
2124
|
+
* approved later starts receiving without reconnecting. */
|
|
1561
2125
|
const attended = {
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
2126
|
+
// Shaped for THIS connection (E5.2): the requester's own copy names nobody.
|
|
2127
|
+
send: (ask) => { if (!this.memberPending())
|
|
2128
|
+
this.ch.send({ t: 'permission-ask', ...askForViewer(ask, this.verifiedUserId) }); },
|
|
2129
|
+
resolved: (id, decision, by, byClientId) => {
|
|
2130
|
+
if (!this.memberPending())
|
|
2131
|
+
this.ch.send({ t: 'permission-resolved', id, decision, by, ...(byClientId ? { byClientId } : {}) });
|
|
2132
|
+
},
|
|
2133
|
+
// Team mode: filtered by role (Task 16b). The registry calls this for
|
|
2134
|
+
// the live broadcast AND for the replay at attach, so both are covered.
|
|
2135
|
+
// An active member still gets only what their role allows.
|
|
2136
|
+
runActivity: (activity) => { if (!this.memberPending())
|
|
2137
|
+
this.sendRunActivity(activity); },
|
|
2138
|
+
registrations: (registrations) => { if (!this.memberPending())
|
|
2139
|
+
this.ch.send({ t: 'registrations', registrations }); },
|
|
2140
|
+
approvalSuggestion: (s) => { if (!this.memberPending())
|
|
2141
|
+
this.ch.send({ t: 'approvals-suggest', ...s }); },
|
|
1567
2142
|
};
|
|
1568
2143
|
// Retained so `permission-answer` can name THIS connection as the
|
|
1569
2144
|
// answerer -- the registry identifies an answerer by the object it
|
|
@@ -1583,6 +2158,13 @@ export class SidecarServer {
|
|
|
1583
2158
|
...(this.verifiedUserId ? { verifiedUserId: this.verifiedUserId } : {}),
|
|
1584
2159
|
});
|
|
1585
2160
|
}
|
|
2161
|
+
/* The registry replays in-flight runs at attach, and the client relies on
|
|
2162
|
+
* that replay landing BEFORE `welcome` (it prunes the runs it still holds
|
|
2163
|
+
* as stale once welcomed; see pruneStaleDaemonRuns). Team mode filters the
|
|
2164
|
+
* replay asynchronously, so let it drain first. The chain never rejects. */
|
|
2165
|
+
await this.replayDrained();
|
|
2166
|
+
if (this.closed)
|
|
2167
|
+
return;
|
|
1586
2168
|
this.opts.log?.(`paired — ${this.tools.size} ${this.tools.size === 1 ? 'tool' : 'tools'} advertised (host dir: ${this.hostStateDir})`);
|
|
1587
2169
|
if (!dirNamePending) {
|
|
1588
2170
|
this.sendWelcome(null);
|
|
@@ -1729,7 +2311,19 @@ export class SidecarServer {
|
|
|
1729
2311
|
// has no claim to.
|
|
1730
2312
|
this.hostsDetach = this.opts.hosts?.attach(dirName);
|
|
1731
2313
|
}
|
|
1732
|
-
|
|
2314
|
+
/* A PENDING member (Part E) learns who to wait for and nothing else: no
|
|
2315
|
+
* tools, and of `info` only the daemon's id and name (an ALLOWLIST, so a
|
|
2316
|
+
* field added later is not told either). Decided from member status
|
|
2317
|
+
* alone; claiming PEER_DAEMON_FEATURE does not lift it. A peer daemon is
|
|
2318
|
+
* served only host-call, so it gets no tools and no root/roots either.
|
|
2319
|
+
* The browser re-pairs once approved. */
|
|
2320
|
+
const pending = this.memberPending();
|
|
2321
|
+
const tools = pending || this.peerDaemon ? [] : [...this.tools.values()].map(({ run: _run, ...decl }) => decl);
|
|
2322
|
+
const full = this.opts.info;
|
|
2323
|
+
const info = !full ? undefined
|
|
2324
|
+
: pending ? { ...(full.id !== undefined ? { id: full.id } : {}), ...(full.name !== undefined ? { name: full.name } : {}) }
|
|
2325
|
+
: this.peerDaemon ? (({ root: _root, roots: _roots, ...rest }) => rest)(full)
|
|
2326
|
+
: full;
|
|
1733
2327
|
// Advertise the loopback HTTP proxy so the host can route local-LLM calls
|
|
1734
2328
|
// through us (server-side fetch, no browser CORS) instead of failing, and
|
|
1735
2329
|
// 'runner' only when a crew runner is actually wired — that's what lets the
|
|
@@ -1754,11 +2348,16 @@ export class SidecarServer {
|
|
|
1754
2348
|
...(this.opts.runner?.supportsTasks ? [RUN_TASK_FEATURE] : []),
|
|
1755
2349
|
// Whether this daemon answers run-plan-get (a runner that reads run records).
|
|
1756
2350
|
...(this.opts.runner?.runPlan ? [RUN_PLAN_FEATURE] : []),
|
|
2351
|
+
// Whether this daemon fans a delegated callee's own node events out to
|
|
2352
|
+
// watchers of the parent run (the running-pills card). Only with a live
|
|
2353
|
+
// hub behind it -- there is otherwise nothing to fan out from.
|
|
2354
|
+
...(this.opts.runs ? [CHILD_EVENTS_FEATURE] : []),
|
|
1757
2355
|
...(this.opts.acp ? ['acp'] : []),
|
|
1758
2356
|
...(this.opts.roots?.areas.length ? ['add-roots'] : []),
|
|
1759
2357
|
...(this.opts.roots?.areas.length && this.opts.roots.create ? ['create-roots'] : []),
|
|
1760
2358
|
...(this.opts.rooms ? ['rooms'] : []),
|
|
1761
2359
|
...(this.opts.procs ? ['procs'] : []),
|
|
2360
|
+
...(this.opts.speech ? [SPEECH_FEATURE] : []),
|
|
1762
2361
|
...(this.opts.sessions ? ['sessions'] : []),
|
|
1763
2362
|
// The operator's transcript CEILING, advertised so a client offers the
|
|
1764
2363
|
// per-run choice only where it could be honoured. Withheld means every
|
|
@@ -1786,6 +2385,17 @@ export class SidecarServer {
|
|
|
1786
2385
|
// service behind it, so a client shows the "This machine" group only
|
|
1787
2386
|
// where there is something to list and revoke.
|
|
1788
2387
|
...(this.opts.approvals ? ['approvals'] : []),
|
|
2388
|
+
// Whether this daemon keeps per-agent access (team agent access, Part B),
|
|
2389
|
+
// so a client shows owner, role and the members panel only where they exist.
|
|
2390
|
+
...(this.opts.access?.teamMode ? [AGENT_ACCESS_FEATURE] : []),
|
|
2391
|
+
// Team run history (spec 2026-10-01 E5.6): team mode only.
|
|
2392
|
+
...(this.opts.access?.teamMode && this.opts.runner?.agentRuns ? [AGENT_RUNS_FEATURE] : []),
|
|
2393
|
+
// A team daemon that keeps a member list (Part E), so a client shows the
|
|
2394
|
+
// Team page and, for a pending member, the waiting screen.
|
|
2395
|
+
...(this.opts.members ? [TEAM_MEMBERS_FEATURE] : []),
|
|
2396
|
+
// Peer links from Settings (spec 2026-10-01 E5.5): whenever the port is
|
|
2397
|
+
// wired, solo mode included, since a member's own daemon is normally solo.
|
|
2398
|
+
...(this.opts.peerAdmin ? [PEER_LINKS_FEATURE] : []),
|
|
1789
2399
|
// Whether this daemon runs confirmed checks and holds the orchestrator's
|
|
1790
2400
|
// workspace lock. Only with a real service behind it (--allow-shell) that
|
|
1791
2401
|
// is not switched off right now (see SidecarChecks.unavailable).
|
|
@@ -1826,7 +2436,7 @@ export class SidecarServer {
|
|
|
1826
2436
|
];
|
|
1827
2437
|
this.ch.send({
|
|
1828
2438
|
t: 'welcome', tools, features,
|
|
1829
|
-
...(
|
|
2439
|
+
...(info ? { info } : {}),
|
|
1830
2440
|
...(dirName ? { dirName } : {}),
|
|
1831
2441
|
...(this.opts.push ? { vapidPublicKey: this.opts.push.publicKey } : {}),
|
|
1832
2442
|
...(this.opts.identity ? { identity: this.identityResult } : {}),
|
|
@@ -1894,6 +2504,16 @@ export class SidecarServer {
|
|
|
1894
2504
|
const id = m.id;
|
|
1895
2505
|
try {
|
|
1896
2506
|
const sessions = this.requireSessions();
|
|
2507
|
+
/* TEAM AGENT ACCESS (Part B, Task 16c): in team mode a session is its
|
|
2508
|
+
* verified creator's and the admin's. Decided before anything is
|
|
2509
|
+
* touched; `session-list` is filtered where it is answered. */
|
|
2510
|
+
if (this.teamAccess()) {
|
|
2511
|
+
const refusal = await this.sessionRefusal(sessions, m);
|
|
2512
|
+
if (refusal) {
|
|
2513
|
+
this.ch.send({ t: 'session-error', id, error: refusal });
|
|
2514
|
+
return;
|
|
2515
|
+
}
|
|
2516
|
+
}
|
|
1897
2517
|
switch (m.t) {
|
|
1898
2518
|
case 'session-create':
|
|
1899
2519
|
this.ch.send({
|
|
@@ -1912,6 +2532,9 @@ export class SidecarServer {
|
|
|
1912
2532
|
// else's. `m.owner` is ignored; a connection that sent no
|
|
1913
2533
|
// clientId files the session under nobody.
|
|
1914
2534
|
...(this.clientId ? { owner: this.clientId } : {}),
|
|
2535
|
+
// The VERIFIED user, from the connection (Task 16c): what team
|
|
2536
|
+
// mode decides on. `owner` above is the claimed client id.
|
|
2537
|
+
...(this.verifiedUserId ? { verifiedOwner: this.verifiedUserId } : {}),
|
|
1915
2538
|
...(m.fromSnapshot ? { fromSnapshot: m.fromSnapshot } : {}),
|
|
1916
2539
|
// From the HANDSHAKE, never from this message: the dot-dir is a
|
|
1917
2540
|
// property of the client's workspace, and taking it from the
|
|
@@ -1949,7 +2572,11 @@ export class SidecarServer {
|
|
|
1949
2572
|
// question "what could I restore from?" has an answer there, and it
|
|
1950
2573
|
// is "nothing". The client uses it to decide whether to offer the
|
|
1951
2574
|
// choice at all, and an error would make it look broken instead.
|
|
1952
|
-
const
|
|
2575
|
+
const listed = sessions.snapshots ? await sessions.snapshots() : [];
|
|
2576
|
+
// Team mode (Task 16c): a member sees only snapshots of sessions
|
|
2577
|
+
// they created; the admin sees every one.
|
|
2578
|
+
const own = this.teamAccess() && !this.isSessionAdmin() ? this.ownSessionIds(sessions) : null;
|
|
2579
|
+
const snapshots = own ? listed.filter((s) => s.sessionId !== undefined && own.has(s.sessionId)) : listed;
|
|
1953
2580
|
this.ch.send({ t: 'session-snapshots-result', id, snapshots });
|
|
1954
2581
|
return;
|
|
1955
2582
|
}
|
|
@@ -2135,8 +2762,47 @@ export class SidecarServer {
|
|
|
2135
2762
|
* exactly the line someone is scrolling back to find. */
|
|
2136
2763
|
if (tool.mutates === true)
|
|
2137
2764
|
this.opts.log?.(`invoke ${m.name}`);
|
|
2765
|
+
/* TEAM AGENT ACCESS (Part B): a mutating tool never touches the access
|
|
2766
|
+
* store; a folder holding a crew is the admin's; a team agent's folder is
|
|
2767
|
+
* its owner's or the admin's, except the first write into a NEW agent,
|
|
2768
|
+
* which claims it for the writer. Reads are not gated here. Anything that
|
|
2769
|
+
* cannot be decided, and any port throw, is a refusal. */
|
|
2770
|
+
const access = tool.mutates === true ? this.teamAccess() : null;
|
|
2771
|
+
let target = null;
|
|
2772
|
+
if (access) {
|
|
2773
|
+
let refusal;
|
|
2774
|
+
try {
|
|
2775
|
+
target = await access.invokeTarget(m.name, m.args);
|
|
2776
|
+
refusal = target ? await this.invokeRefusal(access, target, m.name) : null;
|
|
2777
|
+
}
|
|
2778
|
+
catch (e) {
|
|
2779
|
+
// The detail (a path, an errno) stays in the log, never on the wire.
|
|
2780
|
+
this.opts.log?.(`invoke ${m.name}: access check threw: ${errMessage(e)}`);
|
|
2781
|
+
refusal = ACCESS_CHECK_FAILED;
|
|
2782
|
+
}
|
|
2783
|
+
if (refusal) {
|
|
2784
|
+
this.opts.log?.(`invoke ${m.name} refused: ${refusal}`);
|
|
2785
|
+
this.ch.send({ t: 'invoke-result', id: m.id, ok: false, error: refusal });
|
|
2786
|
+
return;
|
|
2787
|
+
}
|
|
2788
|
+
}
|
|
2138
2789
|
try {
|
|
2139
2790
|
const result = await tool.run(m.args, this.makeContext());
|
|
2791
|
+
if (access && target?.kind === 'agent') {
|
|
2792
|
+
const t = target;
|
|
2793
|
+
try {
|
|
2794
|
+
if (m.name === 'sidecar_fs_delete') {
|
|
2795
|
+
if (t.whole)
|
|
2796
|
+
await access.onAgentDeleted(t.ref);
|
|
2797
|
+
}
|
|
2798
|
+
else if (t.isNew) {
|
|
2799
|
+
await access.onAgentCreated(t.ref, this.verifiedUserId);
|
|
2800
|
+
}
|
|
2801
|
+
}
|
|
2802
|
+
catch (err) {
|
|
2803
|
+
this.opts.log?.(`access: could not record an agent change: ${errMessage(err)}`);
|
|
2804
|
+
}
|
|
2805
|
+
}
|
|
2140
2806
|
this.ch.send({ t: 'invoke-result', id: m.id, ok: true, result });
|
|
2141
2807
|
}
|
|
2142
2808
|
catch (e) {
|
|
@@ -2229,6 +2895,22 @@ export class SidecarServer {
|
|
|
2229
2895
|
this.ch.send({ t: 'run-error', id: m.id, error: 'this sidecar does not support watching runs' });
|
|
2230
2896
|
return;
|
|
2231
2897
|
}
|
|
2898
|
+
/* TEAM AGENT ACCESS (Part B): the run's requester, or, for a run nobody
|
|
2899
|
+
* asked for, its agent's owner or the admin. Never another member's
|
|
2900
|
+
* attended run, whoever the caller is (decision D5). */
|
|
2901
|
+
if (this.teamAccess()) {
|
|
2902
|
+
const refuse = () => this.ch.send({ t: 'run-error', id: m.id, error: WATCH_REFUSED });
|
|
2903
|
+
void this.mayReadRun(m.runId).then((ok) => {
|
|
2904
|
+
if (this.closed)
|
|
2905
|
+
return;
|
|
2906
|
+
if (!ok) {
|
|
2907
|
+
refuse();
|
|
2908
|
+
return;
|
|
2909
|
+
}
|
|
2910
|
+
this.watchRun(m, hub);
|
|
2911
|
+
}, refuse);
|
|
2912
|
+
return;
|
|
2913
|
+
}
|
|
2232
2914
|
/* TEAM MODE: A RUN IS ITS STARTER'S TO WATCH. Watching streams the run's
|
|
2233
2915
|
* events and its final text, which is its content, so it follows the same
|
|
2234
2916
|
* rule as a run's output: only the verified user who started it. A run
|
|
@@ -2240,6 +2922,10 @@ export class SidecarServer {
|
|
|
2240
2922
|
return;
|
|
2241
2923
|
}
|
|
2242
2924
|
}
|
|
2925
|
+
this.watchRun(m, hub);
|
|
2926
|
+
}
|
|
2927
|
+
/** Attach THIS connection to `hub` as a viewer of `m.runId`, replay first. */
|
|
2928
|
+
watchRun(m, hub) {
|
|
2243
2929
|
const attached = hub.subscribe(m.runId, {
|
|
2244
2930
|
event: (event) => this.ch.send({ t: 'event', id: m.id, runId: m.runId, event }),
|
|
2245
2931
|
done: (r) => {
|
|
@@ -2250,6 +2936,7 @@ export class SidecarServer {
|
|
|
2250
2936
|
this.ch.send({ t: 'run-error', id: m.id, error: message });
|
|
2251
2937
|
detachOnce();
|
|
2252
2938
|
},
|
|
2939
|
+
child: (event) => this.ch.send({ t: 'child-event', id: m.id, runId: m.runId, event }),
|
|
2253
2940
|
});
|
|
2254
2941
|
if (!attached) {
|
|
2255
2942
|
this.ch.send({ t: 'run-error', id: m.id, error: `no such run: ${m.runId}` });
|
|
@@ -2281,18 +2968,27 @@ export class SidecarServer {
|
|
|
2281
2968
|
detachOnce();
|
|
2282
2969
|
}
|
|
2283
2970
|
}
|
|
2284
|
-
/** Stop a run
|
|
2285
|
-
*
|
|
2971
|
+
/** Stop a run. Solo mode: same trust as `run`, a connection that may start
|
|
2972
|
+
* runs on this daemon may stop them. Team mode with per-agent access
|
|
2973
|
+
* (Part B): the run's requester, or its agent's owner or the admin. */
|
|
2286
2974
|
onCancelRun(m) {
|
|
2287
2975
|
const cancel = this.opts.runner?.cancel;
|
|
2288
2976
|
if (!cancel) {
|
|
2289
2977
|
this.ch.send({ t: 'cancel-run-result', id: m.id, runId: m.runId, ok: false, reason: 'unsupported' });
|
|
2290
2978
|
return;
|
|
2291
2979
|
}
|
|
2292
|
-
const
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2980
|
+
const doCancel = () => {
|
|
2981
|
+
const out = cancel.call(this.opts.runner, m.runId);
|
|
2982
|
+
this.ch.send(out === 'cancelled'
|
|
2983
|
+
? { t: 'cancel-run-result', id: m.id, runId: m.runId, ok: true }
|
|
2984
|
+
: { t: 'cancel-run-result', id: m.id, runId: m.runId, ok: false, reason: 'no-such-run' });
|
|
2985
|
+
};
|
|
2986
|
+
if (this.teamAccess()) {
|
|
2987
|
+
const refuse = () => this.ch.send({ t: 'cancel-run-result', id: m.id, runId: m.runId, ok: false, reason: 'not-allowed' });
|
|
2988
|
+
void this.mayCancelRun(m.runId).then((ok) => (this.closed ? undefined : ok ? doCancel() : refuse()), refuse);
|
|
2989
|
+
return;
|
|
2990
|
+
}
|
|
2991
|
+
doCancel();
|
|
2296
2992
|
}
|
|
2297
2993
|
/**
|
|
2298
2994
|
* What this caller's agent has actually done.
|
|
@@ -2309,10 +3005,13 @@ export class SidecarServer {
|
|
|
2309
3005
|
const history = this.opts.runner?.history?.bind(this.opts.runner);
|
|
2310
3006
|
const crew = runHistoryCrewOf(m.crew);
|
|
2311
3007
|
const identity = { clientId: this.clientId };
|
|
2312
|
-
//
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
3008
|
+
// The viewer is THIS connection's verified user (team agent access).
|
|
3009
|
+
// No verified user and no crew: asked exactly as an older client asks.
|
|
3010
|
+
const viewer = this.verifiedUserId;
|
|
3011
|
+
const runs = !history ? []
|
|
3012
|
+
: viewer !== undefined ? await history(identity, m.agent, m.limit, crew, viewer)
|
|
3013
|
+
: crew ? await history(identity, m.agent, m.limit, crew)
|
|
3014
|
+
: await history(identity, m.agent, m.limit);
|
|
2316
3015
|
this.ch.send({ t: 'run-history-result', id: m.id, runs });
|
|
2317
3016
|
}
|
|
2318
3017
|
catch {
|
|
@@ -2325,9 +3024,11 @@ export class SidecarServer {
|
|
|
2325
3024
|
* case, and a state the caller renders rather than treats as a failure. */
|
|
2326
3025
|
async onRunTranscript(m) {
|
|
2327
3026
|
try {
|
|
2328
|
-
const
|
|
2329
|
-
|
|
2330
|
-
|
|
3027
|
+
const transcript = this.opts.runner?.transcript?.bind(this.opts.runner);
|
|
3028
|
+
const identity = { clientId: this.clientId };
|
|
3029
|
+
const lines = !transcript ? []
|
|
3030
|
+
: this.verifiedUserId !== undefined ? await transcript(identity, m.agent, m.runId, this.verifiedUserId)
|
|
3031
|
+
: await transcript(identity, m.agent, m.runId);
|
|
2331
3032
|
this.ch.send({ t: 'run-transcript-result', id: m.id, lines });
|
|
2332
3033
|
}
|
|
2333
3034
|
catch {
|
|
@@ -2341,9 +3042,11 @@ export class SidecarServer {
|
|
|
2341
3042
|
* a path. */
|
|
2342
3043
|
async onCrewMemory(m) {
|
|
2343
3044
|
try {
|
|
2344
|
-
const
|
|
2345
|
-
|
|
2346
|
-
|
|
3045
|
+
const runner = this.opts.runner;
|
|
3046
|
+
// The viewer is THIS connection's verified user (team agent access).
|
|
3047
|
+
const memories = !runner?.memories ? []
|
|
3048
|
+
: this.verifiedUserId !== undefined ? await runner.memories({ clientId: this.clientId }, m.agent, this.verifiedUserId)
|
|
3049
|
+
: await runner.memories({ clientId: this.clientId }, m.agent);
|
|
2347
3050
|
this.ch.send({ t: 'crew-memory-result', id: m.id, memories });
|
|
2348
3051
|
}
|
|
2349
3052
|
catch {
|
|
@@ -2354,6 +3057,18 @@ export class SidecarServer {
|
|
|
2354
3057
|
}
|
|
2355
3058
|
}
|
|
2356
3059
|
async onRun(m) {
|
|
3060
|
+
/* TEAM AGENT ACCESS (Part B, Task 16c): running an agent inside a session
|
|
3061
|
+
* writes into that session's workdir and guest, so it takes the same
|
|
3062
|
+
* ownership as every other session operation: the recorded verified
|
|
3063
|
+
* creator, or the admin. Before anything else, and on top of the per-agent
|
|
3064
|
+
* run gate. */
|
|
3065
|
+
if (m.sessionId !== undefined && this.teamAccess()) {
|
|
3066
|
+
const sessions = this.opts.sessions;
|
|
3067
|
+
if (!sessions || !this.maySession(sessions, m.sessionId)) {
|
|
3068
|
+
this.ch.send({ t: 'run-error', id: m.id, error: SESSION_REFUSED });
|
|
3069
|
+
return;
|
|
3070
|
+
}
|
|
3071
|
+
}
|
|
2357
3072
|
/* WHERE the run happens, decided once, up front.
|
|
2358
3073
|
*
|
|
2359
3074
|
* A `sessionId` is a confinement request. A daemon that cannot honour it
|
|
@@ -2513,7 +3228,15 @@ export class SidecarServer {
|
|
|
2513
3228
|
const devices = this.opts.devices;
|
|
2514
3229
|
if (!devices)
|
|
2515
3230
|
return [];
|
|
2516
|
-
|
|
3231
|
+
/* A PENDING member (Part E, preflight B2) sees only their own devices:
|
|
3232
|
+
* the store lists every device and its verified user, and devices-list is
|
|
3233
|
+
* on the member gate's allowlist only because of this filter. Matched on
|
|
3234
|
+
* THIS connection's verified user; none, no devices. */
|
|
3235
|
+
const pending = this.memberPending();
|
|
3236
|
+
const own = this.verifiedUserId;
|
|
3237
|
+
const listed = await devices.list();
|
|
3238
|
+
const shown = pending ? listed.filter((d) => own !== undefined && d.verifiedUserId === own) : listed;
|
|
3239
|
+
return shown.map((d) => ({
|
|
2517
3240
|
id: d.id,
|
|
2518
3241
|
name: d.name,
|
|
2519
3242
|
jkt: d.jkt,
|
|
@@ -2533,7 +3256,8 @@ export class SidecarServer {
|
|
|
2533
3256
|
this.ch.send({ t: 'devices-list-result', id: m.id, ok: true, devices: await this.deviceSummaries() });
|
|
2534
3257
|
}
|
|
2535
3258
|
catch (err) {
|
|
2536
|
-
this.
|
|
3259
|
+
this.opts.log?.(`devices-list failed: ${errMessage(err)}`);
|
|
3260
|
+
this.ch.send({ t: 'devices-list-result', id: m.id, ok: false, error: DEVICES_FAILED });
|
|
2537
3261
|
}
|
|
2538
3262
|
}
|
|
2539
3263
|
/**
|
|
@@ -2567,13 +3291,17 @@ export class SidecarServer {
|
|
|
2567
3291
|
* connection's VERIFIED user (never anything the message claims). A
|
|
2568
3292
|
* connection with no verified user revokes nothing. The operator's local
|
|
2569
3293
|
* `devices revoke` verb keeps full power; it does not come through here. */
|
|
2570
|
-
|
|
3294
|
+
// A pending member (Part E) is held to the same rule, whatever the
|
|
3295
|
+
// admission says: devices-revoke is on the member gate's allowlist only
|
|
3296
|
+
// as an own-devices message.
|
|
3297
|
+
if (this.opts.admission?.ownDevicesOnly || this.memberPending()) {
|
|
2571
3298
|
let owner;
|
|
2572
3299
|
try {
|
|
2573
3300
|
owner = (await devices.get(m.deviceId))?.verifiedUserId;
|
|
2574
3301
|
}
|
|
2575
3302
|
catch (err) {
|
|
2576
|
-
this.
|
|
3303
|
+
this.opts.log?.(`devices-revoke failed: ${errMessage(err)}`);
|
|
3304
|
+
this.ch.send({ t: 'devices-revoke-result', id: m.id, ok: false, error: DEVICES_FAILED });
|
|
2577
3305
|
return;
|
|
2578
3306
|
}
|
|
2579
3307
|
if (!this.verifiedUserId || owner !== this.verifiedUserId) {
|
|
@@ -2594,7 +3322,8 @@ export class SidecarServer {
|
|
|
2594
3322
|
this.ch.send({ t: 'devices-revoke-result', id: m.id, ok: true, devices: await this.deviceSummaries() });
|
|
2595
3323
|
}
|
|
2596
3324
|
catch (err) {
|
|
2597
|
-
this.
|
|
3325
|
+
this.opts.log?.(`devices-revoke failed: ${errMessage(err)}`);
|
|
3326
|
+
this.ch.send({ t: 'devices-revoke-result', id: m.id, ok: false, error: DEVICES_FAILED });
|
|
2598
3327
|
}
|
|
2599
3328
|
}
|
|
2600
3329
|
/** Every synced host directory, with `self` stamped from THIS connection's
|
|
@@ -2783,6 +3512,11 @@ export class SidecarServer {
|
|
|
2783
3512
|
this.ch.send({ t: 'approvals-accept-result', id: m.id, ok: false, error: 'suggestionId must be a non-empty string and choice one of global, not-now, never' });
|
|
2784
3513
|
return;
|
|
2785
3514
|
}
|
|
3515
|
+
const noUser = this.approvalsNoUser();
|
|
3516
|
+
if (noUser !== null) {
|
|
3517
|
+
this.ch.send({ t: 'approvals-accept-result', id: m.id, ok: false, error: noUser });
|
|
3518
|
+
return;
|
|
3519
|
+
}
|
|
2786
3520
|
// A connection that never attached to attendance was never offered
|
|
2787
3521
|
// anything, and has no identity to accept with.
|
|
2788
3522
|
if (this.attended === undefined) {
|
|
@@ -2792,12 +3526,13 @@ export class SidecarServer {
|
|
|
2792
3526
|
try {
|
|
2793
3527
|
// `this.attended` -- the object THIS connection attached -- is the only
|
|
2794
3528
|
// accepter identity; the daemon compares it with the client it offered
|
|
2795
|
-
// to. Nothing in the message can name another connection
|
|
2796
|
-
|
|
3529
|
+
// to. Nothing in the message can name another connection, or another
|
|
3530
|
+
// user: `verifiedUserId` is the one this connection proved.
|
|
3531
|
+
await port.accept(this.attended, m.suggestionId, m.choice, this.verifiedUserId);
|
|
2797
3532
|
this.ch.send({ t: 'approvals-accept-result', id: m.id, ok: true });
|
|
2798
3533
|
}
|
|
2799
3534
|
catch (err) {
|
|
2800
|
-
this.ch.send({ t: 'approvals-accept-result', id: m.id, ok: false, error:
|
|
3535
|
+
this.ch.send({ t: 'approvals-accept-result', id: m.id, ok: false, error: this.approvalsError(m.t, err) });
|
|
2801
3536
|
}
|
|
2802
3537
|
}
|
|
2803
3538
|
async onApprovalsList(m) {
|
|
@@ -2806,11 +3541,16 @@ export class SidecarServer {
|
|
|
2806
3541
|
this.ch.send({ t: 'approvals-list-result', id: m.id, ok: false, error: 'this daemon does not keep standing approvals' });
|
|
2807
3542
|
return;
|
|
2808
3543
|
}
|
|
3544
|
+
const noUser = this.approvalsNoUser();
|
|
3545
|
+
if (noUser !== null) {
|
|
3546
|
+
this.ch.send({ t: 'approvals-list-result', id: m.id, ok: false, error: noUser });
|
|
3547
|
+
return;
|
|
3548
|
+
}
|
|
2809
3549
|
try {
|
|
2810
|
-
this.ch.send({ t: 'approvals-list-result', id: m.id, ok: true, state: await port.state() });
|
|
3550
|
+
this.ch.send({ t: 'approvals-list-result', id: m.id, ok: true, state: await port.state(m.ref, this.verifiedUserId) });
|
|
2811
3551
|
}
|
|
2812
3552
|
catch (err) {
|
|
2813
|
-
this.ch.send({ t: 'approvals-list-result', id: m.id, ok: false, error:
|
|
3553
|
+
this.ch.send({ t: 'approvals-list-result', id: m.id, ok: false, error: this.approvalsError(m.t, err) });
|
|
2814
3554
|
}
|
|
2815
3555
|
}
|
|
2816
3556
|
async onApprovalsRevoke(m) {
|
|
@@ -2823,13 +3563,40 @@ export class SidecarServer {
|
|
|
2823
3563
|
this.ch.send({ t: 'approvals-revoke-result', id: m.id, ok: false, error: 'tool must be a non-empty string' });
|
|
2824
3564
|
return;
|
|
2825
3565
|
}
|
|
3566
|
+
const noUser = this.approvalsNoUser();
|
|
3567
|
+
if (noUser !== null) {
|
|
3568
|
+
this.ch.send({ t: 'approvals-revoke-result', id: m.id, ok: false, error: noUser });
|
|
3569
|
+
return;
|
|
3570
|
+
}
|
|
3571
|
+
// Team mode has no machine-wide rules to revoke: a revoke names its agent.
|
|
3572
|
+
// The port refuses this too; refused here first so it never reaches it.
|
|
3573
|
+
if (this.opts.teamMode && (m.ref === undefined || m.ref === null)) {
|
|
3574
|
+
this.ch.send({ t: 'approvals-revoke-result', id: m.id, ok: false, error: APPROVALS_NAME_AGENT });
|
|
3575
|
+
return;
|
|
3576
|
+
}
|
|
2826
3577
|
try {
|
|
2827
|
-
this.ch.send({ t: 'approvals-revoke-result', id: m.id, ok: true, state: await port.revoke(m.tool) });
|
|
3578
|
+
this.ch.send({ t: 'approvals-revoke-result', id: m.id, ok: true, state: await port.revoke(m.tool, m.ref, this.verifiedUserId) });
|
|
2828
3579
|
}
|
|
2829
3580
|
catch (err) {
|
|
2830
|
-
this.ch.send({ t: 'approvals-revoke-result', id: m.id, ok: false, error:
|
|
3581
|
+
this.ch.send({ t: 'approvals-revoke-result', id: m.id, ok: false, error: this.approvalsError(m.t, err) });
|
|
2831
3582
|
}
|
|
2832
3583
|
}
|
|
3584
|
+
/** Team mode: every approvals message needs the user this connection
|
|
3585
|
+
* proved, since a team agent's rules are its owner's to change and its
|
|
3586
|
+
* members' to see. Null when the message may go on to the port. Solo mode
|
|
3587
|
+
* never refuses here. */
|
|
3588
|
+
approvalsNoUser() {
|
|
3589
|
+
return this.opts.teamMode && !this.verifiedUserId ? APPROVALS_NO_USER : null;
|
|
3590
|
+
}
|
|
3591
|
+
/** What an approvals port error puts on the wire. Solo mode: its message,
|
|
3592
|
+
* as always. Team mode: an ApprovalsRefusal's reason, and for anything else
|
|
3593
|
+
* (a path, an errno) fixed text, the detail kept in the daemon log. */
|
|
3594
|
+
approvalsError(op, err) {
|
|
3595
|
+
if (!this.opts.teamMode || isApprovalsRefusal(err))
|
|
3596
|
+
return errMessage(err);
|
|
3597
|
+
this.opts.log?.(`approvals: ${op} threw: ${errMessage(err)}`);
|
|
3598
|
+
return APPROVALS_FAILED;
|
|
3599
|
+
}
|
|
2833
3600
|
/** Why the checks port cannot answer right now, or null when it can. */
|
|
2834
3601
|
checksRefusal() {
|
|
2835
3602
|
if (!this.opts.checks)
|
|
@@ -2915,7 +3682,16 @@ export class SidecarServer {
|
|
|
2915
3682
|
* run (recorded with it, restarts included). A run with no recorded
|
|
2916
3683
|
* starter (the daemon's own clock, or an unverified start) is refused
|
|
2917
3684
|
* too: fail closed. The plan alone is not gated. */
|
|
2918
|
-
|
|
3685
|
+
/* TEAM AGENT ACCESS (Part B): a run's plan and its output are its
|
|
3686
|
+
* requester's, or, for a run nobody asked for, its agent owner's and the
|
|
3687
|
+
* admin's (D5). The plan is the run's content too: the same rule. */
|
|
3688
|
+
if (this.teamAccess()) {
|
|
3689
|
+
if (!(await this.mayReadRun(m.runId))) {
|
|
3690
|
+
this.ch.send({ t: 'run-plan-get-result', id: m.id, ok: false, error: OUTPUT_REFUSED });
|
|
3691
|
+
return;
|
|
3692
|
+
}
|
|
3693
|
+
}
|
|
3694
|
+
else if (output && this.opts.admission?.ownRunsOnly) {
|
|
2919
3695
|
const starter = runner.runStartedBy ? await runner.runStartedBy(m.runId) : null;
|
|
2920
3696
|
if (!this.verifiedUserId || starter !== this.verifiedUserId) {
|
|
2921
3697
|
this.ch.send({ t: 'run-plan-get-result', id: m.id, ok: false, error: 'You can see the output only of runs you started.' });
|
|
@@ -3042,6 +3818,11 @@ export class SidecarServer {
|
|
|
3042
3818
|
});
|
|
3043
3819
|
return;
|
|
3044
3820
|
}
|
|
3821
|
+
const runRefusal = await this.roomRunRefusal(m.req.participants);
|
|
3822
|
+
if (runRefusal !== null) {
|
|
3823
|
+
this.ch.send({ t: 'room-open-result', id: m.id, ok: false, problems: [runRefusal] });
|
|
3824
|
+
return;
|
|
3825
|
+
}
|
|
3045
3826
|
const clientId = this.roomIdentity();
|
|
3046
3827
|
const owner = clientId === undefined
|
|
3047
3828
|
? undefined
|
|
@@ -3153,6 +3934,11 @@ export class SidecarServer {
|
|
|
3153
3934
|
return;
|
|
3154
3935
|
}
|
|
3155
3936
|
try {
|
|
3937
|
+
const refusal = await this.roomDriveRefusal(m.roomId);
|
|
3938
|
+
if (refusal !== null) {
|
|
3939
|
+
this.ch.send({ t: 'room-resume-result', id: m.id, ok: false, error: refusal });
|
|
3940
|
+
return;
|
|
3941
|
+
}
|
|
3156
3942
|
const room = await this.opts.rooms.resume(m.roomId);
|
|
3157
3943
|
this.ch.send({ t: 'room-resume-result', id: m.id, ok: true, room });
|
|
3158
3944
|
}
|
|
@@ -3162,6 +3948,42 @@ export class SidecarServer {
|
|
|
3162
3948
|
this.ch.send({ t: 'room-resume-result', id: m.id, ok: false, error: errMessage(err) });
|
|
3163
3949
|
}
|
|
3164
3950
|
}
|
|
3951
|
+
/** Team agent access (Part B, final review F1): a room runs every agent in
|
|
3952
|
+
* it as the person who opens or drives it, so each one needs `run` for THIS
|
|
3953
|
+
* connection's verified user, decided by the room service where the daemon
|
|
3954
|
+
* actually finds the agent. Null when allowed. Solo mode (or no access
|
|
3955
|
+
* port): null, unchanged. No verified user, no gate on the room service, or
|
|
3956
|
+
* a throw: refused. */
|
|
3957
|
+
async roomRunRefusal(participants) {
|
|
3958
|
+
if (!this.teamAccess())
|
|
3959
|
+
return null;
|
|
3960
|
+
try {
|
|
3961
|
+
const rooms = this.opts.rooms;
|
|
3962
|
+
const user = this.verifiedUserId;
|
|
3963
|
+
if (!rooms?.mayRun || !user)
|
|
3964
|
+
return ROOM_RUN_REFUSED;
|
|
3965
|
+
return (await rooms.mayRun(participants, user)) ? null : ROOM_RUN_REFUSED;
|
|
3966
|
+
}
|
|
3967
|
+
catch {
|
|
3968
|
+
return ROOM_RUN_REFUSED;
|
|
3969
|
+
}
|
|
3970
|
+
}
|
|
3971
|
+
/** `roomRunRefusal` for a room that already exists, by its recorded
|
|
3972
|
+
* participants: the verbs that drive its agents (resume, answer, interject). A room
|
|
3973
|
+
* that is not there is left to the room service's own "No room" answer. */
|
|
3974
|
+
async roomDriveRefusal(roomId) {
|
|
3975
|
+
if (!this.teamAccess())
|
|
3976
|
+
return null;
|
|
3977
|
+
try {
|
|
3978
|
+
const found = await this.opts.rooms?.read(roomId);
|
|
3979
|
+
if (!found)
|
|
3980
|
+
return null;
|
|
3981
|
+
return await this.roomRunRefusal(found.room.participants);
|
|
3982
|
+
}
|
|
3983
|
+
catch {
|
|
3984
|
+
return ROOM_RUN_REFUSED;
|
|
3985
|
+
}
|
|
3986
|
+
}
|
|
3165
3987
|
/** WHO THIS CONNECTION IS, INSIDE A ROOM.
|
|
3166
3988
|
*
|
|
3167
3989
|
* The user its pairing JWT proved (`verifiedUserId`: API-signed, aud = this
|
|
@@ -3218,6 +4040,11 @@ export class SidecarServer {
|
|
|
3218
4040
|
this.ch.send({ t: 'room-answer-result', id: m.id, ok: false, error: actor.error });
|
|
3219
4041
|
return;
|
|
3220
4042
|
}
|
|
4043
|
+
const refusal = await this.roomDriveRefusal(m.roomId);
|
|
4044
|
+
if (refusal !== null) {
|
|
4045
|
+
this.ch.send({ t: 'room-answer-result', id: m.id, ok: false, error: refusal });
|
|
4046
|
+
return;
|
|
4047
|
+
}
|
|
3221
4048
|
const room = await this.opts.rooms.answer(m.roomId, actor.id, m.text, m.addressed);
|
|
3222
4049
|
this.ch.send({ t: 'room-answer-result', id: m.id, ok: true, room });
|
|
3223
4050
|
}
|
|
@@ -3238,6 +4065,14 @@ export class SidecarServer {
|
|
|
3238
4065
|
this.ch.send({ t: 'room-interject-result', id: m.id, ok: false, error: actor.error });
|
|
3239
4066
|
return;
|
|
3240
4067
|
}
|
|
4068
|
+
/* In a conversation-mode room an interjection wakes the loop and runs
|
|
4069
|
+
* every agent (roomService.interject), so it takes the same run gate as
|
|
4070
|
+
* resume and answer. Gated in every mode: simplest, and fails closed. */
|
|
4071
|
+
const refusal = await this.roomDriveRefusal(m.roomId);
|
|
4072
|
+
if (refusal !== null) {
|
|
4073
|
+
this.ch.send({ t: 'room-interject-result', id: m.id, ok: false, error: refusal });
|
|
4074
|
+
return;
|
|
4075
|
+
}
|
|
3241
4076
|
const room = await this.opts.rooms.interject(m.roomId, actor.id, m.text);
|
|
3242
4077
|
this.ch.send({ t: 'room-interject-result', id: m.id, ok: true, room });
|
|
3243
4078
|
}
|
|
@@ -3300,9 +4135,22 @@ export class SidecarServer {
|
|
|
3300
4135
|
this.ch.send({ t: 'mcp-admin-error', id: m.id, error: 'this daemon cannot manage MCP servers -- update it and reconnect' });
|
|
3301
4136
|
return;
|
|
3302
4137
|
}
|
|
4138
|
+
if (m.t !== 'mcp-servers-list') {
|
|
4139
|
+
const refusal = this.mcpRefusal();
|
|
4140
|
+
if (refusal) {
|
|
4141
|
+
this.ch.send({ t: 'mcp-admin-error', id: m.id, error: refusal });
|
|
4142
|
+
return;
|
|
4143
|
+
}
|
|
4144
|
+
}
|
|
3303
4145
|
try {
|
|
3304
4146
|
if (m.t === 'mcp-servers-list') {
|
|
3305
|
-
|
|
4147
|
+
const servers = await admin.listServers();
|
|
4148
|
+
/* Team mode: a member who is not the admin learns which servers exist,
|
|
4149
|
+
* never where they are (a hosted URL can carry a secret in its path). */
|
|
4150
|
+
this.ch.send({
|
|
4151
|
+
t: 'mcp-servers', id: m.id,
|
|
4152
|
+
servers: this.mcpRefusal() === null ? servers : servers.map(({ name, hosted, hasCredential }) => ({ name, hosted, hasCredential })),
|
|
4153
|
+
});
|
|
3306
4154
|
return;
|
|
3307
4155
|
}
|
|
3308
4156
|
// `=== true`, not truthy: this decides whether the daemon attaches the
|
|
@@ -3333,11 +4181,111 @@ export class SidecarServer {
|
|
|
3333
4181
|
this.ch.send({ t: 'mcp-admin-error', id: m.id, error: e instanceof Error ? e.message : String(e) });
|
|
3334
4182
|
}
|
|
3335
4183
|
}
|
|
4184
|
+
/** Part D credential messages. The caller is this connection's VERIFIED
|
|
4185
|
+
* user and nothing the message says; the port decides whether that user is
|
|
4186
|
+
* the agent's owner or the admin. Solo mode refuses, as `onAccess` does. The
|
|
4187
|
+
* secret in `agent-cred-set` goes to the port and is never logged or echoed.
|
|
4188
|
+
* A refusal with no reply code goes out as fixed text (Part B's
|
|
4189
|
+
* ACCESS_FAILED rule) and its detail goes to the log. */
|
|
4190
|
+
async onAgentCred(m) {
|
|
4191
|
+
const fail = (code, error, reason) => {
|
|
4192
|
+
this.ch.send({ t: 'agent-cred-result', id: m.id, ok: false, code, error, ...(reason ? { reason } : {}) });
|
|
4193
|
+
};
|
|
4194
|
+
const port = this.opts.agentCreds;
|
|
4195
|
+
if (!port) {
|
|
4196
|
+
fail('UNAVAILABLE', 'this daemon does not manage agent credentials');
|
|
4197
|
+
return;
|
|
4198
|
+
}
|
|
4199
|
+
if (!this.teamAccess()) {
|
|
4200
|
+
fail('NOT_TEAM_AGENT', AGENT_CRED_SOLO);
|
|
4201
|
+
return;
|
|
4202
|
+
}
|
|
4203
|
+
const caller = this.verifiedUserId;
|
|
4204
|
+
if (!caller) {
|
|
4205
|
+
fail('NOT_OWNER', 'this connection has no verified identity');
|
|
4206
|
+
return;
|
|
4207
|
+
}
|
|
4208
|
+
if (typeof m.server !== 'string' || !m.server || m.server.length > MAX_IDENTITY_FIELD_LEN || AGENT_CRED_RESERVED_SERVERS.has(m.server)) {
|
|
4209
|
+
fail('BAD_REQUEST', 'server must name an MCP server');
|
|
4210
|
+
return;
|
|
4211
|
+
}
|
|
4212
|
+
/* Every shape check runs here, BEFORE the port: a malformed request never
|
|
4213
|
+
* reaches the code that writes auth.json. */
|
|
4214
|
+
if (m.t === 'access-bind' && m.binding !== null && m.binding !== 'asker' && m.binding !== 'agent') {
|
|
4215
|
+
fail('BAD_REQUEST', 'binding must be asker, agent or null');
|
|
4216
|
+
return;
|
|
4217
|
+
}
|
|
4218
|
+
if (m.t === 'agent-cred-set') {
|
|
4219
|
+
const bad = agentCredHeadersProblem(m.headers);
|
|
4220
|
+
if (bad) {
|
|
4221
|
+
fail('BAD_REQUEST', bad);
|
|
4222
|
+
return;
|
|
4223
|
+
}
|
|
4224
|
+
}
|
|
4225
|
+
const server = logSafe(m.server);
|
|
4226
|
+
/* The outcome is decided inside the try and sent after it, so a `send`
|
|
4227
|
+
* that throws cannot be caught here and answered a second time. */
|
|
4228
|
+
let outcome;
|
|
4229
|
+
try {
|
|
4230
|
+
if (m.t === 'access-bind') {
|
|
4231
|
+
await port.bind(m.ref, caller, m.server, m.binding);
|
|
4232
|
+
outcome = { ok: true };
|
|
4233
|
+
}
|
|
4234
|
+
else if (m.t === 'agent-cred-set') {
|
|
4235
|
+
await port.setHeaders(m.ref, caller, m.server, m.headers);
|
|
4236
|
+
outcome = { ok: true };
|
|
4237
|
+
}
|
|
4238
|
+
else if (m.t === 'agent-mcp-login') {
|
|
4239
|
+
const out = await port.login(m.ref, caller, m.server, (url) => this.ch.send({ t: 'mcp-login-url', id: m.id, url }));
|
|
4240
|
+
if (out.ok)
|
|
4241
|
+
outcome = { ok: true };
|
|
4242
|
+
/* no-oauth and no-dcr carry an authored sentence. 'failed' carries
|
|
4243
|
+
* whatever broke mid-flow (an errno, a host), which stays in the log. */
|
|
4244
|
+
else if (out.reason === 'failed') {
|
|
4245
|
+
this.opts.log?.(`agent credentials: ${m.t} for "${server}" failed: ${out.message}`);
|
|
4246
|
+
outcome = { ok: false, code: 'LOGIN_FAILED', error: AGENT_CRED_LOGIN_FAILED, reason: out.reason };
|
|
4247
|
+
}
|
|
4248
|
+
else {
|
|
4249
|
+
outcome = { ok: false, code: 'LOGIN_FAILED', error: out.message, reason: out.reason };
|
|
4250
|
+
}
|
|
4251
|
+
}
|
|
4252
|
+
else {
|
|
4253
|
+
await port.clear(m.ref, caller, m.server);
|
|
4254
|
+
outcome = { ok: true };
|
|
4255
|
+
}
|
|
4256
|
+
}
|
|
4257
|
+
catch (err) {
|
|
4258
|
+
const code = agentCredCodeOf(err);
|
|
4259
|
+
if (code) {
|
|
4260
|
+
outcome = { ok: false, code, error: errMessage(err) };
|
|
4261
|
+
}
|
|
4262
|
+
else {
|
|
4263
|
+
// The detail (a path, an errno) stays in the log, never on the wire.
|
|
4264
|
+
this.opts.log?.(`agent credentials: ${m.t} for "${server}" threw: ${errMessage(err)}`);
|
|
4265
|
+
outcome = { ok: false, code: 'UNAVAILABLE', error: AGENT_CRED_FAILED };
|
|
4266
|
+
}
|
|
4267
|
+
}
|
|
4268
|
+
try {
|
|
4269
|
+
if (outcome.ok)
|
|
4270
|
+
this.ch.send({ t: 'agent-cred-result', id: m.id, ok: true });
|
|
4271
|
+
else
|
|
4272
|
+
fail(outcome.code, outcome.error, outcome.reason);
|
|
4273
|
+
}
|
|
4274
|
+
catch (err) {
|
|
4275
|
+
// One reply per request: a channel that cannot carry it is logged, not answered again.
|
|
4276
|
+
this.opts.log?.(`agent credentials: could not send the ${m.t} reply: ${errMessage(err)}`);
|
|
4277
|
+
}
|
|
4278
|
+
}
|
|
3336
4279
|
async onMcpCall(m) {
|
|
3337
4280
|
if (!this.opts.mcp) {
|
|
3338
4281
|
this.ch.send({ t: 'mcp-error', id: m.id, error: 'this daemon serves no MCP tools' });
|
|
3339
4282
|
return;
|
|
3340
4283
|
}
|
|
4284
|
+
const refusal = this.mcpRefusal();
|
|
4285
|
+
if (refusal) {
|
|
4286
|
+
this.ch.send({ t: 'mcp-error', id: m.id, error: refusal });
|
|
4287
|
+
return;
|
|
4288
|
+
}
|
|
3341
4289
|
const parsed = parseMcpToolAddress(m.address);
|
|
3342
4290
|
if (!parsed) {
|
|
3343
4291
|
this.ch.send({ t: 'mcp-error', id: m.id, error: `"${m.address}" is not a valid MCP tool address` });
|
|
@@ -3356,6 +4304,675 @@ export class SidecarServer {
|
|
|
3356
4304
|
this.ch.send({ t: 'mcp-error', id: m.id, error: errMessage(err) });
|
|
3357
4305
|
}
|
|
3358
4306
|
}
|
|
4307
|
+
/** One access-* request, answered for THIS connection's verified user. Solo
|
|
4308
|
+
* mode (or no port) refuses without asking. A port that throws becomes a
|
|
4309
|
+
* refusal: it must never kill the connection or leave the request unanswered. */
|
|
4310
|
+
async onAccess(m) {
|
|
4311
|
+
const port = this.opts.access;
|
|
4312
|
+
if (!port || !port.teamMode) {
|
|
4313
|
+
this.ch.send({ t: 'access-result', id: m.id, ok: false, code: 'NOT_TEAM_AGENT', error: 'this daemon does not keep agent access' });
|
|
4314
|
+
return;
|
|
4315
|
+
}
|
|
4316
|
+
let reply;
|
|
4317
|
+
try {
|
|
4318
|
+
reply = await port.handle(this.verifiedUserId, m);
|
|
4319
|
+
}
|
|
4320
|
+
catch (err) {
|
|
4321
|
+
// The detail (a path, an errno) stays in the log, never on the wire.
|
|
4322
|
+
this.opts.log?.(`access: ${m.t} threw: ${errMessage(err)}`);
|
|
4323
|
+
reply = { ok: false, code: 'NO_ACCESS', error: ACCESS_FAILED };
|
|
4324
|
+
}
|
|
4325
|
+
this.ch.send(reply.ok
|
|
4326
|
+
? { t: 'access-result', id: m.id, ok: true, ...(reply.access ? { access: reply.access } : {}), ...(reply.members ? { members: reply.members } : {}) }
|
|
4327
|
+
: { t: 'access-result', id: m.id, ok: false, code: reply.code, error: reply.error });
|
|
4328
|
+
}
|
|
4329
|
+
/** Team mode with a member list, and this connection's verified user is not
|
|
4330
|
+
* an active member right now. Read fresh on every call. Member status
|
|
4331
|
+
* ALONE: a peer daemon connection is exempted from the request gate in
|
|
4332
|
+
* onMessage (it is served only PEER_DAEMON_MESSAGES there), never here, so
|
|
4333
|
+
* a claimed peer cannot lift the welcome strip. A port that throws reads as
|
|
4334
|
+
* pending (fail closed): it runs inside onMessage and every broadcast. */
|
|
4335
|
+
memberPending() {
|
|
4336
|
+
const members = this.opts.members;
|
|
4337
|
+
if (members === undefined)
|
|
4338
|
+
return false;
|
|
4339
|
+
try {
|
|
4340
|
+
return members.statusOf(this.verifiedUserId) !== 'active';
|
|
4341
|
+
}
|
|
4342
|
+
catch {
|
|
4343
|
+
return true;
|
|
4344
|
+
}
|
|
4345
|
+
}
|
|
4346
|
+
/** One member-* request, answered for THIS connection's verified user. A
|
|
4347
|
+
* port that throws becomes a refusal with fixed text (the detail is
|
|
4348
|
+
* logged), never a dropped connection. */
|
|
4349
|
+
async onMember(m) {
|
|
4350
|
+
const port = this.opts.members;
|
|
4351
|
+
let reply;
|
|
4352
|
+
if (!port) {
|
|
4353
|
+
reply = { ok: false, code: 'UNAVAILABLE', error: 'this daemon keeps no member list (it is not in team mode)' };
|
|
4354
|
+
}
|
|
4355
|
+
else {
|
|
4356
|
+
try {
|
|
4357
|
+
reply = await port.handle(this.verifiedUserId, m);
|
|
4358
|
+
}
|
|
4359
|
+
catch (err) {
|
|
4360
|
+
this.opts.log?.(`members: ${m.t} threw: ${errMessage(err)}`);
|
|
4361
|
+
reply = { ok: false, code: 'UNAVAILABLE', error: MEMBER_FAILED };
|
|
4362
|
+
}
|
|
4363
|
+
}
|
|
4364
|
+
/* Rebuilt from named fields (like onAccess), never spread: whatever else
|
|
4365
|
+
* a port's reply carries does not reach the wire. */
|
|
4366
|
+
this.ch.send(reply.ok
|
|
4367
|
+
? {
|
|
4368
|
+
t: 'member-result', id: m.id, ok: true,
|
|
4369
|
+
...(reply.status !== undefined ? { status: reply.status } : {}),
|
|
4370
|
+
...(reply.daemonName !== undefined ? { daemonName: reply.daemonName } : {}),
|
|
4371
|
+
...(reply.adminLabel !== undefined ? { adminLabel: reply.adminLabel } : {}),
|
|
4372
|
+
...(reply.adminId !== undefined ? { adminId: reply.adminId } : {}),
|
|
4373
|
+
...(reply.admin !== undefined ? { admin: reply.admin } : {}),
|
|
4374
|
+
...(reply.members ? {
|
|
4375
|
+
members: reply.members.map((r) => ({
|
|
4376
|
+
userId: r.userId, status: r.status, firstSeen: r.firstSeen, label: r.label,
|
|
4377
|
+
...(r.approvedBy !== undefined ? { approvedBy: r.approvedBy } : {}),
|
|
4378
|
+
...(r.approvedAt !== undefined ? { approvedAt: r.approvedAt } : {}),
|
|
4379
|
+
})),
|
|
4380
|
+
} : {}),
|
|
4381
|
+
}
|
|
4382
|
+
: { t: 'member-result', id: m.id, ok: false, code: reply.code, error: reply.error });
|
|
4383
|
+
}
|
|
4384
|
+
/** Team access overview (Part E, E2), for THIS connection's verified user.
|
|
4385
|
+
* Solo mode (or no port) refuses without asking, like onAccess. A coded
|
|
4386
|
+
* refusal passes its code and authored text through; any other failure goes
|
|
4387
|
+
* out as fixed text, with the detail in the log. */
|
|
4388
|
+
async onAccessOverview(m) {
|
|
4389
|
+
const port = this.opts.access;
|
|
4390
|
+
if (!port || !port.teamMode || !port.overview) {
|
|
4391
|
+
this.ch.send({ t: 'access-overview-result', id: m.id, ok: false, code: 'NOT_TEAM_AGENT', error: 'this daemon does not keep agent access' });
|
|
4392
|
+
return;
|
|
4393
|
+
}
|
|
4394
|
+
/* The rows are rebuilt INSIDE the try, so a malformed port result (no
|
|
4395
|
+
* array, a null row) is the fixed-text failure. The reply is sent after
|
|
4396
|
+
* it, so a `send` that throws is logged, never answered a second time. */
|
|
4397
|
+
let reply;
|
|
4398
|
+
try {
|
|
4399
|
+
const rows = await port.overview(this.verifiedUserId);
|
|
4400
|
+
/* The admin-only notice must never fail the rows (owner ruling
|
|
4401
|
+
* 2026-10-06): a throw here is logged and the list goes out empty. */
|
|
4402
|
+
let inactive = [];
|
|
4403
|
+
if (port.inactiveDeployments) {
|
|
4404
|
+
try {
|
|
4405
|
+
inactive = (await port.inactiveDeployments(this.verifiedUserId)).map(inactiveDeploymentOf);
|
|
4406
|
+
}
|
|
4407
|
+
catch (err) {
|
|
4408
|
+
this.opts.log?.(`access: the inactive-deployment list threw; sent empty: ${errMessage(err)}`);
|
|
4409
|
+
}
|
|
4410
|
+
}
|
|
4411
|
+
reply = {
|
|
4412
|
+
t: 'access-overview-result', id: m.id, ok: true,
|
|
4413
|
+
rows: rows.map(accessOverviewRowOf),
|
|
4414
|
+
inactiveDeployments: inactive,
|
|
4415
|
+
};
|
|
4416
|
+
}
|
|
4417
|
+
catch (err) {
|
|
4418
|
+
const code = accessCodeOf(err);
|
|
4419
|
+
if (code) {
|
|
4420
|
+
reply = { t: 'access-overview-result', id: m.id, ok: false, code, error: errMessage(err) };
|
|
4421
|
+
}
|
|
4422
|
+
else {
|
|
4423
|
+
// The detail (a path, an errno) stays in the log, never on the wire.
|
|
4424
|
+
this.opts.log?.(`access: access-overview threw: ${errMessage(err)}`);
|
|
4425
|
+
reply = { t: 'access-overview-result', id: m.id, ok: false, code: 'NO_ACCESS', error: ACCESS_FAILED };
|
|
4426
|
+
}
|
|
4427
|
+
}
|
|
4428
|
+
try {
|
|
4429
|
+
this.ch.send(reply);
|
|
4430
|
+
}
|
|
4431
|
+
catch (err) {
|
|
4432
|
+
this.opts.log?.(`access: could not send the access-overview reply: ${errMessage(err)}`);
|
|
4433
|
+
}
|
|
4434
|
+
}
|
|
4435
|
+
/** Each MCP server's binding and credential PRESENCE for one agent (Part E,
|
|
4436
|
+
* E2). The owner or admin only, decided by the port from the verified user.
|
|
4437
|
+
* Solo mode refuses without asking, like onAgentCred. Every row is rebuilt
|
|
4438
|
+
* from its three fields here, so a port that returned more (a header, a
|
|
4439
|
+
* token) could never put it on the wire. */
|
|
4440
|
+
async onAgentCredStatus(m) {
|
|
4441
|
+
const fail = (code, error) => {
|
|
4442
|
+
this.ch.send({ t: 'agent-cred-status-result', id: m.id, ok: false, code, error });
|
|
4443
|
+
};
|
|
4444
|
+
const port = this.opts.agentCreds;
|
|
4445
|
+
if (!port?.status) {
|
|
4446
|
+
fail('UNAVAILABLE', 'this daemon does not manage agent credentials');
|
|
4447
|
+
return;
|
|
4448
|
+
}
|
|
4449
|
+
if (!this.teamAccess()) {
|
|
4450
|
+
fail('NOT_TEAM_AGENT', AGENT_CRED_SOLO);
|
|
4451
|
+
return;
|
|
4452
|
+
}
|
|
4453
|
+
const caller = this.verifiedUserId;
|
|
4454
|
+
if (!caller) {
|
|
4455
|
+
fail('NOT_OWNER', 'this connection has no verified identity');
|
|
4456
|
+
return;
|
|
4457
|
+
}
|
|
4458
|
+
/* Rows rebuilt INSIDE the try (a malformed port result is the fixed-text
|
|
4459
|
+
* failure); the reply sent after it, once. */
|
|
4460
|
+
let reply;
|
|
4461
|
+
try {
|
|
4462
|
+
const rows = await port.status(m.ref, caller);
|
|
4463
|
+
reply = {
|
|
4464
|
+
t: 'agent-cred-status-result', id: m.id, ok: true,
|
|
4465
|
+
servers: rows.map((r) => ({
|
|
4466
|
+
server: String(r.server),
|
|
4467
|
+
binding: r.binding === 'asker' || r.binding === 'agent' ? r.binding : null,
|
|
4468
|
+
hasAgentCredential: r.hasAgentCredential === true,
|
|
4469
|
+
})),
|
|
4470
|
+
};
|
|
4471
|
+
}
|
|
4472
|
+
catch (err) {
|
|
4473
|
+
const code = agentCredCodeOf(err);
|
|
4474
|
+
if (code) {
|
|
4475
|
+
reply = { t: 'agent-cred-status-result', id: m.id, ok: false, code, error: errMessage(err) };
|
|
4476
|
+
}
|
|
4477
|
+
else {
|
|
4478
|
+
// The detail (a path, an errno) stays in the log, never on the wire.
|
|
4479
|
+
this.opts.log?.(`agent credentials: agent-cred-status threw: ${errMessage(err)}`);
|
|
4480
|
+
reply = { t: 'agent-cred-status-result', id: m.id, ok: false, code: 'UNAVAILABLE', error: AGENT_CRED_FAILED };
|
|
4481
|
+
}
|
|
4482
|
+
}
|
|
4483
|
+
try {
|
|
4484
|
+
this.ch.send(reply);
|
|
4485
|
+
}
|
|
4486
|
+
catch (err) {
|
|
4487
|
+
this.opts.log?.(`agent credentials: could not send the agent-cred-status reply: ${errMessage(err)}`);
|
|
4488
|
+
}
|
|
4489
|
+
}
|
|
4490
|
+
/** Team run history (spec 2026-10-01 E5.6): viewer or higher on the agent,
|
|
4491
|
+
* for THIS connection's verified user. The ref is validated before the
|
|
4492
|
+
* access port sees it. Each row is rebuilt from its own fields here
|
|
4493
|
+
* (agentRunRowOf), so a runner that returned more (a message, a
|
|
4494
|
+
* transcript) could never put it on the wire. An uncoded failure is fixed text; the detail stays in the log. */
|
|
4495
|
+
async onAgentRuns(m) {
|
|
4496
|
+
const fail = (code, error) => {
|
|
4497
|
+
this.ch.send({ t: 'agent-runs-result', id: m.id, ok: false, code, error });
|
|
4498
|
+
};
|
|
4499
|
+
const access = this.teamAccess();
|
|
4500
|
+
const list = this.opts.runner?.agentRuns?.bind(this.opts.runner);
|
|
4501
|
+
if (!access || !list) {
|
|
4502
|
+
fail('NOT_TEAM_AGENT', 'this daemon keeps no team run history');
|
|
4503
|
+
return;
|
|
4504
|
+
}
|
|
4505
|
+
const user = this.verifiedUserId;
|
|
4506
|
+
if (!user) {
|
|
4507
|
+
fail('NO_ACCESS', ACCESS_NO_ROLE);
|
|
4508
|
+
return;
|
|
4509
|
+
}
|
|
4510
|
+
const ref = agentRunsRefOf(m.ref);
|
|
4511
|
+
if (!ref) {
|
|
4512
|
+
fail('UNKNOWN_AGENT', AGENT_RUNS_BAD_REF);
|
|
4513
|
+
return;
|
|
4514
|
+
}
|
|
4515
|
+
let reply;
|
|
4516
|
+
try {
|
|
4517
|
+
if (!(await access.can(ref, user, 'run-list'))) {
|
|
4518
|
+
fail('NO_ACCESS', ACCESS_NO_ROLE);
|
|
4519
|
+
return;
|
|
4520
|
+
}
|
|
4521
|
+
const limit = typeof m.limit === 'number' && Number.isInteger(m.limit) && m.limit > 0
|
|
4522
|
+
? Math.min(m.limit, AGENT_RUNS_MAX_LIMIT) : AGENT_RUNS_DEFAULT_LIMIT;
|
|
4523
|
+
const before = typeof m.before === 'string' && m.before.length > 0 && m.before.length <= 200 ? m.before : undefined;
|
|
4524
|
+
const rows = await list(ref, user, { limit, ...(before ? { before } : {}) });
|
|
4525
|
+
reply = {
|
|
4526
|
+
t: 'agent-runs-result', id: m.id, ok: true,
|
|
4527
|
+
runs: rows.map(agentRunRowOf),
|
|
4528
|
+
};
|
|
4529
|
+
}
|
|
4530
|
+
catch (err) {
|
|
4531
|
+
const code = accessCodeOf(err);
|
|
4532
|
+
if (code) {
|
|
4533
|
+
reply = { t: 'agent-runs-result', id: m.id, ok: false, code, error: errMessage(err) };
|
|
4534
|
+
}
|
|
4535
|
+
else {
|
|
4536
|
+
// The detail (a path, an errno) stays in the log, never on the wire.
|
|
4537
|
+
this.opts.log?.(`access: agent-runs threw: ${errMessage(err)}`);
|
|
4538
|
+
reply = { t: 'agent-runs-result', id: m.id, ok: false, code: 'NO_ACCESS', error: AGENT_RUNS_FAILED };
|
|
4539
|
+
}
|
|
4540
|
+
}
|
|
4541
|
+
try {
|
|
4542
|
+
this.ch.send(reply);
|
|
4543
|
+
}
|
|
4544
|
+
catch (err) {
|
|
4545
|
+
this.opts.log?.(`access: could not send the agent-runs reply: ${errMessage(err)}`);
|
|
4546
|
+
}
|
|
4547
|
+
}
|
|
4548
|
+
/** peer-* (spec 2026-10-01 E5.5), for THIS connection's verified user. A
|
|
4549
|
+
* link is rebuilt from its five fields, so a port that returned more (a
|
|
4550
|
+
* device key) could never put it on the wire. An uncoded throw is fixed
|
|
4551
|
+
* text; the detail stays in the log. */
|
|
4552
|
+
async onPeer(m) {
|
|
4553
|
+
const port = this.opts.peerAdmin;
|
|
4554
|
+
let reply;
|
|
4555
|
+
if (!port) {
|
|
4556
|
+
reply = { t: 'peer-result', id: m.id, ok: false, code: 'UNAVAILABLE', error: 'this daemon does not link to team daemons' };
|
|
4557
|
+
}
|
|
4558
|
+
else {
|
|
4559
|
+
try {
|
|
4560
|
+
const r = await port.handle(this.verifiedUserId, m);
|
|
4561
|
+
reply = !r.ok
|
|
4562
|
+
? { t: 'peer-result', id: m.id, ok: false, code: r.code, error: r.error }
|
|
4563
|
+
: {
|
|
4564
|
+
t: 'peer-result', id: m.id, ok: true,
|
|
4565
|
+
...(r.peers ? {
|
|
4566
|
+
peers: r.peers.map((p) => ({
|
|
4567
|
+
sidecarId: p.sidecarId, url: p.url, ...(typeof p.name === 'string' ? { name: p.name } : {}),
|
|
4568
|
+
connected: p.connected === true, mcpAllow: [...p.mcpAllow],
|
|
4569
|
+
})),
|
|
4570
|
+
} : {}),
|
|
4571
|
+
};
|
|
4572
|
+
}
|
|
4573
|
+
catch (err) {
|
|
4574
|
+
this.opts.log?.(`peer links: ${m.t} threw: ${errMessage(err)}`);
|
|
4575
|
+
reply = { t: 'peer-result', id: m.id, ok: false, code: 'UNAVAILABLE', error: PEER_FAILED };
|
|
4576
|
+
}
|
|
4577
|
+
}
|
|
4578
|
+
try {
|
|
4579
|
+
this.ch.send(reply);
|
|
4580
|
+
}
|
|
4581
|
+
catch (err) {
|
|
4582
|
+
this.opts.log?.(`peer links: could not send the ${m.t} reply: ${errMessage(err)}`);
|
|
4583
|
+
}
|
|
4584
|
+
}
|
|
4585
|
+
/** Team mode with per-agent access wired. Every Part B gate keys on this,
|
|
4586
|
+
* so solo mode takes none of the new paths. */
|
|
4587
|
+
teamAccess() {
|
|
4588
|
+
const a = this.opts.access;
|
|
4589
|
+
return a !== undefined && a.teamMode ? a : null;
|
|
4590
|
+
}
|
|
4591
|
+
/** Who asked for a run: this process's hub record, the runner's facts, or
|
|
4592
|
+
* the run's stored record (restarts included). */
|
|
4593
|
+
async runRequester(runId, facts) {
|
|
4594
|
+
return this.opts.runs?.startedBy?.(runId) ?? facts?.requester ?? (await this.opts.runner?.runStartedBy?.(runId)) ?? undefined;
|
|
4595
|
+
}
|
|
4596
|
+
/** May THIS connection read run `runId`'s content (watch it, read its
|
|
4597
|
+
* output)? Decision D5, in this order: a run somebody asked for is theirs
|
|
4598
|
+
* alone, the owner and the admin included; a run nobody asked for (a
|
|
4599
|
+
* schedule, a trigger) is its agent owner's and the admin's. Anything
|
|
4600
|
+
* unknown, and anything that throws, is refused. */
|
|
4601
|
+
async mayReadRun(runId) {
|
|
4602
|
+
try {
|
|
4603
|
+
const access = this.teamAccess();
|
|
4604
|
+
const user = this.verifiedUserId;
|
|
4605
|
+
if (!access || !user || typeof runId !== 'string')
|
|
4606
|
+
return false;
|
|
4607
|
+
const facts = (await this.opts.runner?.runFacts?.(runId)) ?? null;
|
|
4608
|
+
const requester = await this.runRequester(runId, facts);
|
|
4609
|
+
if (requester !== undefined)
|
|
4610
|
+
return requester === user;
|
|
4611
|
+
if (!facts || facts.origin === 'user')
|
|
4612
|
+
return false;
|
|
4613
|
+
return await access.can(facts.ref, user, 'unattended-content');
|
|
4614
|
+
}
|
|
4615
|
+
catch {
|
|
4616
|
+
return false;
|
|
4617
|
+
}
|
|
4618
|
+
}
|
|
4619
|
+
/** One run-activity item to THIS connection. Solo mode: as is, at once.
|
|
4620
|
+
* Team mode with per-agent access: only what `runActivityFor` lets
|
|
4621
|
+
* through, decided in arrival order. */
|
|
4622
|
+
sendRunActivity(activity) {
|
|
4623
|
+
if (!this.teamAccess()) {
|
|
4624
|
+
// No team: nothing is anyone's, so no report claims mine or attended.
|
|
4625
|
+
this.ch.send({ t: 'run-activity', activity: withoutViewerClaims(activity) });
|
|
4626
|
+
return;
|
|
4627
|
+
}
|
|
4628
|
+
// Items queued before a cut-off replay (see replayDrained) are dropped
|
|
4629
|
+
// however they are decided later.
|
|
4630
|
+
const epoch = this.runActivityEpoch;
|
|
4631
|
+
this.runActivityChain = this.runActivityChain
|
|
4632
|
+
.then(() => this.runActivityDecided(activity))
|
|
4633
|
+
.then((item) => {
|
|
4634
|
+
// Re-read at the send: a member removed or made pending while the
|
|
4635
|
+
// item was being decided hears nothing (Part E).
|
|
4636
|
+
if (item && !this.closed && epoch === this.runActivityEpoch && !this.memberPending())
|
|
4637
|
+
this.ch.send({ t: 'run-activity', activity: item });
|
|
4638
|
+
})
|
|
4639
|
+
.catch(() => undefined);
|
|
4640
|
+
}
|
|
4641
|
+
/** The pre-welcome replay drained, or cut off at its total cap (F14): past
|
|
4642
|
+
* the cap every item still undecided is dropped for this connection, and
|
|
4643
|
+
* the welcome goes out. Never rejects. */
|
|
4644
|
+
async replayDrained() {
|
|
4645
|
+
if (!this.teamAccess()) {
|
|
4646
|
+
await this.runActivityChain;
|
|
4647
|
+
return;
|
|
4648
|
+
}
|
|
4649
|
+
const cap = this.opts.runActivityDecideMs !== undefined ? this.opts.runActivityDecideMs * WELCOME_REPLAY_DEADLINES : WELCOME_REPLAY_MAX_MS;
|
|
4650
|
+
let timer;
|
|
4651
|
+
const capped = new Promise((res) => { timer = setTimeout(() => res('cap'), cap); });
|
|
4652
|
+
const drained = this.runActivityChain.then(() => 'drained');
|
|
4653
|
+
const outcome = await Promise.race([drained, capped]).finally(() => clearTimeout(timer));
|
|
4654
|
+
if (outcome === 'cap') {
|
|
4655
|
+
this.runActivityEpoch++;
|
|
4656
|
+
this.opts.log?.('run activity: the replay at connect took too long; what was left of it was not sent to this connection');
|
|
4657
|
+
}
|
|
4658
|
+
}
|
|
4659
|
+
/** `runActivityFor` under a deadline: past it the item is dropped. */
|
|
4660
|
+
runActivityDecided(activity) {
|
|
4661
|
+
let timer;
|
|
4662
|
+
const timed = new Promise((res) => {
|
|
4663
|
+
timer = setTimeout(() => res(null), this.opts.runActivityDecideMs ?? RUN_ACTIVITY_DECIDE_MS);
|
|
4664
|
+
});
|
|
4665
|
+
return Promise.race([this.runActivityFor(activity), timed]).finally(() => clearTimeout(timer));
|
|
4666
|
+
}
|
|
4667
|
+
/** What THIS connection may hear of one run (team agent access, Part B,
|
|
4668
|
+
* Task 16b). Nothing unless it may list the agent's runs (viewer or higher)
|
|
4669
|
+
* or asked for the run itself. The failure message and the task only when
|
|
4670
|
+
* it may read the run's content (`mayReadRun`, decision D5: the requester,
|
|
4671
|
+
* or for an unattended run the agent's owner or the admin). The agent is
|
|
4672
|
+
* the run's own recorded facts, never the names the item carries. No facts,
|
|
4673
|
+
* facts for a different agent, or any throw: nothing. What gets through says
|
|
4674
|
+
* `mine` on the requester's own connection and `attended` when a person
|
|
4675
|
+
* asked for the run (E5.1); whatever the item claimed of either is dropped,
|
|
4676
|
+
* and no user id is added. */
|
|
4677
|
+
async runActivityFor(activity) {
|
|
4678
|
+
try {
|
|
4679
|
+
const access = this.teamAccess();
|
|
4680
|
+
const user = this.verifiedUserId;
|
|
4681
|
+
if (!access || !user || typeof activity.runId !== 'string')
|
|
4682
|
+
return null;
|
|
4683
|
+
const clean = withoutViewerClaims(activity);
|
|
4684
|
+
const facts = (await this.opts.runner?.runFacts?.(clean.runId)) ?? null;
|
|
4685
|
+
if (!facts || facts.ref.agent !== clean.agent)
|
|
4686
|
+
return null;
|
|
4687
|
+
const full = await this.mayReadRun(clean.runId);
|
|
4688
|
+
if (!full && !(await access.can(facts.ref, user, 'run-list')))
|
|
4689
|
+
return null;
|
|
4690
|
+
const item = { ...clean };
|
|
4691
|
+
if (!full) {
|
|
4692
|
+
delete item.message;
|
|
4693
|
+
delete item.task;
|
|
4694
|
+
}
|
|
4695
|
+
// Mine and attended for THIS connection (E5.1), from the run's own
|
|
4696
|
+
// facts, after the role decision.
|
|
4697
|
+
const requester = await this.runRequester(clean.runId, facts);
|
|
4698
|
+
return activityForViewer(item, { requester, origin: facts.origin }, user);
|
|
4699
|
+
}
|
|
4700
|
+
catch {
|
|
4701
|
+
return null;
|
|
4702
|
+
}
|
|
4703
|
+
}
|
|
4704
|
+
/** Team mode: THIS connection's verified user is the daemon admin. A throw
|
|
4705
|
+
* is "no". */
|
|
4706
|
+
isSessionAdmin() {
|
|
4707
|
+
try {
|
|
4708
|
+
const access = this.teamAccess();
|
|
4709
|
+
const user = this.verifiedUserId;
|
|
4710
|
+
return access !== null && sameAdmin(user, access.adminId());
|
|
4711
|
+
}
|
|
4712
|
+
catch {
|
|
4713
|
+
return false;
|
|
4714
|
+
}
|
|
4715
|
+
}
|
|
4716
|
+
/** The ids of the sessions whose recorded VERIFIED creator is THIS
|
|
4717
|
+
* connection's verified user. Never the claimed `owner`. A session with no
|
|
4718
|
+
* recorded creator is nobody's here. May throw (the caller refuses). */
|
|
4719
|
+
ownSessionIds(sessions) {
|
|
4720
|
+
const user = this.verifiedUserId;
|
|
4721
|
+
if (!user)
|
|
4722
|
+
return new Set();
|
|
4723
|
+
return new Set(sessions.list().filter((s) => s.verifiedOwner !== undefined && s.verifiedOwner === user).map((s) => s.id));
|
|
4724
|
+
}
|
|
4725
|
+
/** May THIS connection act on session `sessionId` (pause, resume, destroy,
|
|
4726
|
+
* capture, watch, run)? Team mode (Task 16c): the admin, or the session's
|
|
4727
|
+
* recorded verified creator. A session with no recorded creator is the
|
|
4728
|
+
* admin's alone. A throw refuses. */
|
|
4729
|
+
maySession(sessions, sessionId) {
|
|
4730
|
+
try {
|
|
4731
|
+
if (this.isSessionAdmin())
|
|
4732
|
+
return true;
|
|
4733
|
+
return typeof sessionId === 'string' && this.ownSessionIds(sessions).has(sessionId);
|
|
4734
|
+
}
|
|
4735
|
+
catch {
|
|
4736
|
+
return false;
|
|
4737
|
+
}
|
|
4738
|
+
}
|
|
4739
|
+
/** The sessions THIS connection is shown. Solo mode: every one, as before.
|
|
4740
|
+
* Team mode (Task 16c): the admin sees every one, anyone else only their
|
|
4741
|
+
* own. A throw shows none. */
|
|
4742
|
+
visibleSessions() {
|
|
4743
|
+
const sessions = this.opts.sessions;
|
|
4744
|
+
if (!sessions)
|
|
4745
|
+
return [];
|
|
4746
|
+
if (!this.teamAccess())
|
|
4747
|
+
return sessions.list();
|
|
4748
|
+
try {
|
|
4749
|
+
if (this.isSessionAdmin())
|
|
4750
|
+
return sessions.list();
|
|
4751
|
+
const own = this.ownSessionIds(sessions);
|
|
4752
|
+
return sessions.list().filter((s) => own.has(s.id));
|
|
4753
|
+
}
|
|
4754
|
+
catch {
|
|
4755
|
+
return [];
|
|
4756
|
+
}
|
|
4757
|
+
}
|
|
4758
|
+
/** Team mode (Task 16c): why THIS connection may not send `m`, or null.
|
|
4759
|
+
* `session-unwatch` stops only this connection's own watch and is never
|
|
4760
|
+
* refused; `session-snapshots` is filtered where it is answered. */
|
|
4761
|
+
async sessionRefusal(sessions, m) {
|
|
4762
|
+
switch (m.t) {
|
|
4763
|
+
case 'session-commit':
|
|
4764
|
+
// A client-chosen name is how a snapshot is attributed and a repeated
|
|
4765
|
+
// one overwrites: only the admin may pick one.
|
|
4766
|
+
if (m.name !== undefined && !this.isSessionAdmin())
|
|
4767
|
+
return SNAPSHOT_NAME_REFUSED;
|
|
4768
|
+
return this.maySession(sessions, m.sessionId) ? null : SESSION_REFUSED;
|
|
4769
|
+
case 'session-pause':
|
|
4770
|
+
case 'session-resume':
|
|
4771
|
+
case 'session-destroy':
|
|
4772
|
+
case 'session-watch':
|
|
4773
|
+
return this.maySession(sessions, m.sessionId) ? null : SESSION_REFUSED;
|
|
4774
|
+
case 'session-snapshot-usage':
|
|
4775
|
+
return this.isSessionAdmin() ? null : SNAPSHOT_USAGE_ADMIN_ONLY;
|
|
4776
|
+
case 'session-create': {
|
|
4777
|
+
if (!m.fromSnapshot || this.isSessionAdmin())
|
|
4778
|
+
return null;
|
|
4779
|
+
try {
|
|
4780
|
+
const snapshot = (sessions.snapshots ? await sessions.snapshots() : []).find((s) => s.name === m.fromSnapshot);
|
|
4781
|
+
const own = this.ownSessionIds(sessions);
|
|
4782
|
+
return snapshot?.sessionId !== undefined && own.has(snapshot.sessionId) ? null : SNAPSHOT_REFUSED;
|
|
4783
|
+
}
|
|
4784
|
+
catch {
|
|
4785
|
+
return SNAPSHOT_REFUSED;
|
|
4786
|
+
}
|
|
4787
|
+
}
|
|
4788
|
+
default:
|
|
4789
|
+
return null;
|
|
4790
|
+
}
|
|
4791
|
+
}
|
|
4792
|
+
/** May THIS connection stop run `runId`? Its requester may, and so may the
|
|
4793
|
+
* agent's owner or the admin, for any run of that agent. A throw refuses. */
|
|
4794
|
+
async mayCancelRun(runId) {
|
|
4795
|
+
try {
|
|
4796
|
+
const access = this.teamAccess();
|
|
4797
|
+
const user = this.verifiedUserId;
|
|
4798
|
+
if (!access || !user || typeof runId !== 'string')
|
|
4799
|
+
return false;
|
|
4800
|
+
const facts = (await this.opts.runner?.runFacts?.(runId)) ?? null;
|
|
4801
|
+
if ((await this.runRequester(runId, facts)) === user)
|
|
4802
|
+
return true;
|
|
4803
|
+
return facts !== null && (await access.can(facts.ref, user, 'cancel-any'));
|
|
4804
|
+
}
|
|
4805
|
+
catch {
|
|
4806
|
+
return false;
|
|
4807
|
+
}
|
|
4808
|
+
}
|
|
4809
|
+
/** Who may do this config call on the daemon's own crew (team mode):
|
|
4810
|
+
* - read a definition (or list an agent): any role; read anything else in
|
|
4811
|
+
* an agent's folder (its runs' memory, sessions, logs): owner or admin;
|
|
4812
|
+
* - write, append or delete in an agent's folder: owner or admin, except the
|
|
4813
|
+
* first write into a NEW agent, which claims it for the writer;
|
|
4814
|
+
* - change the crew folder itself or a crew-level file: the admin.
|
|
4815
|
+
* Answers a refusal, or what to record once the call has succeeded. Anything
|
|
4816
|
+
* that cannot be decided, or throws, is a refusal. */
|
|
4817
|
+
async configGate(svc, m) {
|
|
4818
|
+
const reading = m.t === 'config-list' || m.t === 'config-read';
|
|
4819
|
+
const refusal = reading ? ACCESS_NO_ROLE : ACCESS_NOT_OWNER;
|
|
4820
|
+
try {
|
|
4821
|
+
const access = this.teamAccess();
|
|
4822
|
+
if (!access)
|
|
4823
|
+
return {};
|
|
4824
|
+
const user = this.verifiedUserId;
|
|
4825
|
+
if (typeof m.path !== 'string')
|
|
4826
|
+
return { refusal };
|
|
4827
|
+
const canonical = await this.canonicalConfigPath(svc, m.path);
|
|
4828
|
+
if (canonical === null)
|
|
4829
|
+
return { refusal };
|
|
4830
|
+
const target = crewConfigTarget(canonical);
|
|
4831
|
+
if (!target)
|
|
4832
|
+
return {};
|
|
4833
|
+
if (reading) {
|
|
4834
|
+
if (target.kind === 'crew')
|
|
4835
|
+
return {};
|
|
4836
|
+
/* A run's words (a kept transcript, a run's session log) are decided
|
|
4837
|
+
* per run, exactly as run-transcript decides them (decision D5: a
|
|
4838
|
+
* member's attended run is theirs alone, the owner and admin
|
|
4839
|
+
* included; an unattended run is the owner's and the admin's). A role
|
|
4840
|
+
* on the agent is neither needed nor enough. The folder itself may be
|
|
4841
|
+
* listed with any role, and the listing keeps only the runs this user
|
|
4842
|
+
* may read (visibleConfigEntries); anything else in it is refused. */
|
|
4843
|
+
const run = runContentOf(canonical);
|
|
4844
|
+
if (run !== null) {
|
|
4845
|
+
if (run.id === null) {
|
|
4846
|
+
return m.t === 'config-list' && run.folder && (await access.can(daemonAgent(target.agent), user, 'see')) ? {} : { refusal };
|
|
4847
|
+
}
|
|
4848
|
+
return (await this.mayReadRunContent(target.agent, run.id, user)) ? {} : { refusal };
|
|
4849
|
+
}
|
|
4850
|
+
const action = m.t === 'config-list' || target.whole || target.definition ? 'see' : 'unattended-content';
|
|
4851
|
+
if (!(await access.can(daemonAgent(target.agent), user, action)))
|
|
4852
|
+
return { refusal };
|
|
4853
|
+
return {};
|
|
4854
|
+
}
|
|
4855
|
+
if (target.kind === 'crew')
|
|
4856
|
+
return sameAdmin(user, access.adminId()) ? {} : { refusal: ACCESS_ADMIN_ONLY };
|
|
4857
|
+
const ref = daemonAgent(target.agent);
|
|
4858
|
+
/* Decided BEFORE the write: an agent that already exists is never claimed. */
|
|
4859
|
+
if (m.t !== 'config-delete' && (await access.isNewAgent(ref)))
|
|
4860
|
+
return user !== undefined ? { claim: ref } : { refusal };
|
|
4861
|
+
if (!(await access.can(ref, user, 'write-definition')))
|
|
4862
|
+
return { refusal };
|
|
4863
|
+
return m.t === 'config-delete' && target.whole ? { deleted: ref } : {};
|
|
4864
|
+
}
|
|
4865
|
+
catch {
|
|
4866
|
+
return { refusal };
|
|
4867
|
+
}
|
|
4868
|
+
}
|
|
4869
|
+
/** The runner's per-run decision (D5) for one run's kept words on the
|
|
4870
|
+
* daemon crew. No runner decision available, or a throw: no. */
|
|
4871
|
+
async mayReadRunContent(agent, runId, user) {
|
|
4872
|
+
try {
|
|
4873
|
+
const mayRead = this.opts.runner?.mayReadTranscript;
|
|
4874
|
+
return mayRead !== undefined && (await mayRead.call(this.opts.runner, agent, runId, user));
|
|
4875
|
+
}
|
|
4876
|
+
catch {
|
|
4877
|
+
return false;
|
|
4878
|
+
}
|
|
4879
|
+
}
|
|
4880
|
+
/** A listing without the agents this connection has no role on (team mode),
|
|
4881
|
+
* or null when that cannot be decided. Only whole agents are left out:
|
|
4882
|
+
* dropping individual entries would read as deletions to a syncing client.
|
|
4883
|
+
* The one exception is a run's session folder (`sessions/<runId>`), which
|
|
4884
|
+
* is run content: only the runs this user may read are listed (D5). */
|
|
4885
|
+
async visibleConfigEntries(svc, path, entries) {
|
|
4886
|
+
const access = this.teamAccess();
|
|
4887
|
+
if (!access)
|
|
4888
|
+
return entries;
|
|
4889
|
+
try {
|
|
4890
|
+
const base = await this.canonicalConfigPath(svc, path);
|
|
4891
|
+
if (base === null)
|
|
4892
|
+
return null;
|
|
4893
|
+
const visible = new Map();
|
|
4894
|
+
const runs = new Map();
|
|
4895
|
+
const out = [];
|
|
4896
|
+
const isAdmin = sameAdmin(this.verifiedUserId, access.adminId());
|
|
4897
|
+
for (const e of entries) {
|
|
4898
|
+
const full = base ? `${base}/${e.path}` : e.path;
|
|
4899
|
+
const target = crewConfigTarget(full);
|
|
4900
|
+
if (target?.kind === 'crew' && !isAdmin && full.split('/').length >= 2 && (e.dir === true || full.split('/').length >= 3))
|
|
4901
|
+
continue;
|
|
4902
|
+
if (target?.kind !== 'agent') {
|
|
4903
|
+
out.push(e);
|
|
4904
|
+
continue;
|
|
4905
|
+
}
|
|
4906
|
+
let shown = visible.get(target.agent);
|
|
4907
|
+
if (shown === undefined) {
|
|
4908
|
+
shown = await access.can(daemonAgent(target.agent), this.verifiedUserId, 'see');
|
|
4909
|
+
visible.set(target.agent, shown);
|
|
4910
|
+
}
|
|
4911
|
+
if (!shown)
|
|
4912
|
+
continue;
|
|
4913
|
+
const run = runContentOf(full);
|
|
4914
|
+
if (run?.kind === 'sessions' && run.id !== null) {
|
|
4915
|
+
const key = `${target.agent}/${run.id}`;
|
|
4916
|
+
let readable = runs.get(key);
|
|
4917
|
+
if (readable === undefined) {
|
|
4918
|
+
readable = await this.mayReadRunContent(target.agent, run.id, this.verifiedUserId);
|
|
4919
|
+
runs.set(key, readable);
|
|
4920
|
+
}
|
|
4921
|
+
if (!readable)
|
|
4922
|
+
continue;
|
|
4923
|
+
}
|
|
4924
|
+
out.push(e);
|
|
4925
|
+
}
|
|
4926
|
+
return out;
|
|
4927
|
+
}
|
|
4928
|
+
catch {
|
|
4929
|
+
return null;
|
|
4930
|
+
}
|
|
4931
|
+
}
|
|
4932
|
+
/** After a config write or delete went through: record a new agent's owner,
|
|
4933
|
+
* or drop a deleted agent's record. Never fails the call that succeeded. */
|
|
4934
|
+
async afterConfigChange(gate) {
|
|
4935
|
+
const access = this.teamAccess();
|
|
4936
|
+
if (!access)
|
|
4937
|
+
return;
|
|
4938
|
+
try {
|
|
4939
|
+
if (gate.claim)
|
|
4940
|
+
await access.onAgentCreated(gate.claim, this.verifiedUserId);
|
|
4941
|
+
if (gate.deleted)
|
|
4942
|
+
await access.onAgentDeleted(gate.deleted);
|
|
4943
|
+
}
|
|
4944
|
+
catch (err) {
|
|
4945
|
+
this.opts.log?.(`access: could not record an agent change: ${errMessage(err)}`);
|
|
4946
|
+
}
|
|
4947
|
+
}
|
|
4948
|
+
/** Why THIS connection may not run a mutating tool on `target`, or null.
|
|
4949
|
+
* The first write into a new agent claims it, which needs a verified user;
|
|
4950
|
+
* a delete never claims, so it goes through the ordinary check. */
|
|
4951
|
+
async invokeRefusal(access, target, tool) {
|
|
4952
|
+
if (target.kind === 'access-store')
|
|
4953
|
+
return ACCESS_STORE_REFUSED;
|
|
4954
|
+
const user = this.verifiedUserId;
|
|
4955
|
+
if (target.kind === 'crew-parent')
|
|
4956
|
+
return sameAdmin(user, access.adminId()) ? null : ACCESS_ADMIN_ONLY;
|
|
4957
|
+
if (target.isNew && tool !== 'sidecar_fs_delete')
|
|
4958
|
+
return user !== undefined ? null : ACCESS_NOT_OWNER;
|
|
4959
|
+
return (await access.can(target.ref, user, 'write-definition')) ? null : ACCESS_NOT_OWNER;
|
|
4960
|
+
}
|
|
4961
|
+
/** Team mode: the daemon's MCP servers and credentials are the admin's
|
|
4962
|
+
* (Part B). Null when THIS connection may use or change them. Unknown admin
|
|
4963
|
+
* and any throw while deciding are refusals. */
|
|
4964
|
+
mcpRefusal() {
|
|
4965
|
+
try {
|
|
4966
|
+
const access = this.teamAccess();
|
|
4967
|
+
if (!access)
|
|
4968
|
+
return null;
|
|
4969
|
+
const user = this.verifiedUserId;
|
|
4970
|
+
return sameAdmin(user, access.adminId()) ? null : MCP_ADMIN_ONLY;
|
|
4971
|
+
}
|
|
4972
|
+
catch {
|
|
4973
|
+
return MCP_ADMIN_ONLY;
|
|
4974
|
+
}
|
|
4975
|
+
}
|
|
3359
4976
|
async onRoomFindings(m) {
|
|
3360
4977
|
if (!this.opts.rooms) {
|
|
3361
4978
|
// A daemon without the capability hosts no rooms, so it holds no
|
|
@@ -3401,8 +5018,19 @@ export class SidecarServer {
|
|
|
3401
5018
|
return;
|
|
3402
5019
|
}
|
|
3403
5020
|
try {
|
|
5021
|
+
/* TEAM AGENT ACCESS (Part B) on the daemon's own crew (`crew/...`). The
|
|
5022
|
+
* allowlist is still the service's; this only decides WHO, per agent. */
|
|
5023
|
+
const gate = this.teamAccess() ? await this.configGate(svc, m) : {};
|
|
5024
|
+
if ('refusal' in gate) {
|
|
5025
|
+
this.ch.send({ t: 'config-result', id: m.id, ok: false, error: gate.refusal });
|
|
5026
|
+
return;
|
|
5027
|
+
}
|
|
3404
5028
|
if (m.t === 'config-list') {
|
|
3405
|
-
|
|
5029
|
+
const entries = await svc.list(m.path, m.depth);
|
|
5030
|
+
const shown = await this.visibleConfigEntries(svc, m.path, entries);
|
|
5031
|
+
this.ch.send(shown
|
|
5032
|
+
? { t: 'config-result', id: m.id, ok: true, entries: shown }
|
|
5033
|
+
: { t: 'config-result', id: m.id, ok: false, error: ACCESS_NO_ROLE });
|
|
3406
5034
|
return;
|
|
3407
5035
|
}
|
|
3408
5036
|
if (m.t === 'config-read') {
|
|
@@ -3418,12 +5046,91 @@ export class SidecarServer {
|
|
|
3418
5046
|
}
|
|
3419
5047
|
else
|
|
3420
5048
|
await svc.remove(m.path);
|
|
5049
|
+
await this.afterConfigChange(gate);
|
|
3421
5050
|
this.ch.send({ t: 'config-result', id: m.id, ok: true });
|
|
3422
5051
|
}
|
|
3423
5052
|
catch (e) {
|
|
3424
5053
|
this.ch.send({ t: 'config-result', id: m.id, ok: false, error: errMessage(e) });
|
|
3425
5054
|
}
|
|
3426
5055
|
}
|
|
5056
|
+
/** The config path as the filesystem would reach it, for classification:
|
|
5057
|
+
* separators, `.`, empty and `..` segments resolved; the `crew` segment, the
|
|
5058
|
+
* agent folder (to its on-disk name, read from the service's own listing of
|
|
5059
|
+
* `crew`) and a definition file name matched case-insensitively, since the
|
|
5060
|
+
* filesystem under the state dir may be case-insensitive. Null means "cannot
|
|
5061
|
+
* be told" (two case variants exist and none is exact): the caller refuses.
|
|
5062
|
+
* A listing that throws propagates, and the caller refuses. A path outside
|
|
5063
|
+
* the crew comes back as is. */
|
|
5064
|
+
async canonicalConfigPath(svc, path) {
|
|
5065
|
+
const segs = [];
|
|
5066
|
+
for (const seg of path.split(/[\\/]/)) {
|
|
5067
|
+
if (seg === '' || seg === '.')
|
|
5068
|
+
continue;
|
|
5069
|
+
if (seg === '..')
|
|
5070
|
+
segs.pop();
|
|
5071
|
+
else
|
|
5072
|
+
segs.push(seg);
|
|
5073
|
+
}
|
|
5074
|
+
/* A `:` is an NTFS alternate-stream / `::$INDEX_ALLOCATION` spelling of a
|
|
5075
|
+
* folder: it reaches the real folder under a name no check here would
|
|
5076
|
+
* recognise. Never legitimate in a config path. */
|
|
5077
|
+
if (segs.some((seg) => seg.includes(':')))
|
|
5078
|
+
return null;
|
|
5079
|
+
if (segs.length === 0 || !sameConfigName(segs[0], 'crew'))
|
|
5080
|
+
return segs.join('/');
|
|
5081
|
+
const out = ['crew'];
|
|
5082
|
+
if (segs.length >= 2) {
|
|
5083
|
+
let agent = segs[1];
|
|
5084
|
+
const entries = await svc.list('crew', 1);
|
|
5085
|
+
if (/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(agent)) {
|
|
5086
|
+
const dirs = entries
|
|
5087
|
+
.filter((e) => e.dir === true && !e.path.includes('/') && sameConfigName(e.path, agent))
|
|
5088
|
+
.map((e) => e.path);
|
|
5089
|
+
if (dirs.length === 1)
|
|
5090
|
+
agent = dirs[0];
|
|
5091
|
+
else if (dirs.length > 1 && !dirs.includes(agent))
|
|
5092
|
+
return null;
|
|
5093
|
+
}
|
|
5094
|
+
else {
|
|
5095
|
+
/* Not an agent name. Inside it (3+ segments) is never a legitimate
|
|
5096
|
+
* location. A crew-level entry (2 segments) is fine only as a FILE the
|
|
5097
|
+
* listing shows, or a new file whose name is no 8.3 short name; a
|
|
5098
|
+
* short name or a plain folder is how an agent's folder hides. */
|
|
5099
|
+
if (segs.length >= 3)
|
|
5100
|
+
return null;
|
|
5101
|
+
const hit = entries.find((e) => !e.path.includes('/') && sameConfigName(e.path, agent));
|
|
5102
|
+
if (hit ? hit.dir === true : agent.includes('~'))
|
|
5103
|
+
return null;
|
|
5104
|
+
if (hit)
|
|
5105
|
+
agent = hit.path;
|
|
5106
|
+
}
|
|
5107
|
+
out.push(agent);
|
|
5108
|
+
}
|
|
5109
|
+
/* Every segment below the agent folder, at every depth: no short-name
|
|
5110
|
+
* (`<base>~<digit>`), trailing-dot or trailing-space spelling (the filesystem drops
|
|
5111
|
+
* those, so they reach a real name this classification never sees), and
|
|
5112
|
+
* each one canonicalized to its on-disk name by listing its parent, so a
|
|
5113
|
+
* case variant of `transcripts` is classified as `transcripts`. */
|
|
5114
|
+
for (let i = 2; i < segs.length; i++) {
|
|
5115
|
+
const seg = segs[i];
|
|
5116
|
+
if (/~\d/.test(seg) || seg.endsWith('.') || seg.endsWith(' '))
|
|
5117
|
+
return null;
|
|
5118
|
+
const def = i === 2 && segs.length === 3 ? CREW_DEFINITION_FILES.find((d) => sameConfigName(d, seg)) : undefined;
|
|
5119
|
+
if (def !== undefined) {
|
|
5120
|
+
out.push(def);
|
|
5121
|
+
continue;
|
|
5122
|
+
}
|
|
5123
|
+
const siblings = await svc.list(out.join('/'), 1);
|
|
5124
|
+
const names = siblings.filter((e) => !e.path.includes('/') && sameConfigName(e.path, seg)).map((e) => e.path);
|
|
5125
|
+
if (names.length === 1)
|
|
5126
|
+
out.push(names[0]);
|
|
5127
|
+
else if (names.length > 1 && !names.includes(seg))
|
|
5128
|
+
return null;
|
|
5129
|
+
else
|
|
5130
|
+
out.push(seg);
|
|
5131
|
+
}
|
|
5132
|
+
return out.join('/');
|
|
5133
|
+
}
|
|
3427
5134
|
async onHostSyncPut(m) {
|
|
3428
5135
|
const identity = this.requireHostSyncIdentity();
|
|
3429
5136
|
if ('error' in identity) {
|
|
@@ -3479,12 +5186,62 @@ export class SidecarServer {
|
|
|
3479
5186
|
}
|
|
3480
5187
|
hostCall(provider, method, args) {
|
|
3481
5188
|
const id = ++this.hostSeq;
|
|
5189
|
+
const label = `${provider}.${method}`;
|
|
3482
5190
|
return new Promise((resolve, reject) => {
|
|
3483
|
-
this.
|
|
3484
|
-
|
|
5191
|
+
if (this.closed) {
|
|
5192
|
+
reject(new HostCallError('undelivered', `host call ${label} not sent: the connection is closed`));
|
|
5193
|
+
return;
|
|
5194
|
+
}
|
|
5195
|
+
const pending = { resolve, reject, label };
|
|
5196
|
+
if (!HUMAN_WAIT_PROVIDERS.has(provider)) {
|
|
5197
|
+
const ms = this.opts.hostCallTimeoutMs ?? DEFAULT_HOST_CALL_TIMEOUT_MS;
|
|
5198
|
+
pending.timer = setTimeout(() => {
|
|
5199
|
+
if (this.hostPending.get(id) !== pending)
|
|
5200
|
+
return;
|
|
5201
|
+
this.hostPending.delete(id);
|
|
5202
|
+
reject(new HostCallError('timeout', `host call ${label} timed out after ${ms}ms`));
|
|
5203
|
+
}, ms);
|
|
5204
|
+
// Never keep a node process alive for this timer alone.
|
|
5205
|
+
pending.timer.unref?.();
|
|
5206
|
+
}
|
|
5207
|
+
this.hostPending.set(id, pending);
|
|
5208
|
+
try {
|
|
5209
|
+
this.ch.send({ t: 'host-call', id, provider, method, args });
|
|
5210
|
+
}
|
|
5211
|
+
catch (err) {
|
|
5212
|
+
this.hostPending.delete(id);
|
|
5213
|
+
if (pending.timer !== undefined)
|
|
5214
|
+
clearTimeout(pending.timer);
|
|
5215
|
+
reject(new HostCallError('undelivered', `host call ${label} not sent: ${errMessage(err)}`));
|
|
5216
|
+
}
|
|
3485
5217
|
});
|
|
3486
5218
|
}
|
|
3487
5219
|
}
|
|
5220
|
+
/** Whether `user` is the daemon admin `admin`. Both must be non-empty
|
|
5221
|
+
* strings: an unknown admin, or an empty id on either side, is never a match. */
|
|
5222
|
+
export function sameAdmin(user, admin) {
|
|
5223
|
+
return typeof user === 'string' && user !== '' && typeof admin === 'string' && admin !== '' && user === admin;
|
|
5224
|
+
}
|
|
5225
|
+
/** Where a daemon-crew config path is inside a run's kept words: an agent's
|
|
5226
|
+
* `transcripts/<runId>.jsonl` or anything under `sessions/<runId>/` (a
|
|
5227
|
+
* daemon run's session log, whose session id is its run id). `id` null is
|
|
5228
|
+
* the folder itself (`folder`) or a spelling inside it that names no run.
|
|
5229
|
+
* Null for any other path. Segment names compare case-insensitively; the
|
|
5230
|
+
* caller hands in the canonical path. */
|
|
5231
|
+
function runContentOf(path) {
|
|
5232
|
+
const segs = path.split('/');
|
|
5233
|
+
if (segs.length < 3 || segs[0].toLowerCase() !== 'crew')
|
|
5234
|
+
return null;
|
|
5235
|
+
const kind = segs[2].toLowerCase();
|
|
5236
|
+
if (kind !== 'transcripts' && kind !== 'sessions')
|
|
5237
|
+
return null;
|
|
5238
|
+
if (segs.length === 3)
|
|
5239
|
+
return { kind, id: null, folder: true };
|
|
5240
|
+
if (kind === 'sessions')
|
|
5241
|
+
return { kind, id: segs[3] || null, folder: false };
|
|
5242
|
+
const file = segs.length === 4 ? /^(.+)\.jsonl$/i.exec(segs[3]) : null;
|
|
5243
|
+
return { kind, id: file ? file[1] : null, folder: false };
|
|
5244
|
+
}
|
|
3488
5245
|
/** `run-history`'s `crew`, off the wire: anything but the two known shapes is
|
|
3489
5246
|
* treated as absent (the global crew, as an older client meant). The id is
|
|
3490
5247
|
* only a selector among the CALLER's own roots -- the runner matches it
|