@corenel/harness 0.13.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.
Files changed (145) hide show
  1. package/dist/approvals/service.d.ts.map +1 -1
  2. package/dist/approvals/service.js +4 -1
  3. package/dist/approvals/service.js.map +1 -1
  4. package/dist/audit/log.d.ts.map +1 -1
  5. package/dist/audit/log.js +12 -1
  6. package/dist/audit/log.js.map +1 -1
  7. package/dist/audit/recorder.d.ts +12 -1
  8. package/dist/audit/recorder.d.ts.map +1 -1
  9. package/dist/audit/recorder.js +36 -5
  10. package/dist/audit/recorder.js.map +1 -1
  11. package/dist/audit/runAudit.d.ts +2 -1
  12. package/dist/audit/runAudit.d.ts.map +1 -1
  13. package/dist/audit/runAudit.js +4 -0
  14. package/dist/audit/runAudit.js.map +1 -1
  15. package/dist/conversation.d.ts +33 -0
  16. package/dist/conversation.d.ts.map +1 -1
  17. package/dist/conversation.js +44 -1
  18. package/dist/conversation.js.map +1 -1
  19. package/dist/core/compactHistory.d.ts +27 -18
  20. package/dist/core/compactHistory.d.ts.map +1 -1
  21. package/dist/core/compactHistory.js +106 -40
  22. package/dist/core/compactHistory.js.map +1 -1
  23. package/dist/core/dontsRecent.d.ts +5 -0
  24. package/dist/core/dontsRecent.d.ts.map +1 -0
  25. package/dist/core/dontsRecent.js +23 -0
  26. package/dist/core/dontsRecent.js.map +1 -0
  27. package/dist/core/foldBlocks.d.ts +43 -0
  28. package/dist/core/foldBlocks.d.ts.map +1 -0
  29. package/dist/core/foldBlocks.js +114 -0
  30. package/dist/core/foldBlocks.js.map +1 -0
  31. package/dist/core/foldTypes.d.ts +25 -0
  32. package/dist/core/foldTypes.d.ts.map +1 -0
  33. package/dist/core/foldTypes.js +2 -0
  34. package/dist/core/foldTypes.js.map +1 -0
  35. package/dist/core/loop.d.ts +26 -5
  36. package/dist/core/loop.d.ts.map +1 -1
  37. package/dist/core/loop.js +274 -74
  38. package/dist/core/loop.js.map +1 -1
  39. package/dist/core/recovery.d.ts +1 -0
  40. package/dist/core/recovery.d.ts.map +1 -1
  41. package/dist/core/recovery.js +12 -0
  42. package/dist/core/recovery.js.map +1 -1
  43. package/dist/core/spawn.d.ts +13 -1
  44. package/dist/core/spawn.d.ts.map +1 -1
  45. package/dist/core/spawn.js +10 -1
  46. package/dist/core/spawn.js.map +1 -1
  47. package/dist/core/summarize.d.ts +9 -3
  48. package/dist/core/summarize.d.ts.map +1 -1
  49. package/dist/core/summarize.js +97 -42
  50. package/dist/core/summarize.js.map +1 -1
  51. package/dist/dag/executeNode.d.ts +3 -1
  52. package/dist/dag/executeNode.d.ts.map +1 -1
  53. package/dist/dag/executeNode.js +1 -0
  54. package/dist/dag/executeNode.js.map +1 -1
  55. package/dist/guardrail/permission-service.d.ts.map +1 -1
  56. package/dist/guardrail/permission-service.js +31 -11
  57. package/dist/guardrail/permission-service.js.map +1 -1
  58. package/dist/guardrail/serialize.d.ts.map +1 -1
  59. package/dist/guardrail/serialize.js +5 -0
  60. package/dist/guardrail/serialize.js.map +1 -1
  61. package/dist/guardrail/validate.d.ts.map +1 -1
  62. package/dist/guardrail/validate.js +21 -6
  63. package/dist/guardrail/validate.js.map +1 -1
  64. package/dist/memory/store.d.ts +3 -0
  65. package/dist/memory/store.d.ts.map +1 -1
  66. package/dist/memory/store.js +16 -1
  67. package/dist/memory/store.js.map +1 -1
  68. package/dist/orchestrator/record.d.ts +7 -0
  69. package/dist/orchestrator/record.d.ts.map +1 -1
  70. package/dist/orchestrator/record.js +14 -2
  71. package/dist/orchestrator/record.js.map +1 -1
  72. package/dist/prompts/library/defaults.generated.d.ts +2 -0
  73. package/dist/prompts/library/defaults.generated.d.ts.map +1 -1
  74. package/dist/prompts/library/defaults.generated.js +6 -0
  75. package/dist/prompts/library/defaults.generated.js.map +1 -1
  76. package/dist/prompts/library/index.d.ts +3 -0
  77. package/dist/prompts/library/index.d.ts.map +1 -1
  78. package/dist/prompts/library/index.js +3 -0
  79. package/dist/prompts/library/index.js.map +1 -1
  80. package/dist/sessions/directivesStore.d.ts +9 -0
  81. package/dist/sessions/directivesStore.d.ts.map +1 -0
  82. package/dist/sessions/directivesStore.js +54 -0
  83. package/dist/sessions/directivesStore.js.map +1 -0
  84. package/dist/sessions/fileSessionStore.d.ts +3 -2
  85. package/dist/sessions/fileSessionStore.d.ts.map +1 -1
  86. package/dist/sessions/fileSessionStore.js +30 -9
  87. package/dist/sessions/fileSessionStore.js.map +1 -1
  88. package/dist/sessions/foldStorage.d.ts +15 -0
  89. package/dist/sessions/foldStorage.d.ts.map +1 -0
  90. package/dist/sessions/foldStorage.js +121 -0
  91. package/dist/sessions/foldStorage.js.map +1 -0
  92. package/dist/sessions/jsonl.d.ts +6 -0
  93. package/dist/sessions/jsonl.d.ts.map +1 -1
  94. package/dist/sessions/jsonl.js +15 -1
  95. package/dist/sessions/jsonl.js.map +1 -1
  96. package/dist/sidecar/accessTestRig.d.ts +32 -0
  97. package/dist/sidecar/accessTestRig.d.ts.map +1 -0
  98. package/dist/sidecar/accessTestRig.js +107 -0
  99. package/dist/sidecar/accessTestRig.js.map +1 -0
  100. package/dist/sidecar/askView.d.ts +20 -0
  101. package/dist/sidecar/askView.d.ts.map +1 -0
  102. package/dist/sidecar/askView.js +38 -0
  103. package/dist/sidecar/askView.js.map +1 -0
  104. package/dist/sidecar/host.d.ts +139 -11
  105. package/dist/sidecar/host.d.ts.map +1 -1
  106. package/dist/sidecar/host.js +394 -13
  107. package/dist/sidecar/host.js.map +1 -1
  108. package/dist/sidecar/hostCapability.d.ts +6 -0
  109. package/dist/sidecar/hostCapability.d.ts.map +1 -0
  110. package/dist/sidecar/hostCapability.js +7 -0
  111. package/dist/sidecar/hostCapability.js.map +1 -0
  112. package/dist/sidecar/identity.d.ts +5 -0
  113. package/dist/sidecar/identity.d.ts.map +1 -1
  114. package/dist/sidecar/memberGate.d.ts +5 -0
  115. package/dist/sidecar/memberGate.d.ts.map +1 -0
  116. package/dist/sidecar/memberGate.js +128 -0
  117. package/dist/sidecar/memberGate.js.map +1 -0
  118. package/dist/sidecar/server.d.ts +425 -11
  119. package/dist/sidecar/server.d.ts.map +1 -1
  120. package/dist/sidecar/server.js +1820 -53
  121. package/dist/sidecar/server.js.map +1 -1
  122. package/dist/skills.d.ts +7 -1
  123. package/dist/skills.d.ts.map +1 -1
  124. package/dist/skills.js +18 -5
  125. package/dist/skills.js.map +1 -1
  126. package/dist/tools/builtins.d.ts +17 -0
  127. package/dist/tools/builtins.d.ts.map +1 -1
  128. package/dist/tools/builtins.js +22 -2
  129. package/dist/tools/builtins.js.map +1 -1
  130. package/dist/tools/callAgent.d.ts +14 -1
  131. package/dist/tools/callAgent.d.ts.map +1 -1
  132. package/dist/tools/callAgent.js +18 -2
  133. package/dist/tools/callAgent.js.map +1 -1
  134. package/dist/tools/createCrewAgent.d.ts +39 -0
  135. package/dist/tools/createCrewAgent.d.ts.map +1 -0
  136. package/dist/tools/createCrewAgent.js +121 -0
  137. package/dist/tools/createCrewAgent.js.map +1 -0
  138. package/dist/tools/foldTools.d.ts +5 -0
  139. package/dist/tools/foldTools.d.ts.map +1 -0
  140. package/dist/tools/foldTools.js +123 -0
  141. package/dist/tools/foldTools.js.map +1 -0
  142. package/dist/tools/skillTool.d.ts.map +1 -1
  143. package/dist/tools/skillTool.js +7 -2
  144. package/dist/tools/skillTool.js.map +1 -1
  145. package/package.json +2 -2
