@corenel/harness 0.9.0 → 0.11.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 (247) hide show
  1. package/dist/approvals/decisionLog.d.ts +21 -0
  2. package/dist/approvals/decisionLog.d.ts.map +1 -0
  3. package/dist/approvals/decisionLog.js +129 -0
  4. package/dist/approvals/decisionLog.js.map +1 -0
  5. package/dist/approvals/hooks.d.ts +18 -0
  6. package/dist/approvals/hooks.d.ts.map +1 -0
  7. package/dist/approvals/hooks.js +34 -0
  8. package/dist/approvals/hooks.js.map +1 -0
  9. package/dist/approvals/index.d.ts +10 -0
  10. package/dist/approvals/index.d.ts.map +1 -0
  11. package/dist/approvals/index.js +10 -0
  12. package/dist/approvals/index.js.map +1 -0
  13. package/dist/approvals/service.d.ts +46 -0
  14. package/dist/approvals/service.d.ts.map +1 -0
  15. package/dist/approvals/service.js +181 -0
  16. package/dist/approvals/service.js.map +1 -0
  17. package/dist/approvals/settings.d.ts +20 -0
  18. package/dist/approvals/settings.d.ts.map +1 -0
  19. package/dist/approvals/settings.js +32 -0
  20. package/dist/approvals/settings.js.map +1 -0
  21. package/dist/approvals/shellTools.d.ts +10 -0
  22. package/dist/approvals/shellTools.d.ts.map +1 -0
  23. package/dist/approvals/shellTools.js +17 -0
  24. package/dist/approvals/shellTools.js.map +1 -0
  25. package/dist/approvals/standingStore.d.ts +15 -0
  26. package/dist/approvals/standingStore.d.ts.map +1 -0
  27. package/dist/approvals/standingStore.js +50 -0
  28. package/dist/approvals/standingStore.js.map +1 -0
  29. package/dist/approvals/suggest.d.ts +15 -0
  30. package/dist/approvals/suggest.d.ts.map +1 -0
  31. package/dist/approvals/suggest.js +23 -0
  32. package/dist/approvals/suggest.js.map +1 -0
  33. package/dist/approvals/summarize.d.ts +7 -0
  34. package/dist/approvals/summarize.d.ts.map +1 -0
  35. package/dist/approvals/summarize.js +70 -0
  36. package/dist/approvals/summarize.js.map +1 -0
  37. package/dist/approvals/testTextStore.d.ts +6 -0
  38. package/dist/approvals/testTextStore.d.ts.map +1 -0
  39. package/dist/approvals/testTextStore.js +11 -0
  40. package/dist/approvals/testTextStore.js.map +1 -0
  41. package/dist/approvals/textStore.d.ts +9 -0
  42. package/dist/approvals/textStore.d.ts.map +1 -0
  43. package/dist/approvals/textStore.js +2 -0
  44. package/dist/approvals/textStore.js.map +1 -0
  45. package/dist/audit/canonical.d.ts +5 -0
  46. package/dist/audit/canonical.d.ts.map +1 -0
  47. package/dist/audit/canonical.js +15 -0
  48. package/dist/audit/canonical.js.map +1 -0
  49. package/dist/audit/ids.d.ts +3 -0
  50. package/dist/audit/ids.d.ts.map +1 -0
  51. package/dist/audit/ids.js +11 -0
  52. package/dist/audit/ids.js.map +1 -0
  53. package/dist/audit/index.d.ts +7 -0
  54. package/dist/audit/index.d.ts.map +1 -0
  55. package/dist/audit/index.js +7 -0
  56. package/dist/audit/index.js.map +1 -0
  57. package/dist/audit/log.d.ts +64 -0
  58. package/dist/audit/log.d.ts.map +1 -0
  59. package/dist/audit/log.js +457 -0
  60. package/dist/audit/log.js.map +1 -0
  61. package/dist/audit/recorder.d.ts +31 -0
  62. package/dist/audit/recorder.d.ts.map +1 -0
  63. package/dist/audit/recorder.js +185 -0
  64. package/dist/audit/recorder.js.map +1 -0
  65. package/dist/audit/runAudit.d.ts +38 -0
  66. package/dist/audit/runAudit.d.ts.map +1 -0
  67. package/dist/audit/runAudit.js +99 -0
  68. package/dist/audit/runAudit.js.map +1 -0
  69. package/dist/audit/verify.d.ts +14 -0
  70. package/dist/audit/verify.d.ts.map +1 -0
  71. package/dist/audit/verify.js +47 -0
  72. package/dist/audit/verify.js.map +1 -0
  73. package/dist/conversation.d.ts +31 -0
  74. package/dist/conversation.d.ts.map +1 -1
  75. package/dist/conversation.js +33 -0
  76. package/dist/conversation.js.map +1 -1
  77. package/dist/core/hash.d.ts +2 -0
  78. package/dist/core/hash.d.ts.map +1 -0
  79. package/dist/core/hash.js +9 -0
  80. package/dist/core/hash.js.map +1 -0
  81. package/dist/core/loop.d.ts +18 -0
  82. package/dist/core/loop.d.ts.map +1 -1
  83. package/dist/core/loop.js +61 -5
  84. package/dist/core/loop.js.map +1 -1
  85. package/dist/core/spawn.d.ts +59 -4
  86. package/dist/core/spawn.d.ts.map +1 -1
  87. package/dist/core/spawn.js +83 -5
  88. package/dist/core/spawn.js.map +1 -1
  89. package/dist/core/turnVerify.d.ts +25 -0
  90. package/dist/core/turnVerify.d.ts.map +1 -0
  91. package/dist/core/turnVerify.js +177 -0
  92. package/dist/core/turnVerify.js.map +1 -0
  93. package/dist/dag/executeNode.d.ts +7 -0
  94. package/dist/dag/executeNode.d.ts.map +1 -1
  95. package/dist/dag/executeNode.js +3 -0
  96. package/dist/dag/executeNode.js.map +1 -1
  97. package/dist/guardrail/guardrail.d.ts +7 -0
  98. package/dist/guardrail/guardrail.d.ts.map +1 -1
  99. package/dist/guardrail/guardrail.js +8 -0
  100. package/dist/guardrail/guardrail.js.map +1 -1
  101. package/dist/guardrail/index.d.ts +2 -2
  102. package/dist/guardrail/index.d.ts.map +1 -1
  103. package/dist/guardrail/index.js +2 -2
  104. package/dist/guardrail/index.js.map +1 -1
  105. package/dist/guardrail/permission-service.d.ts +37 -6
  106. package/dist/guardrail/permission-service.d.ts.map +1 -1
  107. package/dist/guardrail/permission-service.js +111 -11
  108. package/dist/guardrail/permission-service.js.map +1 -1
  109. package/dist/guardrail/serialize.d.ts.map +1 -1
  110. package/dist/guardrail/serialize.js +2 -0
  111. package/dist/guardrail/serialize.js.map +1 -1
  112. package/dist/guardrail/validate.d.ts.map +1 -1
  113. package/dist/guardrail/validate.js +3 -1
  114. package/dist/guardrail/validate.js.map +1 -1
  115. package/dist/install/lockfile.d.ts +13 -5
  116. package/dist/install/lockfile.d.ts.map +1 -1
  117. package/dist/install/lockfile.js +31 -2
  118. package/dist/install/lockfile.js.map +1 -1
  119. package/dist/orchestrator/agentSearch.d.ts +15 -0
  120. package/dist/orchestrator/agentSearch.d.ts.map +1 -0
  121. package/dist/orchestrator/agentSearch.js +102 -0
  122. package/dist/orchestrator/agentSearch.js.map +1 -0
  123. package/dist/orchestrator/agentTags.d.ts +13 -0
  124. package/dist/orchestrator/agentTags.d.ts.map +1 -0
  125. package/dist/orchestrator/agentTags.js +48 -0
  126. package/dist/orchestrator/agentTags.js.map +1 -0
  127. package/dist/orchestrator/branch.d.ts +9 -0
  128. package/dist/orchestrator/branch.d.ts.map +1 -0
  129. package/dist/orchestrator/branch.js +26 -0
  130. package/dist/orchestrator/branch.js.map +1 -0
  131. package/dist/orchestrator/checks.d.ts +16 -0
  132. package/dist/orchestrator/checks.d.ts.map +1 -0
  133. package/dist/orchestrator/checks.js +66 -0
  134. package/dist/orchestrator/checks.js.map +1 -0
  135. package/dist/orchestrator/planDoc.d.ts +15 -0
  136. package/dist/orchestrator/planDoc.d.ts.map +1 -0
  137. package/dist/orchestrator/planDoc.js +59 -0
  138. package/dist/orchestrator/planDoc.js.map +1 -0
  139. package/dist/orchestrator/record.d.ts +103 -0
  140. package/dist/orchestrator/record.d.ts.map +1 -0
  141. package/dist/orchestrator/record.js +168 -0
  142. package/dist/orchestrator/record.js.map +1 -0
  143. package/dist/orchestrator/sidecarTools.d.ts +7 -0
  144. package/dist/orchestrator/sidecarTools.d.ts.map +1 -0
  145. package/dist/orchestrator/sidecarTools.js +141 -0
  146. package/dist/orchestrator/sidecarTools.js.map +1 -0
  147. package/dist/prompts/config.d.ts.map +1 -1
  148. package/dist/prompts/config.js +11 -0
  149. package/dist/prompts/config.js.map +1 -1
  150. package/dist/prompts/index.d.ts +0 -6
  151. package/dist/prompts/index.d.ts.map +1 -1
  152. package/dist/prompts/index.js +10 -17
  153. package/dist/prompts/index.js.map +1 -1
  154. package/dist/prompts/library/defaults.generated.d.ts.map +1 -1
  155. package/dist/prompts/library/defaults.generated.js +29 -12
  156. package/dist/prompts/library/defaults.generated.js.map +1 -1
  157. package/dist/redact.d.ts.map +1 -1
  158. package/dist/redact.js +1 -0
  159. package/dist/redact.js.map +1 -1
  160. package/dist/rooms/findings.d.ts +14 -0
  161. package/dist/rooms/findings.d.ts.map +1 -1
  162. package/dist/rooms/findings.js +37 -0
  163. package/dist/rooms/findings.js.map +1 -1
  164. package/dist/sessions/blobGc.d.ts.map +1 -1
  165. package/dist/sessions/blobGc.js +2 -5
  166. package/dist/sessions/blobGc.js.map +1 -1
  167. package/dist/sessions/childSessions.d.ts +12 -0
  168. package/dist/sessions/childSessions.d.ts.map +1 -0
  169. package/dist/sessions/childSessions.js +43 -0
  170. package/dist/sessions/childSessions.js.map +1 -0
  171. package/dist/sessions/fileRecallStore.d.ts.map +1 -1
  172. package/dist/sessions/fileRecallStore.js +1 -3
  173. package/dist/sessions/fileRecallStore.js.map +1 -1
  174. package/dist/sessions/fileSessionStore.d.ts +13 -8
  175. package/dist/sessions/fileSessionStore.d.ts.map +1 -1
  176. package/dist/sessions/fileSessionStore.js +53 -17
  177. package/dist/sessions/fileSessionStore.js.map +1 -1
  178. package/dist/sessions/inflight.js +0 -0
  179. package/dist/sessions/inflight.js.map +1 -1
  180. package/dist/sessions/jsonl.d.ts +9 -0
  181. package/dist/sessions/jsonl.d.ts.map +1 -1
  182. package/dist/sessions/jsonl.js +11 -0
  183. package/dist/sessions/jsonl.js.map +1 -1
  184. package/dist/sessions/testFs.d.ts.map +1 -1
  185. package/dist/sessions/testFs.js +1 -1
  186. package/dist/sessions/testFs.js.map +1 -1
  187. package/dist/sidecar/host.d.ts +178 -1
  188. package/dist/sidecar/host.d.ts.map +1 -1
  189. package/dist/sidecar/host.js +363 -3
  190. package/dist/sidecar/host.js.map +1 -1
  191. package/dist/sidecar/identity.d.ts +44 -0
  192. package/dist/sidecar/identity.d.ts.map +1 -0
  193. package/dist/sidecar/identity.js +45 -0
  194. package/dist/sidecar/identity.js.map +1 -0
  195. package/dist/sidecar/server.d.ts +273 -6
  196. package/dist/sidecar/server.d.ts.map +1 -1
  197. package/dist/sidecar/server.js +1009 -40
  198. package/dist/sidecar/server.js.map +1 -1
  199. package/dist/skillSource.js +1 -1
  200. package/dist/skillSource.js.map +1 -1
  201. package/dist/skills.d.ts +23 -7
  202. package/dist/skills.d.ts.map +1 -1
  203. package/dist/skills.js +62 -37
  204. package/dist/skills.js.map +1 -1
  205. package/dist/tools/callAgent.d.ts +29 -12
  206. package/dist/tools/callAgent.d.ts.map +1 -1
  207. package/dist/tools/callAgent.js +120 -14
  208. package/dist/tools/callAgent.js.map +1 -1
  209. package/dist/tools/crewList.d.ts +33 -0
  210. package/dist/tools/crewList.d.ts.map +1 -0
  211. package/dist/tools/crewList.js +66 -0
  212. package/dist/tools/crewList.js.map +1 -0
  213. package/dist/tools/files.d.ts.map +1 -1
  214. package/dist/tools/files.js +11 -5
  215. package/dist/tools/files.js.map +1 -1
  216. package/dist/tools/findAgent.d.ts +23 -0
  217. package/dist/tools/findAgent.d.ts.map +1 -0
  218. package/dist/tools/findAgent.js +58 -0
  219. package/dist/tools/findAgent.js.map +1 -0
  220. package/dist/tools/namespaces.d.ts.map +1 -1
  221. package/dist/tools/namespaces.js +7 -0
  222. package/dist/tools/namespaces.js.map +1 -1
  223. package/dist/tools/orchestrate.d.ts +4 -0
  224. package/dist/tools/orchestrate.d.ts.map +1 -0
  225. package/dist/tools/orchestrate.js +38 -0
  226. package/dist/tools/orchestrate.js.map +1 -0
  227. package/dist/tools/orchestration.d.ts +24 -0
  228. package/dist/tools/orchestration.d.ts.map +1 -0
  229. package/dist/tools/orchestration.js +173 -0
  230. package/dist/tools/orchestration.js.map +1 -0
  231. package/dist/tools/orchestrationSet.d.ts +6 -0
  232. package/dist/tools/orchestrationSet.d.ts.map +1 -0
  233. package/dist/tools/orchestrationSet.js +12 -0
  234. package/dist/tools/orchestrationSet.js.map +1 -0
  235. package/dist/tools/roomTools.d.ts +6 -0
  236. package/dist/tools/roomTools.d.ts.map +1 -1
  237. package/dist/tools/roomTools.js +26 -2
  238. package/dist/tools/roomTools.js.map +1 -1
  239. package/dist/tools/skillTool.d.ts +15 -0
  240. package/dist/tools/skillTool.d.ts.map +1 -0
  241. package/dist/tools/skillTool.js +39 -0
  242. package/dist/tools/skillTool.js.map +1 -0
  243. package/dist/tools/urlGuard.d.ts +7 -0
  244. package/dist/tools/urlGuard.d.ts.map +1 -1
  245. package/dist/tools/urlGuard.js +54 -18
  246. package/dist/tools/urlGuard.js.map +1 -1
  247. package/package.json +8 -3