@@ -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, () => true)
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
- if (!this.authed)
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
- this.ch.onClose?.(() => {
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 Error(m.error || 'host call failed'));
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.opts.sessions?.list() ?? [] });
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
- this.ch.send({ t: 'mcp-tools', id: m.id, tools: this.opts.mcp?.tools() ?? [] });
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
- : 'unnamed device';
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
- if (this.opts.attendance) {
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
- send: (ask) => this.ch.send({ t: 'permission-ask', ...ask }),
1563
- resolved: (id, decision, by, byClientId) => this.ch.send({ t: 'permission-resolved', id, decision, by, ...(byClientId ? { byClientId } : {}) }),
1564
- runActivity: (activity) => this.ch.send({ t: 'run-activity', activity }),
1565
- registrations: (registrations) => this.ch.send({ t: 'registrations', registrations }),
1566
- approvalSuggestion: (s) => this.ch.send({ t: 'approvals-suggest', ...s }),
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
- const tools = [...this.tools.values()].map(({ run: _run, ...decl }) => decl);
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
- ...(this.opts.info ? { info: this.opts.info } : {}),
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 snapshots = sessions.snapshots ? await sessions.snapshots() : [];
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, whoever started it. Same trust as `run`: a connection that may
2285
- * start runs on this daemon may stop them. */
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 out = cancel.call(this.opts.runner, m.runId);
2293
- this.ch.send(out === 'cancelled'
2294
- ? { t: 'cancel-run-result', id: m.id, runId: m.runId, ok: true }
2295
- : { t: 'cancel-run-result', id: m.id, runId: m.runId, ok: false, reason: 'no-such-run' });
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
- // No crew: asked exactly as an older client asks.
2313
- const runs = !history ? [] : crew
2314
- ? await history(identity, m.agent, m.limit, crew)
2315
- : await history(identity, m.agent, m.limit);
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 lines = this.opts.runner?.transcript
2329
- ? await this.opts.runner.transcript({ clientId: this.clientId }, m.agent, m.runId)
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 memories = this.opts.runner?.memories
2345
- ? await this.opts.runner.memories({ clientId: this.clientId }, m.agent)
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
@@ -2389,7 +3104,14 @@ export class SidecarServer {
2389
3104
  // derived from an agent's `session:` block. A client that could set it
2390
3105
  // would grant itself mutating tools inside a sandbox. Deliberately
2391
3106
  // absent -- see session/guestRunner.ts.
2392
- this.opts.sessions.run(m.sessionId, agent, input, onEvent, identity, { keepTranscript: m.keepTranscript === true })
3107
+ this.opts.sessions.run(m.sessionId, agent, input, onEvent, identity, {
3108
+ keepTranscript: m.keepTranscript === true,
3109
+ // Who asked and why, for the run's audit lines: from THIS
3110
+ // connection's verified identity, never the message, and a run a
3111
+ // connection asked for is a person's run whatever the message says.
3112
+ ...(this.verifiedUserId ? { startedBy: this.verifiedUserId } : {}),
3113
+ origin: 'user',
3114
+ })
2393
3115
  : (agent, input, onEvent, identity) =>
2394
3116
  // Task and autonomy are forwarded as asked; the runner, not this
2395
3117
  // class, decides whether the agent may run that way.
@@ -2397,6 +3119,9 @@ export class SidecarServer {
2397
3119
  task: m.task, autonomy: m.autonomy,
2398
3120
  // From THIS connection's verified identity, never the message.
2399
3121
  ...(this.verifiedUserId ? { startedBy: this.verifiedUserId } : {}),
3122
+ // A run a connection asked for is a person's run (audit `origin`);
3123
+ // only the daemon's own dispatcher says otherwise.
3124
+ origin: 'user',
2400
3125
  });
2401
3126
  let runId;
2402
3127
  const pending = [];
@@ -2503,7 +3228,15 @@ export class SidecarServer {
2503
3228
  const devices = this.opts.devices;
2504
3229
  if (!devices)
2505
3230
  return [];
2506
- return (await devices.list()).map((d) => ({
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) => ({
2507
3240
  id: d.id,
2508
3241
  name: d.name,
2509
3242
  jkt: d.jkt,
@@ -2523,7 +3256,8 @@ export class SidecarServer {
2523
3256
  this.ch.send({ t: 'devices-list-result', id: m.id, ok: true, devices: await this.deviceSummaries() });
2524
3257
  }
2525
3258
  catch (err) {
2526
- this.ch.send({ t: 'devices-list-result', id: m.id, ok: false, error: errMessage(err) });
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 });
2527
3261
  }
2528
3262
  }
2529
3263
  /**
@@ -2557,13 +3291,17 @@ export class SidecarServer {
2557
3291
  * connection's VERIFIED user (never anything the message claims). A
2558
3292
  * connection with no verified user revokes nothing. The operator's local
2559
3293
  * `devices revoke` verb keeps full power; it does not come through here. */
2560
- if (this.opts.admission?.ownDevicesOnly) {
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()) {
2561
3298
  let owner;
2562
3299
  try {
2563
3300
  owner = (await devices.get(m.deviceId))?.verifiedUserId;
2564
3301
  }
2565
3302
  catch (err) {
2566
- this.ch.send({ t: 'devices-revoke-result', id: m.id, ok: false, error: errMessage(err) });
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 });
2567
3305
  return;
2568
3306
  }
2569
3307
  if (!this.verifiedUserId || owner !== this.verifiedUserId) {
@@ -2584,7 +3322,8 @@ export class SidecarServer {
2584
3322
  this.ch.send({ t: 'devices-revoke-result', id: m.id, ok: true, devices: await this.deviceSummaries() });
2585
3323
  }
2586
3324
  catch (err) {
2587
- this.ch.send({ t: 'devices-revoke-result', id: m.id, ok: false, error: errMessage(err) });
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 });
2588
3327
  }
2589
3328
  }