@@ -1,9 +1,23 @@
1
- import { SIDECAR_PROTOCOL, errMessage, isLoopbackHost, parseMcpToolAddress, } from '@corenel/protocol';
1
+ import { DEFAULT_STATE_DIR, RUN_TASK_FEATURE, CREW_DEPLOY_FEATURE } from '@corenel/protocol';
2
+ import { SIDECAR_PROTOCOL, errMessage, isLoopbackHost, isLoopbackAddress, parseMcpToolAddress, isWebPushSubscription, MAX_ASSERTION_LEN, } from '@corenel/protocol';
3
+ import { pairingTokenHash } from './identity.js';
4
+ const NO_CHECKS = 'this sidecar does not run checks (start it with --allow-shell)';
5
+ /** Default pre-pairing deadline: a real client sends `hello` immediately. */
6
+ export const HELLO_DEADLINE_MS = 10000;
7
+ /** Denied hellos one connection may send before it is closed: a client with a
8
+ * stale token needs one, not an unbounded guessing loop. */
9
+ export const MAX_FAILED_HELLOS = 3;
2
10
  /** How long the welcome waits on `clientDirName` before going out without it. A
3
11
  * manifest write is a few milliseconds; anything near this is a wedged disk, and
4
12
  * a client left with no handshake at all is worse than one that has to fall back
5
13
  * to deducing its own directory. */
6
14
  const DIR_NAME_TIMEOUT_MS = 3000;
15
+ /** How long pairing waits on the identity verifier before going out
16
+ * UNVERIFIED. The same bound as `DIR_NAME_TIMEOUT_MS`, and run concurrently
17
+ * with it, so a verifier that hangs never makes the handshake slower than a
18
+ * wedged dirName lookup already could. A timeout never refuses the connection:
19
+ * it only withholds the widened trust a verified id would earn. */
20
+ export const IDENTITY_TIMEOUT_MS = 3000;
7
21
  /** True for anything with a callable `.then` -- covers native Promises and any
8
22
  * other thenable a caller's `onClient` might return, without assuming it's a
9
23
  * real Promise instance (the declared type is `() => void`; an async function
@@ -24,6 +38,19 @@ const MAX_IDENTITY_FIELD_LEN = 200;
24
38
  * daemon produces -- it only stops an unbounded list from becoming unbounded
25
39
  * work at a point where the connection has just authenticated. */
26
40
  const MAX_CLAIMED_ACCOUNTS = 8;
41
+ /** Bounds on a hello's client `capabilities`. A list outside them is carried
42
+ * as NOTHING (not truncated): it is informational only, so the fail-safe
43
+ * reading of a malformed list is "an older app". */
44
+ const MAX_CAPABILITIES = 16;
45
+ const MAX_CAPABILITY_LEN = 64;
46
+ /** The client capabilities a hello announced, or [] when absent or malformed
47
+ * (not an array, too long, or holding anything but short strings). */
48
+ function announcedCapabilities(v) {
49
+ if (!Array.isArray(v) || v.length > MAX_CAPABILITIES)
50
+ return [];
51
+ const list = v;
52
+ return list.every((c) => typeof c === 'string' && c.length <= MAX_CAPABILITY_LEN) ? list : [];
53
+ }
27
54
  /** Base64-encode bytes without a Node Buffer (btoa is in the browser + Node 18+),
28
55
  * so this module stays free of node:* types for the shared harness build. */