2590
3329
  /** Every synced host directory, with `self` stamped from THIS connection's
@@ -2773,6 +3512,11 @@ export class SidecarServer {
2773
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' });
2774
3513
  return;
2775
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
+ }
2776
3520
  // A connection that never attached to attendance was never offered
2777
3521
  // anything, and has no identity to accept with.
2778
3522
  if (this.attended === undefined) {
@@ -2782,12 +3526,13 @@ export class SidecarServer {
2782
3526
  try {
2783
3527
  // `this.attended` -- the object THIS connection attached -- is the only
2784
3528
  // accepter identity; the daemon compares it with the client it offered
2785
- // to. Nothing in the message can name another connection.
2786
- await port.accept(this.attended, m.suggestionId, m.choice);
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);
2787
3532
  this.ch.send({ t: 'approvals-accept-result', id: m.id, ok: true });
2788
3533
  }
2789
3534
  catch (err) {
2790
- this.ch.send({ t: 'approvals-accept-result', id: m.id, ok: false, error: errMessage(err) });
3535
+ this.ch.send({ t: 'approvals-accept-result', id: m.id, ok: false, error: this.approvalsError(m.t, err) });
2791
3536
  }
2792
3537
  }
2793
3538
  async onApprovalsList(m) {
@@ -2796,11 +3541,16 @@ export class SidecarServer {
2796
3541
  this.ch.send({ t: 'approvals-list-result', id: m.id, ok: false, error: 'this daemon does not keep standing approvals' });
2797
3542
  return;
2798
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
+ }
2799
3549
  try {
2800
- 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) });
2801
3551
  }
2802
3552
  catch (err) {
2803
- this.ch.send({ t: 'approvals-list-result', id: m.id, ok: false, error: errMessage(err) });
3553
+ this.ch.send({ t: 'approvals-list-result', id: m.id, ok: false, error: this.approvalsError(m.t, err) });
2804
3554
  }
2805
3555
  }
2806
3556
  async onApprovalsRevoke(m) {
@@ -2813,13 +3563,40 @@ export class SidecarServer {
2813
3563
  this.ch.send({ t: 'approvals-revoke-result', id: m.id, ok: false, error: 'tool must be a non-empty string' });
2814
3564
  return;
2815
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
+ }
2816
3577
  try {
2817
- 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) });
2818
3579
  }
2819
3580
  catch (err) {
2820
- this.ch.send({ t: 'approvals-revoke-result', id: m.id, ok: false, error: errMessage(err) });
3581
+ this.ch.send({ t: 'approvals-revoke-result', id: m.id, ok: false, error: this.approvalsError(m.t, err) });
2821
3582
  }
2822
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
+ }
2823
3600
  /** Why the checks port cannot answer right now, or null when it can. */
2824
3601
  checksRefusal() {
2825
3602
  if (!this.opts.checks)
@@ -2905,7 +3682,16 @@ export class SidecarServer {
2905
3682
  * run (recorded with it, restarts included). A run with no recorded
2906
3683
  * starter (the daemon's own clock, or an unverified start) is refused
2907
3684
  * too: fail closed. The plan alone is not gated. */
2908
- if (output && this.opts.admission?.ownRunsOnly) {
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) {
2909
3695
  const starter = runner.runStartedBy ? await runner.runStartedBy(m.runId) : null;
2910
3696
  if (!this.verifiedUserId || starter !== this.verifiedUserId) {
2911
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.' });
@@ -3032,6 +3818,11 @@ export class SidecarServer {
3032
3818
  });
3033
3819
  return;
3034
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
+ }
3035
3826
  const clientId = this.roomIdentity();
3036
3827
  const owner = clientId === undefined
3037
3828
  ? undefined
@@ -3143,6 +3934,11 @@ export class SidecarServer {
3143
3934
  return;
3144
3935
  }
3145
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
+ }
3146
3942
  const room = await this.opts.rooms.resume(m.roomId);
3147
3943
  this.ch.send({ t: 'room-resume-result', id: m.id, ok: true, room });
3148
3944
  }
@@ -3152,6 +3948,42 @@ export class SidecarServer {
3152
3948
  this.ch.send({ t: 'room-resume-result', id: m.id, ok: false, error: errMessage(err) });
3153
3949
  }
3154
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
+ }
3155
3987
  /** WHO THIS CONNECTION IS, INSIDE A ROOM.
3156
3988
  *
3157
3989
  * The user its pairing JWT proved (`verifiedUserId`: API-signed, aud = this
@@ -3208,6 +4040,11 @@ export class SidecarServer {
3208
4040
  this.ch.send({ t: 'room-answer-result', id: m.id, ok: false, error: actor.error });
3209
4041
  return;
3210
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
+ }
3211
4048
  const room = await this.opts.rooms.answer(m.roomId, actor.id, m.text, m.addressed);
3212
4049
  this.ch.send({ t: 'room-answer-result', id: m.id, ok: true, room });
3213
4050
  }
@@ -3228,6 +4065,14 @@ export class SidecarServer {
3228
4065
  this.ch.send({ t: 'room-interject-result', id: m.id, ok: false, error: actor.error });
3229
4066
  return;
3230
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
+ }
3231
4076
  const room = await this.opts.rooms.interject(m.roomId, actor.id, m.text);
3232
4077
  this.ch.send({ t: 'room-interject-result', id: m.id, ok: true, room });
3233
4078
  }
@@ -3290,9 +4135,22 @@ export class SidecarServer {
3290
4135
  this.ch.send({ t: 'mcp-admin-error', id: m.id, error: 'this daemon cannot manage MCP servers -- update it and reconnect' });
3291
4136
  return;
3292
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
+ }
3293
4145
  try {
3294
4146
  if (m.t === 'mcp-servers-list') {
3295
- this.ch.send({ t: 'mcp-servers', id: m.id, servers: await admin.listServers() });
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
+ });
3296
4154
  return;
3297
4155
  }
3298
4156
  // `=== true`, not truthy: this decides whether the daemon attaches the
@@ -3323,11 +4181,111 @@ export class SidecarServer {
3323
4181
  this.ch.send({ t: 'mcp-admin-error', id: m.id, error: e instanceof Error ? e.message : String(e) });
3324
4182
  }
3325
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
+ }
3326
4279
  async onMcpCall(m) {
3327
4280
  if (!this.opts.mcp) {
3328
4281
  this.ch.send({ t: 'mcp-error', id: m.id, error: 'this daemon serves no MCP tools' });
3329
4282
  return;
3330
4283
  }
4284
+ const refusal = this.mcpRefusal();
4285
+ if (refusal) {
4286
+ this.ch.send({ t: 'mcp-error', id: m.id, error: refusal });
4287
+ return;
4288
+ }
3331
4289
  const parsed = parseMcpToolAddress(m.address);
3332
4290
  if (!parsed) {
3333
4291
  this.ch.send({ t: 'mcp-error', id: m.id, error: `"${m.address}" is not a valid MCP tool address` });
@@ -3346,6 +4304,675 @@ export class SidecarServer {
3346
4304
  this.ch.send({ t: 'mcp-error', id: m.id, error: errMessage(err) });
3347
4305
  }
3348
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
+ }
3349
4976
  async onRoomFindings(m) {
3350
4977
  if (!this.opts.rooms) {
3351
4978
  // A daemon without the capability hosts no rooms, so it holds no
@@ -3391,8 +5018,19 @@ export class SidecarServer {
3391
5018
  return;
3392
5019
  }
3393
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
+ }
3394
5028
  if (m.t === 'config-list') {
3395
- this.ch.send({ t: 'config-result', id: m.id, ok: true, entries: await svc.list(m.path, m.depth) });
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 });
3396
5034
  return;
3397
5035
  }
3398
5036
  if (m.t === 'config-read') {
@@ -3408,12 +5046,91 @@ export class SidecarServer {
3408
5046
  }
3409
5047
  else
3410
5048
  await svc.remove(m.path);
5049
+ await this.afterConfigChange(gate);
3411
5050
  this.ch.send({ t: 'config-result', id: m.id, ok: true });
3412
5051
  }
3413
5052
  catch (e) {
3414
5053
  this.ch.send({ t: 'config-result', id: m.id, ok: false, error: errMessage(e) });
3415
5054
  }
3416
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
+ }
3417
5134
  async onHostSyncPut(m) {
3418
5135
  const identity = this.requireHostSyncIdentity();
3419
5136
  if ('error' in identity) {
@@ -3469,12 +5186,62 @@ export class SidecarServer {
3469
5186
  }
3470
5187
  hostCall(provider, method, args) {
3471
5188
  const id = ++this.hostSeq;
5189
+ const label = `${provider}.${method}`;
3472
5190
  return new Promise((resolve, reject) => {
3473
- this.hostPending.set(id, { resolve, reject });
3474
- this.ch.send({ t: 'host-call', id, provider, method, args });
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
+ }
3475
5217
  });
3476
5218
  }
3477
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
+ }
3478
5245
  /** `run-history`'s `crew`, off the wire: anything but the two known shapes is
3479
5246
  * treated as absent (the global crew, as an older client meant). The id is
3480
5247
  * only a selector among the CALLER's own roots -- the runner matches it