29
56
  function bytesToB64(bytes) {
@@ -32,6 +59,16 @@ function bytesToB64(bytes) {
32
59
  bin += String.fromCharCode(bytes[i]);
33
60
  return btoa(bin);
34
61
  }
62
+ /** A fresh, single-use identity-challenge nonce: 32 random bytes, base64url
63
+ * (43 characters, no padding). `crypto.getRandomValues` — not node:crypto's
64
+ * `randomBytes` — for the same host-agnostic reason as `bytesToB64` above:
65
+ * it is a Web Crypto global in both the browser and Node 18+, so this module
66
+ * stays free of node:* imports. */
67
+ function makeNonce() {
68
+ const bytes = new Uint8Array(32);
69
+ crypto.getRandomValues(bytes);
70
+ return bytesToB64(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
71
+ }
35
72
  /** Constant-time string compare for the pairing token — avoids leaking it via the
36
73
  * early-exit timing of `!==`. Pure JS so this stays host-agnostic (no node:crypto in
37
74
  * a possibly-bundled module). The token is a fixed-length hex string, so comparing
@@ -44,6 +81,39 @@ function safeEqual(a, b) {
44
81
  diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
45
82
  return diff === 0;
46
83
  }
84
+ /** Longest verdict reason the harness will put on a welcome or in a log. */
85
+ const MAX_REASON_LEN = 80;
86
+ /** A verifier-supplied reason (or error message), made safe to echo on the
87
+ * welcome and write to the log WHATEVER the verifier put in it: only
88
+ * `[A-Za-z0-9 .,:_()-]` survives (no newlines, quotes, braces or log-forging
89
+ * control text), whitespace runs collapse, and the result is capped at
90
+ * MAX_REASON_LEN. Nothing left means 'rejected'. The harness does this itself
91
+ * rather than trusting every verifier to keep its reasons short and clean. */
92
+ function sanitizeReason(raw) {
93
+ const kept = raw.replace(/[^A-Za-z0-9 .,:_()-]/g, '').replace(/ {2,}/g, ' ').trim().slice(0, MAX_REASON_LEN).trim();
94
+ return kept || 'rejected';
95
+ }
96
+ /** A verifier's answer, re-checked before it can bind anything. The port is
97
+ * daemon code, but a verdict that claims success without a usable user id must
98
+ * fail CLOSED (unverified), never bind an empty, oversized or non-string id. A
99
+ * failure with no usable reason still reads as a failure. */
100
+ function normalizeVerdict(got) {
101
+ if (typeof got === 'object' && got !== null) {
102
+ const r = got;
103
+ if (r.ok === true) {
104
+ const userId = r.identity?.userId;
105
+ const issuer = r.identity?.issuer;
106
+ if (typeof userId === 'string' && userId && userId.length <= MAX_IDENTITY_FIELD_LEN) {
107
+ return { ok: true, identity: { userId, issuer: typeof issuer === 'string' ? issuer : '' } };
108
+ }
109
+ return { ok: false, reason: 'malformed verdict' };
110
+ }
111
+ if (r.ok === false) {
112
+ return { ok: false, reason: typeof r.reason === 'string' ? sanitizeReason(r.reason) : 'rejected' };
113
+ }
114
+ }
115
+ return { ok: false, reason: 'malformed verdict' };
116
+ }
47
117
  export class SidecarServer {
48
118
  constructor(opts) {
49
119
  Object.defineProperty(this, "ch", {
@@ -70,6 +140,19 @@ export class SidecarServer {
70
140
  writable: true,
71
141
  value: false
72
142
  });
143
+ /** Denied hellos on this connection (see MAX_FAILED_HELLOS). */
144
+ Object.defineProperty(this, "failedHellos", {
145
+ enumerable: true,
146
+ configurable: true,
147
+ writable: true,
148
+ value: 0
149
+ });
150
+ Object.defineProperty(this, "clearHelloDeadline", {
151
+ enumerable: true,
152
+ configurable: true,
153
+ writable: true,
154
+ value: () => { }
155
+ });
73
156
  Object.defineProperty(this, "hostSeq", {
74
157
  enumerable: true,
75
158
  configurable: true,
@@ -89,7 +172,7 @@ export class SidecarServer {
89
172
  enumerable: true,
90
173
  configurable: true,
91
174
  writable: true,
92
- value: '.corenel'
175
+ value: DEFAULT_STATE_DIR
93
176
  });
94
177
  /** The authenticated connection's `clientId`, retained here (from `hello`) so
95
178
  * later `host-sync-*` messages can be attributed to their owner without
@@ -112,6 +195,73 @@ export class SidecarServer {
112
195
  writable: true,
113
196
  value: void 0
114
197
  });
198
+ /** The connection's claimed accounts from the same `hello`, kept beside
199
+ * `clientId` so web push can be filed under both. Bounded/typeof-filtered
200
+ * the same way `clientId` is -- see the `accounts` local in `onHello`. */
201
+ Object.defineProperty(this, "accounts", {
202
+ enumerable: true,
203
+ configurable: true,
204
+ writable: true,
205
+ value: []
206
+ });
207
+ /** The VERIFIED user id (the identity token's `sub`), set at most once, in
208
+ * `onHello`, after the pairing token passed and the verifier answered. It is
209
+ * never re-assigned: no message re-authenticates, so a hijacked socket cannot
210
+ * swap whose connection this is. Undefined = unverified, which is every
211
+ * connection on a daemon with no `identity` verifier. The token itself is
212
+ * never kept. */
213
+ Object.defineProperty(this, "verifiedUserId", {
214
+ enumerable: true,
215
+ configurable: true,
216
+ writable: true,
217
+ value: undefined
218
+ });
219
+ /** The verdict echoed on the welcome, when a verifier is wired. Dead by the
220
+ * time a welcome is ever sent -- `onHello`'s identity block always
221
+ * overwrites it with a real reason ('not requested', 'no daemon id', or the
222
+ * challenge/assert outcome) before `sendWelcome` reads it -- but given a
223
+ * value anyway rather than left to a type assertion, matching every other
224
+ * field on this class. */
225
+ Object.defineProperty(this, "identityResult", {
226
+ enumerable: true,
227
+ configurable: true,
228
+ writable: true,
229
+ value: { verified: false, reason: 'not requested' }
230
+ });
231
+ /** Resolves the promise `onHello` is awaiting on `identity-assert`. Cleared
232
+ * (set to `undefined`) the moment it is used, which is what makes a SECOND
233
+ * `identity-assert` on the same connection a no-op: with no resolver left,
234
+ * the handler in `onMessage` has nothing to call and the message is simply
235
+ * ignored. Also resolved (to a timeout-shaped answer) from the close
236
+ * handler, so a connection that drops while a verdict is pending does not
237
+ * leave `onHello` waiting on a promise nothing will ever settle until the
238
+ * unrelated 3s timer fires. */
239
+ Object.defineProperty(this, "identityAssertResolve", {
240
+ enumerable: true,
241
+ configurable: true,
242
+ writable: true,
243
+ value: void 0
244
+ });
245
+ /** Set when the channel closes. `onHello` awaits (identity, dirName) before
246
+ * attaching to attendance and sending the welcome; a connection that dropped
247
+ * during that wait must not be attached afterwards, or the registry would
248
+ * hold a dead client that nothing ever detaches. */
249
+ Object.defineProperty(this, "closed", {
250
+ enumerable: true,
251
+ configurable: true,
252
+ writable: true,
253
+ value: false
254
+ });
255
+ /** This connection's attended-client object, retained so an answer it sends
256
+ * can be attributed to it. Typed `unknown` because its only consumer is the
257
+ * registry's own identity lookup, which takes `unknown` for the
258
+ * dependency-direction reason on `SidecarAttendanceRegistry.answer`. */
259
+ Object.defineProperty(this, "attended", {
260
+ enumerable: true,
261
+ configurable: true,
262
+ writable: true,
263
+ value: void 0
264
+ });
115
265
  /** Detach function for this connection's attendance attachment, set on a
116
266
  * successful `hello` when `opts.attendance` is wired. Called once, on channel
117
267
  * close, so a dead connection stops receiving/counting toward asks. */
@@ -259,6 +409,15 @@ export class SidecarServer {
259
409
  for (const t of opts.tools)
260
410
  this.tools.set(t.name, t);
261
411
  this.ch.onMessage((m) => this.onMessage(m));
412
+ const deadline = setTimeout(() => {
413
+ if (this.authed || this.closed)
414
+ return;
415
+ this.opts.log?.('closing a connection that did not pair in time');
416
+ this.ch.close();
417
+ }, opts.helloDeadlineMs ?? HELLO_DEADLINE_MS);
418
+ // Never keep a node process alive for this timer alone.
419
+ deadline.unref?.();
420
+ this.clearHelloDeadline = () => clearTimeout(deadline);
262
421
  // Every connected server subscribes, which is what makes a root added by one
263
422
  // paired client reach ALL of them: two browsers on one daemon must never
264
423
  // disagree about what is reachable. The payload is the COMPLETE set.
@@ -285,6 +444,14 @@ export class SidecarServer {
285
444
  this.ch.send({ t: 'room-progress', room });
286
445
  });
287
446
  this.ch.onClose?.(() => {
447
+ this.closed = true;
448
+ this.clearHelloDeadline();
449
+ // A connection that drops while `onHello` is awaiting `identity-assert`
450
+ // must not leave that promise pending until the unrelated 3s timer
451
+ // fires -- settle it now, exactly like a timeout, so onHello observes
452
+ // `this.closed` on its very next line and attaches/sends nothing.
453
+ this.identityAssertResolve?.({ kind: 'timeout' });
454
+ this.identityAssertResolve = undefined;
288
455
  this.attendedDetach?.();
289
456
  this.attendedDetach = undefined;
290
457
  // Leave the live set, or this directory reads as connected for ever and
@@ -314,10 +481,33 @@ export class SidecarServer {
314
481
  onMessage(m) {
315
482
  switch (m.t) {
316
483
  case 'hello':
484
+ /* Identity is fixed at first hello; a second must not re-own the
485
+ * connection. `authed` is set SYNCHRONOUSLY inside onHello, before its
486
+ * first await, so this also refuses a second hello that arrives while
487
+ * the first is still waiting on the identity verifier. */
317
488
  if (this.authed)
318
- return; // identity is fixed at first hello; a second must not re-own the connection
319
- this.onHello(m);
489
+ return;
490
+ /* onHello is async now, so a throw after its awaits (attendance.attach,
491
+ * sendWelcome, hosts.attach) would be an UNHANDLED rejection -- which
492
+ * takes the whole daemon down, not just this connection. Log it. */
493
+ void this.onHello(m).catch((err) => this.opts.log?.(`hello failed: ${errMessage(err)}`));
320
494
  return;
495
+ case 'identity-assert': {
496
+ // Only the FIRST identity-assert on a connection counts -- the
497
+ // resolver is cleared the instant it is used (right here), so a
498
+ // second (or unsolicited, or late-after-timeout) one finds nothing to
499
+ // call and is silently ignored, which is also what consumes the
500
+ // single-use nonce: nothing after this point can still redeem it.
501
+ const resolve = this.identityAssertResolve;
502
+ this.identityAssertResolve = undefined;
503
+ if (!resolve)
504
+ return;
505
+ if (typeof m.assertion === 'string' && m.assertion)
506
+ resolve({ kind: 'assertion', value: m.assertion });
507
+ else
508
+ resolve({ kind: 'none' });
509
+ return;
510
+ }
321
511
  case 'invoke':
322
512
  if (!this.authed) {
323
513
  this.ch.send({ t: 'invoke-result', id: m.id, ok: false, error: 'not authenticated' });
@@ -358,6 +548,13 @@ export class SidecarServer {
358
548
  return;
359
549
  this.onSubscribe(m);
360
550
  return;
551
+ case 'cancel-run':
552
+ if (!this.authed) {
553
+ this.ch.send({ t: 'cancel-run-result', id: m.id, runId: m.runId, ok: false, reason: 'unsupported' });
554
+ return;
555
+ }
556
+ this.onCancelRun(m);
557
+ return;
361
558
  case 'acp-start':
362
559
  if (!this.authed) {
363
560
  this.ch.send({ t: 'acp-error', id: m.id, error: 'not authenticated' });
@@ -387,7 +584,7 @@ export class SidecarServer {
387
584
  // answer is dropped silently rather than forwarded into the registry.
388
585
  if (!this.authed)
389
586
  return;
390
- this.opts.attendance?.answer(m.id, m.decision);
587
+ this.opts.attendance?.answer(m.id, m.decision, this.attended);
391
588
  return;
392
589
  case 'roots-browse':
393
590
  // Auth-gated like every other request: directory enumeration must never
@@ -532,6 +729,66 @@ export class SidecarServer {
532
729
  }
533
730
  void this.onAgentPauseSet(m);
534
731
  return;
732
+ case 'crew-deployments':
733
+ case 'crew-deploy':
734
+ case 'crew-undeploy':
735
+ case 'crew-pause':
736
+ case 'crew-resume':
737
+ case 'crew-stop':
738
+ if (!this.authed) {
739
+ this.ch.send({ t: 'crew-deploy-result', id: m.id, reply: { ok: false, error: 'not authenticated' } });
740
+ return;
741
+ }
742
+ void this.onCrewDeploy(m);
743
+ return;
744
+ case 'approvals-accept':
745
+ if (!this.authed) {
746
+ this.ch.send({ t: 'approvals-accept-result', id: m.id, ok: false, error: 'not authenticated' });
747
+ return;
748
+ }
749
+ void this.onApprovalsAccept(m);
750
+ return;
751
+ case 'approvals-list':
752
+ if (!this.authed) {
753
+ this.ch.send({ t: 'approvals-list-result', id: m.id, ok: false, error: 'not authenticated' });
754
+ return;
755
+ }
756
+ void this.onApprovalsList(m);
757
+ return;
758
+ case 'approvals-revoke':
759
+ if (!this.authed) {
760
+ this.ch.send({ t: 'approvals-revoke-result', id: m.id, ok: false, error: 'not authenticated' });
761
+ return;
762
+ }
763
+ void this.onApprovalsRevoke(m);
764
+ return;
765
+ case 'checks-get':
766
+ if (!this.authed) {
767
+ this.ch.send({ t: 'checks-get-result', id: m.id, ok: false, error: 'not authenticated' });
768
+ return;
769
+ }
770
+ void this.onChecksGet(m);
771
+ return;
772
+ case 'checks-pin':
773
+ if (!this.authed) {
774
+ this.ch.send({ t: 'checks-pin-result', id: m.id, ok: false, error: 'not authenticated' });
775
+ return;
776
+ }
777
+ void this.onChecksPin(m);
778
+ return;
779
+ case 'workspace-release':
780
+ if (!this.authed) {
781
+ this.ch.send({ t: 'workspace-release-result', id: m.id, ok: false, error: 'not authenticated' });
782
+ return;
783
+ }
784
+ void this.onWorkspaceRelease(m);
785
+ return;
786
+ case 'push-subscribe':
787
+ void this.onPushSubscribe(m);
788
+ return;
789
+ case 'push-unsubscribe':
790
+ void this.onPushUnsubscribe(m);
791
+ return;
535
792
  case 'hosts-list':
536
793
  // `ok: false`, not an empty estate: telling an unauthenticated caller
537
794
  // "this daemon holds no synced hosts" is a claim it has not earned --
@@ -657,25 +914,43 @@ export class SidecarServer {
657
914
  return;
658
915
  }
659
916
  }
660
- onHello(m) {
917
+ /** Close a connection that keeps failing to pair. */
918
+ countFailedHello() {
919
+ this.failedHellos++;
920
+ if (this.failedHellos >= MAX_FAILED_HELLOS) {
921
+ this.opts.log?.(`closing a connection after ${this.failedHellos} denied hellos`);
922
+ this.ch.close();
923
+ }
924
+ }
925
+ async onHello(m) {
661
926
  if (m.protocol !== SIDECAR_PROTOCOL) {
662
927
  this.opts.log?.(`denied: unsupported protocol ${m.protocol}`);
663
928
  this.ch.send({ t: 'denied', reason: `protocol ${m.protocol} unsupported` });
929
+ this.countFailedHello();
664
930
  return;
665
931
  }
666
932
  if (!safeEqual(m.token ?? '', this.opts.token)) {
667
933
  this.opts.log?.('denied: invalid token');
668
934
  this.ch.send({ t: 'denied', reason: 'invalid token' });
935
+ this.countFailedHello();
669
936
  return;
670
937
  }
671
938
  this.authed = true;
672
- this.hostStateDir = m.stateDir ?? '.corenel';
939
+ this.clearHelloDeadline();
940
+ this.hostStateDir = m.stateDir ?? DEFAULT_STATE_DIR;
673
941
  // Runtime-validate identity, even though the declared type is `string` --
674
942
  // the message crossed the wire via `JSON.parse(...) as SidecarMsg`, so
675
943
  // nothing actually guaranteed that. A non-string `clientId` (e.g. a number
676
944
  // or object) must never reach `onClient`: downstream consumers use it as an
677
945
  // object key (a daemon's hosts manifest) or feed it to a directory-name
678
946
  // sanitizer that assumes a string.
947
+ /* Declared OUT here, not inside the identity block that fills it, because
948
+ * the attendance attach below needs the same bounded list -- and must get
949
+ * the ALREADY-BOUNDED one rather than re-reading `m.accounts`, which would
950
+ * reintroduce the unbounded walk the block below exists to prevent. A
951
+ * connection that carried no usable clientId leaves this empty, which is
952
+ * the right claim for a peer that identified itself as nobody. */
953
+ let accounts = [];
679
954
  if (typeof m.clientId === 'string' && m.clientId) {
680
955
  const wireId = m.clientId.slice(0, MAX_IDENTITY_FIELD_LEN);
681
956
  /* THE ONE PLACE IDENTITY IS DECIDED, and it has to be here.
@@ -709,7 +984,7 @@ export class SidecarServer {
709
984
  * `<account><spaces>` behind, which a normalizing comparison then trims
710
985
  * into a match nobody claimed. */
711
986
  const claimed = Array.isArray(m.accounts) ? m.accounts : [];
712
- const accounts = claimed.length > MAX_CLAIMED_ACCOUNTS
987
+ accounts = claimed.length > MAX_CLAIMED_ACCOUNTS
713
988
  ? []
714
989
  : claimed.filter((a) => typeof a === 'string' && a.length <= MAX_IDENTITY_FIELD_LEN);
715
990
  let clientId = wireId;
@@ -728,6 +1003,7 @@ export class SidecarServer {
728
1003
  }
729
1004
  }
730
1005
  this.clientId = clientId;
1006
+ this.accounts = accounts;
731
1007
  // The FRIENDLY label, which falls back to the WIRE id and never to the
732
1008
  // mapped key: the name is what seeds a directory name and what an
733
1009
  // operator reads in `hosts list`, and an `acct:<digest>` there would name
@@ -756,40 +1032,219 @@ export class SidecarServer {
756
1032
  this.opts.log?.(`onClient hook threw: ${errMessage(err)}`);
757
1033
  }
758
1034
  }
1035
+ /* THE WAITS, started together. The welcome carries the synced directory
1036
+ * name this daemon resolved for this connection, and the client has no
1037
+ * other way to learn it -- it could previously only deduce it, and got null
1038
+ * whenever no workspace was open or two hosts held the same workspace id,
1039
+ * leaving a management surface unable to say which registrations were its
1040
+ * own.
1041
+ *
1042
+ * The dirName lookup is STARTED here, before the identity verdict is
1043
+ * awaited, so the two bounded waits (DIR_NAME_TIMEOUT_MS,
1044
+ * IDENTITY_TIMEOUT_MS) overlap: the worst case stays one bound, not two.
1045
+ * Both only ever resolve (every failure is null / unverified), so starting
1046
+ * one and then awaiting the other cannot leave a rejection unhandled.
1047
+ *
1048
+ * Bounded, and every dirName failure ends the same way as an older daemon:
1049
+ * no `dirName` on the welcome. A handshake that never arrives would be a
1050
+ * far worse trade than a client that falls back to deducing. With neither
1051
+ * a resolver nor a verifier nothing is awaited at all, and the welcome
1052
+ * goes out in the same tick as today. */
1053
+ const dirIdentity = this.clientId === undefined
1054
+ ? undefined
1055
+ : { clientId: this.clientId, name: this.clientName ?? this.clientId };
1056
+ const dirNamePending = this.opts.clientDirName && dirIdentity
1057
+ ? this.resolveDirName(this.opts.clientDirName, dirIdentity)
1058
+ : undefined;
1059
+ /* IDENTITY, decided BEFORE attendance or push can see this connection.
1060
+ * Reached only past the pairing-token check above -- the challenge is
1061
+ * never sent for a bad pairing token. Whatever the verdict, the
1062
+ * connection pairs: unverified only withholds the widened trust a verified
1063
+ * id earns. `verifiedUserId` is assigned here and nowhere else, once. The
1064
+ * assertion is never logged; only the reason is. The Clerk token itself
1065
+ * never reaches this class -- this exchange only ever sees a corenel-api
1066
+ * pairing assertion (spec amendment 2026-09-25b). */
1067
+ if (this.opts.identity) {
1068
+ if (!m.wantsIdentity) {
1069
+ this.identityResult = { verified: false, reason: 'not requested' };
1070
+ }
1071
+ else if (!this.opts.info?.id) {
1072
+ this.identityResult = { verified: false, reason: 'no daemon id' };
1073
+ }
1074
+ else {
1075
+ const sidecarId = this.opts.info.id;
1076
+ /* The hash of the pairing token THIS connection presented (it passed
1077
+ * the check above). The assertion's `cnf.tkh` must equal it: that
1078
+ * binds the assertion to the pairing the browser actually made, so a
1079
+ * daemon that holds our token cannot relay our challenge through a
1080
+ * browser paired with IT (that browser hashed its own, different,
1081
+ * token). STARTED here but not awaited: the digest is async, and the
1082
+ * challenge, the resolver and the bound timer must all be in place
1083
+ * synchronously -- it is awaited only once an assertion is in hand
1084
+ * (see verdictFromAssertion), inside the same bound. A digest that
1085
+ * rejects pairs unverified ('error'), never refused. */
1086
+ const tokenHash = pairingTokenHash(m.token ?? '').catch(() => null);
1087
+ const nonce = makeNonce();
1088
+ this.ch.send({ t: 'identity-challenge', nonce, sidecarId });
1089
+ const verdict = await this.awaitAndVerifyIdentity(sidecarId, nonce, tokenHash);
1090
+ if (verdict.ok)
1091
+ this.verifiedUserId = verdict.identity.userId;
1092
+ this.identityResult = verdict.ok ? { verified: true } : { verified: false, reason: verdict.reason };
1093
+ }
1094
+ this.opts.log?.(`identity: ${this.identityResult.verified ? 'verified' : `unverified (${this.identityResult.reason})`}`);
1095
+ }
1096
+ /* OLDER APPS (crew deploy). A client that does not announce it can deploy
1097
+ * has agents that sync here but never fire until deployed, and nothing on
1098
+ * its screen can say so -- the daemon says it, once per client (the port
1099
+ * de-duplicates). The list is only ever informational: it grants nothing.
1100
+ * A malformed one counts as absent. Best-effort: never breaks a handshake. */
1101
+ if (this.opts.crewDeploy && !announcedCapabilities(m.capabilities).includes(CREW_DEPLOY_FEATURE)) {
1102
+ try {
1103
+ this.opts.crewDeploy.noteOlderClient(this.clientId ?? '', this.clientName ?? 'unknown app');
1104
+ }
1105
+ catch (err) {
1106
+ this.opts.log?.(`noteOlderClient threw: ${errMessage(err)}`);
1107
+ }
1108
+ }
1109
+ // Dropped while verifying: attaching now would leave a dead client in the
1110
+ // registry that the (already-run) close handler can never detach.
1111
+ if (this.closed)
1112
+ return;
759
1113
  if (this.opts.attendance) {
760
- this.attendedDetach = this.opts.attendance.attach({
1114
+ const attended = {
761
1115
  send: (ask) => this.ch.send({ t: 'permission-ask', ...ask }),
762
- resolved: (id, decision, by) => this.ch.send({ t: 'permission-resolved', id, decision, by }),
1116
+ resolved: (id, decision, by, byClientId) => this.ch.send({ t: 'permission-resolved', id, decision, by, ...(byClientId ? { byClientId } : {}) }),
763
1117
  runActivity: (activity) => this.ch.send({ t: 'run-activity', activity }),
764
1118
  registrations: (registrations) => this.ch.send({ t: 'registrations', registrations }),
1119
+ approvalSuggestion: (s) => this.ch.send({ t: 'approvals-suggest', ...s }),
1120
+ };
1121
+ // Retained so `permission-answer` can name THIS connection as the
1122
+ // answerer -- the registry identifies an answerer by the object it
1123
+ // attached, not by anything the answer message claims.
1124
+ this.attended = attended;
1125
+ /* The identity is what routes an ask under `--mode team`. `clientId` is
1126
+ * the key this connection was FILED under (mapped through `clientKey`
1127
+ * where the daemon supplies one), i.e. the same value its RunIdentity
1128
+ * carries -- which is what makes a run's owner and its ask's recipient
1129
+ * the same connection. `accounts` are CLAIMED, carried for display, and
1130
+ * widen nothing; only `verifiedUserId` (proven at pairing) widens a
1131
+ * delivery to the same user's other devices. See the registry's
1132
+ * `recipients`. */
1133
+ this.attendedDetach = this.opts.attendance.attach(attended, {
1134
+ clientId: this.clientId ?? '',
1135
+ accounts,
1136
+ ...(this.verifiedUserId ? { verifiedUserId: this.verifiedUserId } : {}),
765
1137
  });
766
1138
  }
767
1139
  this.opts.log?.(`paired — ${this.tools.size} ${this.tools.size === 1 ? 'tool' : 'tools'} advertised (host dir: ${this.hostStateDir})`);
768
- /* THE ONE THING THAT WAITS. The welcome carries the synced directory name
769
- * this daemon resolved for this connection, and the client has no other way
770
- * to learn it -- it could previously only deduce it, and got null whenever
771
- * no workspace was open or two hosts held the same workspace id, leaving a
772
- * management surface unable to say which registrations were its own.
773
- *
774
- * Bounded, and every failure ends the same way as an older daemon: no
775
- * `dirName` on the welcome. A handshake that never arrives would be a far
776
- * worse trade than a client that falls back to deducing. */
777
- const identity = this.clientId === undefined
778
- ? undefined
779
- : { clientId: this.clientId, name: this.clientName ?? this.clientId };
780
- if (this.opts.clientDirName && identity) {
781
- void this.resolveDirName(this.opts.clientDirName, identity).then((dirName) => {
782
- this.sendWelcome(dirName);
783
- });
1140
+ if (!dirNamePending) {
1141
+ this.sendWelcome(null);
1142
+ return;
1143
+ }
1144
+ const dirName = await dirNamePending;
1145
+ // Same reasoning as above: a dropped connection must not attach to the
1146
+ // hosts live set, which only a close handler that already ran would undo.
1147
+ if (this.closed)
784
1148
  return;
1149
+ /* CREW DEPLOY OWNERSHIP: a verified connection filed as a synced folder
1150
+ * binds that folder to its user (first one wins; the daemon never
1151
+ * rebinds). Before the welcome, so nothing this client sends can race it.
1152
+ * Best-effort: never breaks a handshake. */
1153
+ if (dirName && this.verifiedUserId && this.opts.crewDeploy?.noteVerifiedDir) {
1154
+ try {
1155
+ this.opts.crewDeploy.noteVerifiedDir(dirName, this.verifiedUserId);
1156
+ }
1157
+ catch (err) {
1158
+ this.opts.log?.(`noteVerifiedDir threw: ${errMessage(err)}`);
1159
+ }
1160
+ }
1161
+ this.sendWelcome(dirName);
1162
+ }
1163
+ /** Waits for this connection's `identity-assert`, with NO timeout of its
1164
+ * own -- the overall bound is `awaitAndVerifyIdentity`'s, covering this wait
1165
+ * AND the verify call together, so the whole leg is bounded at
1166
+ * IDENTITY_TIMEOUT_MS from the challenge, not up to double that. Also
1167
+ * settled (to a timeout-shaped answer) from the close handler, so a
1168
+ * dropped connection cannot leave this pending forever. */
1169
+ awaitIdentityAssertion() {
1170
+ return new Promise((resolve) => {
1171
+ this.identityAssertResolve = resolve;
1172
+ });
1173
+ }
1174
+ /** Turns an `identity-assert` answer into a verdict: 'no assertion' or
1175
+ * 'timed out' unverified for the non-assertion answers, 'assertion too
1176
+ * long' WITHOUT calling the verifier for an oversized one, and otherwise
1177
+ * the verifier's own verdict (never thrown -- a throw or rejection becomes
1178
+ * an unverified 'error'). The expectation is built here, once the
1179
+ * pairing-token hash has settled; a hash that could not be computed is an
1180
+ * unverified 'error' without calling the verifier. */
1181
+ async verdictFromAssertion(answer, audience, nonce, tokenHashPending) {
1182
+ if (answer.kind === 'none')
1183
+ return { ok: false, reason: 'no assertion' };
1184
+ if (answer.kind === 'timeout')
1185
+ return { ok: false, reason: 'timed out' };
1186
+ const assertion = answer.value;
1187
+ if (assertion.length > MAX_ASSERTION_LEN)
1188
+ return { ok: false, reason: 'assertion too long' };
1189
+ const v = this.opts.identity;
1190
+ if (!v)
1191
+ return { ok: false, reason: 'no verifier' };
1192
+ const tokenHash = await tokenHashPending;
1193
+ if (tokenHash === null)
1194
+ return { ok: false, reason: 'error' };
1195
+ const expect = { audience, nonce, tokenHash };
1196
+ try {
1197
+ const verdict = v.explain
1198
+ ? await v.explain(assertion, expect)
1199
+ : await v.verify(assertion, expect).then((id) => (id ? { ok: true, identity: id } : { ok: false, reason: 'rejected' }));
1200
+ return normalizeVerdict(verdict);
1201
+ }
1202
+ catch (err) {
1203
+ this.opts.log?.(`identity verification failed: error (${sanitizeReason(errMessage(err))})`);
1204
+ return { ok: false, reason: 'error' };
1205
+ }
1206
+ }
1207
+ /** The identity verdict for this connection's challenge/assert exchange,
1208
+ * bounded by IDENTITY_TIMEOUT_MS FROM THE CHALLENGE -- covering both the
1209
+ * wait for `identity-assert` and the verify call, in one race, so a slow
1210
+ * verifier cannot add a second timeout's worth of wait on top of a slow
1211
+ * browser. Every failure mode (no assertion, an oversized one, a throw, a
1212
+ * rejection, a malformed answer, a timeout) is an UNVERIFIED verdict with a
1213
+ * reason -- never a refusal. The assertion is passed to the verifier and
1214
+ * nowhere else. */
1215
+ async awaitAndVerifyIdentity(audience, nonce, tokenHash) {
1216
+ let timer;
1217
+ try {
1218
+ const timed = new Promise((res) => {
1219
+ timer = setTimeout(() => res({ ok: false, reason: 'timed out' }), IDENTITY_TIMEOUT_MS);
1220
+ });
1221
+ const answered = this.awaitIdentityAssertion().then((answer) => this.verdictFromAssertion(answer, audience, nonce, tokenHash));
1222
+ const got = await Promise.race([answered, timed]);
1223
+ return normalizeVerdict(got);
1224
+ }
1225
+ finally {
1226
+ if (timer !== undefined)
1227
+ clearTimeout(timer);
1228
+ /* Whichever side of the race won, nothing may still be waiting on
1229
+ * `identity-assert` once this settles. If `answered` won, the message
1230
+ * handler already cleared this itself. If `timed` won (the timeout),
1231
+ * NOTHING has cleared it yet -- `awaitIdentityAssertion`'s promise is
1232
+ * still pending, just no longer awaited by anyone. Leaving the
1233
+ * resolver live would let a LATE `identity-assert` (arriving after the
1234
+ * bound this method already returned past) still call it, running
1235
+ * `verdictFromAssertion` -- and therefore the verifier, and possibly a
1236
+ * JWKS fetch -- for a result nothing reads, while the message handler's
1237
+ * own comment claims a late message is ignored. Clearing here is what
1238
+ * makes that claim true. */
1239
+ this.identityAssertResolve = undefined;
785
1240
  }
786
- this.sendWelcome(null);
787
1241
  }
788
1242
  /** Resolve the connection's synced directory name, or null for every failure
789
1243
  * mode there is (throw, rejection, non-string, timeout). */
790
1244
  async resolveDirName(resolve, identity) {
1245
+ let timer;
791
1246
  try {
792
- const timed = new Promise((res) => setTimeout(() => res(null), DIR_NAME_TIMEOUT_MS));
1247
+ const timed = new Promise((res) => { timer = setTimeout(() => res(null), DIR_NAME_TIMEOUT_MS); });
793
1248
  const got = await Promise.race([Promise.resolve(resolve(identity)), timed]);
794
1249
  return typeof got === 'string' && got ? got : null;
795
1250
  }
@@ -797,6 +1252,10 @@ export class SidecarServer {
797
1252
  this.opts.log?.(`clientDirName failed: ${errMessage(err)}`);
798
1253
  return null;
799
1254
  }
1255
+ finally {
1256
+ if (timer !== undefined)
1257
+ clearTimeout(timer);
1258
+ }
800
1259
  }
801
1260
  /** Whether the identity rule can produce an account key right now. A
802
1261
  * predicate that throws answers NO: an advertisement is a promise to the
@@ -843,6 +1302,11 @@ export class SidecarServer {
843
1302
  // default, whose scheduled runs would otherwise fail one by one with
844
1303
  // nothing on screen to say why.
845
1304
  ...(this.opts.runner?.hasDefaultModel ? ['default-model'] : []),
1305
+ // A client offers Stop on a watched daemon run only where it would work.
1306
+ ...(this.opts.runner?.cancel ? ['run-cancel'] : []),
1307
+ // A client starts a task only where the daemon will honour it: an older
1308
+ // runner ignores `task`, and a dropped task must never look like a start.
1309
+ ...(this.opts.runner?.supportsTasks ? [RUN_TASK_FEATURE] : []),
846
1310
  ...(this.opts.acp ? ['acp'] : []),
847
1311
  ...(this.opts.roots?.areas.length ? ['add-roots'] : []),
848
1312
  ...(this.opts.roots?.areas.length && this.opts.roots.create ? ['create-roots'] : []),
@@ -863,7 +1327,22 @@ export class SidecarServer {
863
1327
  // behind it, so a client offers "Pause" only where pausing would actually
864
1328
  // stop the agent -- the alternative is a button that reports success on a
865
1329
  // daemon that keeps firing.
866
- ...(this.opts.pauses ? ['agent-pause'] : []),
1330
+ // A deploy list holds pauses too (it answers the agent-pause-* messages
1331
+ // through the deploy core), so it earns the flag on its own.
1332
+ ...(this.opts.pauses || this.opts.crewDeploy ? ['agent-pause'] : []),
1333
+ // Whether this daemon keeps a deploy list (crew deploy per machine).
1334
+ // Only with a real port behind it, so a client offers Deploy and
1335
+ // Undeploy only where the daemon would record them -- anywhere else a
1336
+ // client shows the old read-only view.
1337
+ ...(this.opts.crewDeploy ? [CREW_DEPLOY_FEATURE] : []),
1338
+ // Whether this daemon keeps standing approvals (plan 3). Only with a real
1339
+ // service behind it, so a client shows the "This machine" group only
1340
+ // where there is something to list and revoke.
1341
+ ...(this.opts.approvals ? ['approvals'] : []),
1342
+ // Whether this daemon runs confirmed checks and holds the orchestrator's
1343
+ // workspace lock. Only with a real service behind it (--allow-shell) that
1344
+ // is not switched off right now (see SidecarChecks.unavailable).
1345
+ ...(this.checksRefusal() === null ? ['checks'] : []),
867
1346
  // Whether this daemon can report and reclaim its synced host directories.
868
1347
  // Advertised only with a real manifest behind it, so a client never
869
1348
  // offers a destructive control to a daemon that would refuse it.
@@ -886,11 +1365,20 @@ export class SidecarServer {
886
1365
  // why it must be true at the moment it is said, not merely true of how
887
1366
  // the daemon was built. See `clientKeyActive`.
888
1367
  ...(this.opts.clientKey && this.clientKeyIsActive() ? ['account-identity'] : []),
1368
+ // Whether this daemon can send web push. Advertised only with a real
1369
+ // notifier behind it, so a client offers "notify me" only where a
1370
+ // subscription would actually be reached.
1371
+ ...(this.opts.push ? ['web-push'] : []),
1372
+ // Whether this daemon verifies the browser's identity token. Advertised
1373
+ // only with a real verifier behind it; the verdict itself is `identity`.
1374
+ ...(this.opts.identity ? ['verified-identity'] : []),
889
1375
  ];
890
1376
  this.ch.send({
891
1377
  t: 'welcome', tools, features,
892
1378
  ...(this.opts.info ? { info: this.opts.info } : {}),
893
1379
  ...(dirName ? { dirName } : {}),
1380
+ ...(this.opts.push ? { vapidPublicKey: this.opts.push.publicKey } : {}),
1381
+ ...(this.opts.identity ? { identity: this.identityResult } : {}),
894
1382
  });
895
1383
  }
896
1384
  /** Proxy a host HTTP request server-side. Restricted to LOOPBACK targets — the
@@ -909,7 +1397,20 @@ export class SidecarServer {
909
1397
  throw new Error('http(s) URLs only');
910
1398
  if (!isLoopbackHost(u.hostname))
911
1399
  throw new Error(`the sidecar proxy only reaches loopback hosts, not "${u.hostname}"`);
912
- const res = await fetch(m.url, { method: m.method, headers: m.headers, body: m.body });
1400
+ // A NAME (only `localhost` passes the check above) is resolved and every
1401
+ // address re-checked, so a hosts-file or resolver entry cannot point it
1402
+ // off the machine. IP literals need no lookup.
1403
+ const bare = u.hostname.replace(/^\[/, '').replace(/\]$/, '');
1404
+ if (!isLoopbackAddress(bare) && this.opts.resolveHost) {
1405
+ const addrs = await this.opts.resolveHost(bare);
1406
+ if (addrs.length === 0 || addrs.some((a) => !isLoopbackAddress(a))) {
1407
+ throw new Error(`"${u.hostname}" resolves to a non-loopback address; the sidecar proxy only reaches loopback hosts`);
1408
+ }
1409
+ }
1410
+ // redirect:'manual' -- a loopback service with an open redirect must not
1411
+ // bounce this request to a metadata or LAN host. The 3xx (with its
1412
+ // Location) goes back to the caller, which can decide what to do.
1413
+ const res = await fetch(m.url, { method: m.method, headers: m.headers, body: m.body, redirect: 'manual' });
913
1414
  const headers = {};
914
1415
  res.headers.forEach((v, k) => { headers[k] = v; });
915
1416
  this.ch.send({ t: 'http-res', id: m.id, status: res.status, statusText: res.statusText, headers });
@@ -952,7 +1453,11 @@ export class SidecarServer {
952
1453
  * which URLs are refused all live on the daemon side, where the
953
1454
  * operator's declarations are. */
954
1455
  ...(m.repo ? { repo: m.repo } : {}),
955
- ...(m.owner ? { owner: m.owner } : {}),
1456
+ // From the AUTHENTICATED connection, never from this message: a
1457
+ // client naming an owner could label a session as someone
1458
+ // else's. `m.owner` is ignored; a connection that sent no
1459
+ // clientId files the session under nobody.
1460
+ ...(this.clientId ? { owner: this.clientId } : {}),
956
1461
  ...(m.fromSnapshot ? { fromSnapshot: m.fromSnapshot } : {}),
957
1462
  // From the HANDSHAKE, never from this message: the dot-dir is a
958
1463
  // property of the client's workspace, and taking it from the
@@ -1181,7 +1686,12 @@ export class SidecarServer {
1181
1686
  this.ch.send({ t: 'invoke-result', id: m.id, ok: true, result });
1182
1687
  }
1183
1688
  catch (e) {
1184
- this.opts.log?.(`invoke ${m.name} failed: ${errMessage(e)}`);
1689
+ // A read that finds nothing there (ENOENT) answered the question -- the
1690
+ // crew page probes every project for a crew folder most do not have.
1691
+ // Every other failure, and any from a tool that changes things, is logged.
1692
+ const notThere = tool.mutates !== true && e?.code === 'ENOENT';
1693
+ if (!notThere)
1694
+ this.opts.log?.(`invoke ${m.name} failed: ${errMessage(e)}`);
1185
1695
  this.ch.send({ t: 'invoke-result', id: m.id, ok: false, error: errMessage(e) });
1186
1696
  }
1187
1697
  }
@@ -1212,6 +1722,10 @@ export class SidecarServer {
1212
1722
  * user has -- the client does -- and the ACP client drops them if the
1213
1723
  * agent never advertised mcpCapabilities.http. */
1214
1724
  ...(m.mcpServers?.length ? { mcpServers: m.mcpServers } : {}),
1725
+ /* The connection's own filed key -- the same value it attached to the
1726
+ * registry under -- so the proxied agent's asks reach whoever started
1727
+ * it under `--mode team`, and not their teammates. Never from `m`. */
1728
+ ...(this.clientId ? { owner: this.clientId } : {}),
1215
1729
  });
1216
1730
  this.ch.send({ t: 'acp-started', id: m.id, ...started });
1217
1731
  }
@@ -1302,6 +1816,19 @@ export class SidecarServer {
1302
1816
  detachOnce();
1303
1817
  }
1304
1818
  }
1819
+ /** Stop a run, whoever started it. Same trust as `run`: a connection that may
1820
+ * start runs on this daemon may stop them. */
1821
+ onCancelRun(m) {
1822
+ const cancel = this.opts.runner?.cancel;
1823
+ if (!cancel) {
1824
+ this.ch.send({ t: 'cancel-run-result', id: m.id, runId: m.runId, ok: false, reason: 'unsupported' });
1825
+ return;
1826
+ }
1827
+ const out = cancel.call(this.opts.runner, m.runId);
1828
+ this.ch.send(out === 'cancelled'
1829
+ ? { t: 'cancel-run-result', id: m.id, runId: m.runId, ok: true }
1830
+ : { t: 'cancel-run-result', id: m.id, runId: m.runId, ok: false, reason: 'no-such-run' });
1831
+ }
1305
1832
  /**
1306
1833
  * What this caller's agent has actually done.
1307
1834
  *
@@ -1364,6 +1891,14 @@ export class SidecarServer {
1364
1891
  * says so rather than running the work in its own process anyway — silently
1365
1892
  * downgrading is the failure IsolationLevel exists to prevent, and it would
1366
1893
  * be invisible to the client that asked. */
1894
+ // A task is its own run with its own record and autonomy ceiling; the
1895
+ // session layer knows neither, so it is refused rather than dropped. So is
1896
+ // an autonomy on its own: the session path would ignore it, and a ceiling
1897
+ // the client asked for must never be silently dropped.
1898
+ if ((m.task || m.autonomy) && m.sessionId !== undefined) {
1899
+ this.ch.send({ t: 'run-error', id: m.id, error: 'A task starts its own run; it cannot run inside an existing session.' });
1900
+ return;
1901
+ }
1367
1902
  if (m.sessionId !== undefined) {
1368
1903
  const sessions = this.opts.sessions;
1369
1904
  if (!sessions?.run) {
@@ -1386,7 +1921,10 @@ export class SidecarServer {
1386
1921
  // would grant itself mutating tools inside a sandbox. Deliberately
1387
1922
  // absent -- see session/guestRunner.ts.
1388
1923
  this.opts.sessions.run(m.sessionId, agent, input, onEvent, identity, { keepTranscript: m.keepTranscript === true })
1389
- : (agent, input, onEvent, identity) => this.opts.runner.run(agent, input, onEvent, identity);
1924
+ : (agent, input, onEvent, identity) =>
1925
+ // Task and autonomy are forwarded as asked; the runner, not this
1926
+ // class, decides whether the agent may run that way.
1927
+ this.opts.runner.run(agent, input, onEvent, identity, undefined, undefined, { task: m.task, autonomy: m.autonomy });
1390
1928
  let runId;
1391
1929
  const pending = [];
1392
1930
  const onEvent = (event) => {
@@ -1542,6 +2080,17 @@ export class SidecarServer {
1542
2080
  }
1543
2081
  }
1544
2082
  onAgentPauseList(m) {
2083
+ const deploy = this.opts.crewDeploy;
2084
+ if (deploy) {
2085
+ // A pause is a deploy entry now: the list comes from the deploy list.
2086
+ try {
2087
+ this.ch.send({ t: 'agent-pause-list-result', id: m.id, ok: true, pauses: deploy.legacyPauses() });
2088
+ }
2089
+ catch (err) {
2090
+ this.ch.send({ t: 'agent-pause-list-result', id: m.id, ok: false, error: errMessage(err) });
2091
+ }
2092
+ return;
2093
+ }
1545
2094
  if (!this.opts.pauses) {
1546
2095
  // An honest empty answer for a daemon without the capability: it is
1547
2096
  // holding no pauses, which is true. The feature gate is what tells a
@@ -1557,7 +2106,7 @@ export class SidecarServer {
1557
2106
  }
1558
2107
  }
1559
2108
  async onAgentPauseSet(m) {
1560
- if (!this.opts.pauses) {
2109
+ if (!this.opts.pauses && !this.opts.crewDeploy) {
1561
2110
  // NOT an empty success. Accepting a pause this daemon cannot hold would
1562
2111
  // leave the operator believing an agent had been stopped while it kept
1563
2112
  // firing on the hour -- the exact failure the feature exists to end.
@@ -1571,6 +2120,23 @@ export class SidecarServer {
1571
2120
  this.ch.send({ t: 'agent-pause-set-result', id: m.id, ok: false, error: 'owner must be a string and agent a non-empty string' });
1572
2121
  return;
1573
2122
  }
2123
+ const deploy = this.opts.crewDeploy;
2124
+ if (deploy) {
2125
+ // An older app's pause goes through the deploy core, recorded as THIS
2126
+ // connection (the core adds who and why; an older app gives no reason).
2127
+ try {
2128
+ const out = await deploy.pauseByOwnerKey(this.deployCaller(), m.owner, m.agent, m.paused === true);
2129
+ this.ch.send(out.ok
2130
+ ? { t: 'agent-pause-set-result', id: m.id, ok: true, pauses: out.pauses }
2131
+ : { t: 'agent-pause-set-result', id: m.id, ok: false, error: out.error });
2132
+ }
2133
+ catch (err) {
2134
+ this.ch.send({ t: 'agent-pause-set-result', id: m.id, ok: false, error: errMessage(err) });
2135
+ }
2136
+ return;
2137
+ }
2138
+ if (!this.opts.pauses)
2139
+ return;
1574
2140
  try {
1575
2141
  const pauses = await this.opts.pauses.set(m.owner, m.agent, m.paused === true);
1576
2142
  // Republish before replying: the caller's own row and every other
@@ -1583,13 +2149,304 @@ export class SidecarServer {
1583
2149
  this.ch.send({ t: 'agent-pause-set-result', id: m.id, ok: false, error: errMessage(err) });
1584
2150
  }
1585
2151
  }
2152
+ /** The caller for a deploy action: THIS connection, never a message field.
2153
+ * `dirName` is the directory the daemon resolved for this connection and
2154
+ * `verifiedUserId` the id it proved at pairing; both are absent until (and
2155
+ * unless) the daemon established them, which only ever narrows what the
2156
+ * core permits. */
2157
+ deployCaller() {
2158
+ return {
2159
+ kind: 'client',
2160
+ name: this.clientName ?? this.clientId ?? 'unknown app',
2161
+ ...(this.dirName !== undefined ? { dirName: this.dirName } : {}),
2162
+ ...(this.verifiedUserId !== undefined ? { verifiedUserId: this.verifiedUserId } : {}),
2163
+ };
2164
+ }
2165
+ async onCrewDeploy(m) {
2166
+ const port = this.opts.crewDeploy;
2167
+ const answer = (reply) => this.ch.send({ t: 'crew-deploy-result', id: m.id, reply });
2168
+ if (!port) {
2169
+ answer({ ok: false, error: 'this daemon does not keep a deploy list' });
2170
+ return;
2171
+ }
2172
+ // Runtime-typed: the message arrived via JSON.parse(...) as SidecarMsg.
2173
+ // The ref goes on as `unknown` (the core validates it); a reason or run id
2174
+ // that is not a string is dropped, never coerced.
2175
+ const str = (v) => (typeof v === 'string' ? v : undefined);
2176
+ try {
2177
+ switch (m.t) {
2178
+ case 'crew-deployments':
2179
+ answer(await port.deployments());
2180
+ return;
2181
+ case 'crew-deploy':
2182
+ answer(await port.deploy(this.deployCaller(), m.ref, str(m.reason)));
2183
+ return;
2184
+ case 'crew-undeploy':
2185
+ answer(await port.undeploy(this.deployCaller(), m.ref, str(m.reason)));
2186
+ return;
2187
+ case 'crew-pause':
2188
+ answer(await port.pause(this.deployCaller(), m.ref, str(m.reason) ?? ''));
2189
+ return;
2190
+ case 'crew-resume':
2191
+ answer(await port.resume(this.deployCaller(), m.ref, str(m.reason)));
2192
+ return;
2193
+ case 'crew-stop':
2194
+ answer(await port.stop(this.deployCaller(), m.ref, str(m.runId) ?? '', str(m.reason) ?? ''));
2195
+ return;
2196
+ }
2197
+ }
2198
+ catch (err) {
2199
+ answer({ ok: false, error: errMessage(err) });
2200
+ }
2201
+ }
2202
+ async onApprovalsAccept(m) {
2203
+ const port = this.opts.approvals;
2204
+ if (!port) {
2205
+ this.ch.send({ t: 'approvals-accept-result', id: m.id, ok: false, error: 'this daemon does not keep standing approvals' });
2206
+ return;
2207
+ }
2208
+ // Runtime-validated: the message arrived via JSON.parse(...) as SidecarMsg.
2209
+ if (typeof m.suggestionId !== 'string' || !m.suggestionId || (m.choice !== 'global' && m.choice !== 'not-now' && m.choice !== 'never')) {
2210
+ 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' });
2211
+ return;
2212
+ }
2213
+ // A connection that never attached to attendance was never offered
2214
+ // anything, and has no identity to accept with.
2215
+ if (this.attended === undefined) {
2216
+ this.ch.send({ t: 'approvals-accept-result', id: m.id, ok: false, error: 'this connection was not offered that suggestion' });
2217
+ return;
2218
+ }
2219
+ try {
2220
+ // `this.attended` -- the object THIS connection attached -- is the only
2221
+ // accepter identity; the daemon compares it with the client it offered
2222
+ // to. Nothing in the message can name another connection.
2223
+ await port.accept(this.attended, m.suggestionId, m.choice);
2224
+ this.ch.send({ t: 'approvals-accept-result', id: m.id, ok: true });
2225
+ }
2226
+ catch (err) {
2227
+ this.ch.send({ t: 'approvals-accept-result', id: m.id, ok: false, error: errMessage(err) });
2228
+ }
2229
+ }
2230
+ async onApprovalsList(m) {
2231
+ const port = this.opts.approvals;
2232
+ if (!port) {
2233
+ this.ch.send({ t: 'approvals-list-result', id: m.id, ok: false, error: 'this daemon does not keep standing approvals' });
2234
+ return;
2235
+ }
2236
+ try {
2237
+ this.ch.send({ t: 'approvals-list-result', id: m.id, ok: true, state: await port.state() });
2238
+ }
2239
+ catch (err) {
2240
+ this.ch.send({ t: 'approvals-list-result', id: m.id, ok: false, error: errMessage(err) });
2241
+ }
2242
+ }
2243
+ async onApprovalsRevoke(m) {
2244
+ const port = this.opts.approvals;
2245
+ if (!port) {
2246
+ this.ch.send({ t: 'approvals-revoke-result', id: m.id, ok: false, error: 'this daemon does not keep standing approvals' });
2247
+ return;
2248
+ }
2249
+ if (typeof m.tool !== 'string' || !m.tool) {
2250
+ this.ch.send({ t: 'approvals-revoke-result', id: m.id, ok: false, error: 'tool must be a non-empty string' });
2251
+ return;
2252
+ }
2253
+ try {
2254
+ this.ch.send({ t: 'approvals-revoke-result', id: m.id, ok: true, state: await port.revoke(m.tool) });
2255
+ }
2256
+ catch (err) {
2257
+ this.ch.send({ t: 'approvals-revoke-result', id: m.id, ok: false, error: errMessage(err) });
2258
+ }
2259
+ }
2260
+ /** Why the checks port cannot answer right now, or null when it can. */
2261
+ checksRefusal() {
2262
+ if (!this.opts.checks)
2263
+ return NO_CHECKS;
2264
+ try {
2265
+ return this.opts.checks.unavailable?.() ?? null;
2266
+ }
2267
+ catch (err) {
2268
+ // A guard that cannot answer is a guard that says no.
2269
+ return errMessage(err);
2270
+ }
2271
+ }
2272
+ async onChecksGet(m) {
2273
+ const refusal = this.checksRefusal();
2274
+ if (refusal !== null || !this.opts.checks) {
2275
+ this.ch.send({ t: 'checks-get-result', id: m.id, ok: false, error: refusal ?? NO_CHECKS });
2276
+ return;
2277
+ }
2278
+ // Runtime-validate: the message arrived via `JSON.parse(...) as SidecarMsg`.
2279
+ if (typeof m.root !== 'string' || typeof m.stateDir !== 'string') {
2280
+ this.ch.send({ t: 'checks-get-result', id: m.id, ok: false, error: 'root and stateDir must be strings' });
2281
+ return;
2282
+ }
2283
+ try {
2284
+ this.ch.send({ t: 'checks-get-result', id: m.id, ok: true, state: await this.opts.checks.get(m.root, m.stateDir) });
2285
+ }
2286
+ catch (err) {
2287
+ this.ch.send({ t: 'checks-get-result', id: m.id, ok: false, error: errMessage(err) });
2288
+ }
2289
+ }
2290
+ async onChecksPin(m) {
2291
+ const refusal = this.checksRefusal();
2292
+ if (refusal !== null || !this.opts.checks) {
2293
+ this.ch.send({ t: 'checks-pin-result', id: m.id, ok: false, error: refusal ?? NO_CHECKS });
2294
+ return;
2295
+ }
2296
+ if (typeof m.root !== 'string' || typeof m.stateDir !== 'string' || typeof m.content !== 'string') {
2297
+ this.ch.send({ t: 'checks-pin-result', id: m.id, ok: false, error: 'root, stateDir and content must be strings' });
2298
+ return;
2299
+ }
2300
+ try {
2301
+ this.ch.send({ t: 'checks-pin-result', id: m.id, ok: true, state: await this.opts.checks.pin(m.root, m.stateDir, m.content) });
2302
+ }
2303
+ catch (err) {
2304
+ this.ch.send({ t: 'checks-pin-result', id: m.id, ok: false, error: errMessage(err) });
2305
+ }
2306
+ }
2307
+ async onWorkspaceRelease(m) {
2308
+ // Deliberately NOT gated on checksRefusal(): releasing only removes a run's
2309
+ // own lock, and a run that started before the port switched off must still
2310
+ // hand its lock back.
2311
+ if (!this.opts.checks) {
2312
+ this.ch.send({ t: 'workspace-release-result', id: m.id, ok: false, error: NO_CHECKS });
2313
+ return;
2314
+ }
2315
+ if (typeof m.root !== 'string' || typeof m.stateDir !== 'string' || typeof m.runId !== 'string') {
2316
+ this.ch.send({ t: 'workspace-release-result', id: m.id, ok: false, error: 'root, stateDir and runId must be strings' });
2317
+ return;
2318
+ }
2319
+ try {
2320
+ const released = await this.opts.checks.release({ root: m.root, stateDir: m.stateDir, runId: m.runId });
2321
+ this.ch.send({ t: 'workspace-release-result', id: m.id, ok: true, released });
2322
+ }
2323
+ catch (err) {
2324
+ this.ch.send({ t: 'workspace-release-result', id: m.id, ok: false, error: errMessage(err) });
2325
+ }
2326
+ }
2327
+ /** The identity a push subscription/unsubscription is filed under -- ALWAYS
2328
+ * this connection's own authenticated `clientId`/`accounts`, never anything
2329
+ * the message itself carries. Auth-gated first, same as every other
2330
+ * handler; a connection with no `clientId` (an older client, or one that
2331
+ * never claimed an identity) is refused rather than filed under nothing. */
2332
+ pushIdentity() {
2333
+ if (!this.authed)
2334
+ return { error: 'not authenticated' };
2335
+ if (!this.clientId)
2336
+ return { error: 'this connection carries no client identity, so there is nobody to notify' };
2337
+ return {
2338
+ clientId: this.clientId,
2339
+ accounts: this.accounts,
2340
+ ...(this.verifiedUserId ? { verifiedUserId: this.verifiedUserId } : {}),
2341
+ };
2342
+ }
2343
+ async onPushSubscribe(m) {
2344
+ const who = this.pushIdentity();
2345
+ if ('error' in who) {
2346
+ this.ch.send({ t: 'push-result', id: m.id, ok: false, error: who.error });
2347
+ return;
2348
+ }
2349
+ if (!this.opts.push) {
2350
+ this.ch.send({ t: 'push-result', id: m.id, ok: false, error: 'this daemon does not send web push' });
2351
+ return;
2352
+ }
2353
+ if (!isWebPushSubscription(m.subscription)) {
2354
+ this.ch.send({ t: 'push-result', id: m.id, ok: false, error: 'malformed push subscription' });
2355
+ return;
2356
+ }
2357
+ try {
2358
+ await this.opts.push.subscribe(who, m.subscription);
2359
+ this.ch.send({ t: 'push-result', id: m.id, ok: true });
2360
+ }
2361
+ catch (err) {
2362
+ this.ch.send({ t: 'push-result', id: m.id, ok: false, error: errMessage(err) });
2363
+ }
2364
+ }
2365
+ async onPushUnsubscribe(m) {
2366
+ const who = this.pushIdentity();
2367
+ if ('error' in who) {
2368
+ this.ch.send({ t: 'push-result', id: m.id, ok: false, error: who.error });
2369
+ return;
2370
+ }
2371
+ if (!this.opts.push) {
2372
+ this.ch.send({ t: 'push-result', id: m.id, ok: false, error: 'this daemon does not send web push' });
2373
+ return;
2374
+ }
2375
+ if (typeof m.endpoint !== 'string') {
2376
+ this.ch.send({ t: 'push-result', id: m.id, ok: false, error: 'malformed endpoint' });
2377
+ return;
2378
+ }
2379
+ try {
2380
+ await this.opts.push.unsubscribe(who, m.endpoint);
2381
+ this.ch.send({ t: 'push-result', id: m.id, ok: true });
2382
+ }
2383
+ catch (err) {
2384
+ this.ch.send({ t: 'push-result', id: m.id, ok: false, error: errMessage(err) });
2385
+ }
2386
+ }
1586
2387
  async onRoomOpen(m) {
1587
2388
  if (!this.opts.rooms) {
1588
2389
  this.ch.send({ t: 'room-open-result', id: m.id, ok: false, problems: ['this sidecar does not host rooms'] });
1589
2390
  return;
1590
2391
  }
2392
+ /* Ownership must be OBSERVED, not claimed. `m.req.participants` crossed
2393
+ * the wire from a caller this connection has not otherwise proven
2394
+ * anything about beyond its `hello` token -- every id in it, including
2395
+ * the id the sender picked to represent THEMSELVES, is whatever they
2396
+ * typed. Without this, `room-list`'s membership filter and `mayAct`'s
2397
+ * role gate both key off a string the caller chose for itself, which
2398
+ * makes the whole boundary decorative: connect, claim to be the id
2399
+ * already recorded as owner, done.
2400
+ *
2401
+ * So a room cannot be opened at all without a connection that proved an
2402
+ * identity at `hello` (`this.clientId`) -- an unauthenticated-by-identity
2403
+ * opener could otherwise claim to be anyone, including someone already
2404
+ * seated in an existing room. And whichever participant in the request is
2405
+ * marked `role: 'owner'` has its id OVERWRITTEN with that proven
2406
+ * identity, never trusted as sent.
2407
+ *
2408
+ * The overwrite below is `.find()` -- it stamps the FIRST human carrying
2409
+ * `role: 'owner'`. Nothing here, or upstream of this call, guarantees a
2410
+ * request names only one: `.find()` picks the first regardless of how
2411
+ * many there are. A request naming more than one owner is separately
2412
+ * refused, wholesale, by `validateRoomRequest`'s own one-owner check
2413
+ * (packages/crew/room/open.ts) -- but that runs INSIDE `rooms.open`,
2414
+ * after this overwrite has already happened, and this code neither
2415
+ * depends on nor is ordered by it. If that downstream rule were ever
2416
+ * relaxed, this line would silently start choosing arbitrarily among
2417
+ * several claimed owners rather than refusing anything itself.
2418
+ *
2419
+ * Every OTHER human in the request keeps the id it was sent with: those
2420
+ * are clientIds the owner picked from their own attached connections, and
2421
+ * this connection has no way to verify them. A wrong one there grants
2422
+ * nobody rather than displacing the person it should have named -- widen
2423
+ * never redirect.
2424
+ *
2425
+ * ALL OF THAT IS GATED ON `--mode team`. With team mode off this handler
2426
+ * must be exactly what it was before rooms grew roles: a CLI, an ACP
2427
+ * client or an older host that sends no `clientId` at `hello` opens rooms
2428
+ * as it always did, and the id it sent for itself stands. Refusing them
2429
+ * unconditionally would make the opt-in flag not an opt-in -- see the
2430
+ * team-mode-off test in host.rooms.test.ts, which is the merge-safety
2431
+ * claim for this whole branch. */
2432
+ if (this.opts.teamMode && this.roomIdentity() === undefined) {
2433
+ this.ch.send({
2434
+ t: 'room-open-result',
2435
+ id: m.id,
2436
+ ok: false,
2437
+ problems: ['This connection has no verified identity (no clientId from hello), so it cannot open a room.'],
2438
+ });
2439
+ return;
2440
+ }
2441
+ const clientId = this.roomIdentity();
2442
+ const owner = clientId === undefined
2443
+ ? undefined
2444
+ : m.req.participants.find((p) => p.kind === 'human' && p.role === 'owner');
2445
+ const req = owner && clientId !== undefined
2446
+ ? { ...m.req, participants: m.req.participants.map((p) => (p === owner ? { ...p, id: clientId } : p)) }
2447
+ : m.req;
1591
2448
  try {
1592
- const r = await this.opts.rooms.open(m.req);
2449
+ const r = await this.opts.rooms.open(req);
1593
2450
  this.ch.send(r.ok
1594
2451
  ? { t: 'room-open-result', id: m.id, ok: true, room: r.room }
1595
2452
  : { t: 'room-open-result', id: m.id, ok: false, problems: r.problems });
@@ -1609,7 +2466,27 @@ export class SidecarServer {
1609
2466
  return;
1610
2467
  }
1611
2468
  try {
1612
- this.ch.send({ t: 'room-list-result', id: m.id, ok: true, rooms: await this.opts.rooms.list() });
2469
+ const rooms = await this.opts.rooms.list();
2470
+ /* Private by default, but ONLY once an operator opted into `--mode
2471
+ * team` -- off, this is the same list every daemon has always
2472
+ * returned, unnarrowed, for every existing room test and every daemon
2473
+ * nobody has reconfigured.
2474
+ *
2475
+ * Team mode on: membership is judged against `roomIdentity()`, the
2476
+ * connection's OWN proven identity (its verified user, else its clientId), never a participant id read out of
2477
+ * the request -- that is what `onRoomOpen` just finished making
2478
+ * trustworthy. A caller with NO clientId is not "an older client that
2479
+ * predates identity" here the way it is at `hello` -- membership
2480
+ * cannot be proven without an identity, and under private-by-default
2481
+ * the honest answer to "what can you not prove you're in" is nothing,
2482
+ * not everything. Answering with every room would make the boundary
2483
+ * opt-out: connect without a clientId and see it all anyway. */
2484
+ const mine = !this.opts.teamMode
2485
+ ? rooms
2486
+ : this.roomIdentity() === undefined
2487
+ ? []
2488
+ : rooms.filter((r) => r.participants.some((p) => p.id === this.roomIdentity()));
2489
+ this.ch.send({ t: 'room-list-result', id: m.id, ok: true, rooms: mine });
1613
2490
  }
1614
2491
  catch (err) {
1615
2492
  // A room failure must never kill the connection, and it must not read
@@ -1625,6 +2502,33 @@ export class SidecarServer {
1625
2502
  }
1626
2503
  try {
1627
2504
  const found = await this.opts.rooms.read(m.roomId);
2505
+ /* Private by default extends past `room-list` (R7) to the read itself:
2506
+ * without this, an authenticated connection that merely knows or
2507
+ * guesses a roomId got the full transcript regardless of membership --
2508
+ * the list filter alone was obscurity, not a boundary. Gated under
2509
+ * `teamMode` exactly like `room-list`, and judged against the same
2510
+ * proven `this.clientId`, never anything claimed in the message.
2511
+ *
2512
+ * A refusal here says SO -- "not a member" -- rather than answering
2513
+ * `room: null`, which is the honest "no such room" answer and would be
2514
+ * a lie about a room that does in fact exist. */
2515
+ if (found && this.opts.teamMode) {
2516
+ const me = this.roomIdentity();
2517
+ if (me === undefined) {
2518
+ this.ch.send({
2519
+ t: 'room-read-result', id: m.id, ok: false,
2520
+ error: 'This connection has no verified identity, so membership in this room cannot be proven.',
2521
+ });
2522
+ return;
2523
+ }
2524
+ if (!found.room.participants.some((p) => p.id === me)) {
2525
+ this.ch.send({
2526
+ t: 'room-read-result', id: m.id, ok: false,
2527
+ error: `"${me}" is not a member of room "${m.roomId}".`,
2528
+ });
2529
+ return;
2530
+ }
2531
+ }
1628
2532
  // `found === null` here is Task 5's considered "no such room" -- keep
1629
2533
  // it a successful `ok: true, room: null`, never conflated with the
1630
2534
  // catch below's "could not read it" failure.
@@ -1654,13 +2558,63 @@ export class SidecarServer {
1654
2558
  this.ch.send({ t: 'room-resume-result', id: m.id, ok: false, error: errMessage(err) });
1655
2559
  }
1656
2560
  }
2561
+ /** WHO THIS CONNECTION IS, INSIDE A ROOM.
2562
+ *
2563
+ * The user its pairing JWT proved (`verifiedUserId`: API-signed, aud = this
2564
+ * daemon, single-use nonce, allowlisted azp) whenever there is one -- the
2565
+ * same user id the browser seats a person under, sanitized by the same rule
2566
+ * (an OpenAI `name` field accepts only [A-Za-z0-9_-]; src/rooms/participantId.ts).
2567
+ * Keying on `clientId` instead -- the browser's random wire id -- refused
2568
+ * people their own rooms: the room seated `user_...`, the check compared
2569
+ * `1463897a-...`.
2570
+ *
2571
+ * No verified user (no verifier configured, an older client, a JWT that did
2572
+ * not verify): `clientId`, exactly as before. Never a claimed id. */
2573
+ roomIdentity() {
2574
+ const verified = this.verifiedUserId?.replace(/[^A-Za-z0-9_-]/g, '');
2575
+ return verified || this.clientId;
2576
+ }
2577
+ /* ONE identity rule for every room verb.
2578
+ *
2579
+ * `room-read` judges membership on `roomIdentity()` -- the user this
2580
+ * connection's pairing JWT proved, else the clientId it PROVED at `hello`. The mutating verbs used to hand
2581
+ * `m.participantId` straight off the wire to `requireRole`/`mayAct`, so one
2582
+ * branch enforced while another trusted the caller: a viewer could send the
2583
+ * owner's participantId and close the room. Under team mode every verb now
2584
+ * judges the caller the same way `room-read` does, and a connection with no
2585
+ * proven identity is refused rather than attributed to whatever it claimed.
2586
+ *
2587
+ * KNOWN, DELIBERATE CONSEQUENCE (R9): the observed `clientId` and the
2588
+ * participant ids a room is opened with are currently DIFFERENT NAMESPACES
2589
+ * for everyone but the owner (whose id `onRoomOpen` overwrites with the
2590
+ * observed one). Until those namespaces are reconciled, these verbs will
2591
+ * REFUSE non-owner participants under `--mode team`. That refusal is the
2592
+ * intended outcome -- fail closed, consistently -- and must NOT be "fixed"
2593
+ * by falling back to the wire id, which is the hole this closes.
2594
+ *
2595
+ * With `--mode team` OFF this is the pre-existing behaviour, untouched: the
2596
+ * wire id is attribution only, exactly as the spec knowingly accepted. */
2597
+ actingId(claimed) {
2598
+ if (!this.opts.teamMode)
2599
+ return { ok: true, id: claimed };
2600
+ const me = this.roomIdentity();
2601
+ if (me === undefined) {
2602
+ return { ok: false, error: 'This connection has no verified identity (no clientId from hello), so it cannot act on a room.' };
2603
+ }
2604
+ return { ok: true, id: me };
2605
+ }
1657
2606
  async onRoomAnswer(m) {
1658
2607
  if (!this.opts.rooms) {
1659
2608
  this.ch.send({ t: 'room-answer-result', id: m.id, ok: false, error: 'this sidecar does not host rooms' });
1660
2609
  return;
1661
2610
  }
1662
2611
  try {
1663
- const room = await this.opts.rooms.answer(m.roomId, m.participantId, m.text, m.addressed);
2612
+ const actor = this.actingId(m.participantId);
2613
+ if (!actor.ok) {
2614
+ this.ch.send({ t: 'room-answer-result', id: m.id, ok: false, error: actor.error });
2615
+ return;
2616
+ }
2617
+ const room = await this.opts.rooms.answer(m.roomId, actor.id, m.text, m.addressed);
1664
2618
  this.ch.send({ t: 'room-answer-result', id: m.id, ok: true, room });
1665
2619
  }
1666
2620
  catch (err) {
@@ -1675,7 +2629,12 @@ export class SidecarServer {
1675
2629
  return;
1676
2630
  }
1677
2631
  try {
1678
- const room = await this.opts.rooms.interject(m.roomId, m.participantId, m.text);
2632
+ const actor = this.actingId(m.participantId);
2633
+ if (!actor.ok) {
2634
+ this.ch.send({ t: 'room-interject-result', id: m.id, ok: false, error: actor.error });
2635
+ return;
2636
+ }
2637
+ const room = await this.opts.rooms.interject(m.roomId, actor.id, m.text);
1679
2638
  this.ch.send({ t: 'room-interject-result', id: m.id, ok: true, room });
1680
2639
  }
1681
2640
  catch (err) {
@@ -1690,7 +2649,12 @@ export class SidecarServer {
1690
2649
  return;
1691
2650
  }
1692
2651
  try {
1693
- const room = await this.opts.rooms.close(m.roomId, m.participantId, m.reason);
2652
+ const actor = this.actingId(m.participantId);
2653
+ if (!actor.ok) {
2654
+ this.ch.send({ t: 'room-close-result', id: m.id, ok: false, error: actor.error });
2655
+ return;
2656
+ }
2657
+ const room = await this.opts.rooms.close(m.roomId, actor.id, m.reason);
1694
2658
  this.ch.send({ t: 'room-close-result', id: m.id, ok: true, room });
1695
2659
  }
1696
2660
  catch (err) {
@@ -1809,7 +2773,12 @@ export class SidecarServer {
1809
2773
  return;
1810
2774
  }
1811
2775
  try {
1812
- const findings = await this.opts.rooms.editFinding(m.roomId, m.participantId, m.edit);
2776
+ const actor = this.actingId(m.participantId);
2777
+ if (!actor.ok) {
2778
+ this.ch.send({ t: 'room-finding-edit-result', id: m.id, ok: false, error: actor.error });
2779
+ return;
2780
+ }
2781
+ const findings = await this.opts.rooms.editFinding(m.roomId, actor.id, m.edit);
1813
2782
  this.ch.send({ t: 'room-finding-edit-result', id: m.id, ok: true, findings });
1814
2783
  }
1815
2784
  catch (err) {