@corenel/harness 0.10.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 +11 -7
  175. package/dist/sessions/fileSessionStore.d.ts.map +1 -1
  176. package/dist/sessions/fileSessionStore.js +27 -16
  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 +4 -0
  181. package/dist/sessions/jsonl.d.ts.map +1 -1
  182. package/dist/sessions/jsonl.js +1 -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 +245 -2
  196. package/dist/sidecar/server.d.ts.map +1 -1
  197. package/dist/sidecar/server.js +833 -45
  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 +6 -2
@@ -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,63 @@ 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
+ });
115
255
  /** This connection's attended-client object, retained so an answer it sends
116
256
  * can be attributed to it. Typed `unknown` because its only consumer is the
117
257
  * registry's own identity lookup, which takes `unknown` for the
@@ -269,6 +409,15 @@ export class SidecarServer {
269
409
  for (const t of opts.tools)
270
410
  this.tools.set(t.name, t);
271
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);
272
421
  // Every connected server subscribes, which is what makes a root added by one
273
422
  // paired client reach ALL of them: two browsers on one daemon must never
274
423
  // disagree about what is reachable. The payload is the COMPLETE set.
@@ -295,6 +444,14 @@ export class SidecarServer {
295
444
  this.ch.send({ t: 'room-progress', room });
296
445
  });
297
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;
298
455
  this.attendedDetach?.();
299
456
  this.attendedDetach = undefined;
300
457
  // Leave the live set, or this directory reads as connected for ever and
@@ -324,10 +481,33 @@ export class SidecarServer {
324
481
  onMessage(m) {
325
482
  switch (m.t) {
326
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. */
327
488
  if (this.authed)
328
- return; // identity is fixed at first hello; a second must not re-own the connection
329
- 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)}`));
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' });
330
509
  return;
510
+ }
331
511
  case 'invoke':
332
512
  if (!this.authed) {
333
513
  this.ch.send({ t: 'invoke-result', id: m.id, ok: false, error: 'not authenticated' });
@@ -368,6 +548,13 @@ export class SidecarServer {
368
548
  return;
369
549
  this.onSubscribe(m);
370
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;
371
558
  case 'acp-start':
372
559
  if (!this.authed) {
373
560
  this.ch.send({ t: 'acp-error', id: m.id, error: 'not authenticated' });
@@ -542,6 +729,66 @@ export class SidecarServer {
542
729
  }
543
730
  void this.onAgentPauseSet(m);
544
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;
545
792
  case 'hosts-list':
546
793
  // `ok: false`, not an empty estate: telling an unauthenticated caller
547
794
  // "this daemon holds no synced hosts" is a claim it has not earned --
@@ -667,19 +914,30 @@ export class SidecarServer {
667
914
  return;
668
915
  }
669
916
  }
670
- 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) {
671
926
  if (m.protocol !== SIDECAR_PROTOCOL) {
672
927
  this.opts.log?.(`denied: unsupported protocol ${m.protocol}`);
673
928
  this.ch.send({ t: 'denied', reason: `protocol ${m.protocol} unsupported` });
929
+ this.countFailedHello();
674
930
  return;
675
931
  }
676
932
  if (!safeEqual(m.token ?? '', this.opts.token)) {
677
933
  this.opts.log?.('denied: invalid token');
678
934
  this.ch.send({ t: 'denied', reason: 'invalid token' });
935
+ this.countFailedHello();
679
936
  return;
680
937
  }
681
938
  this.authed = true;
682
- this.hostStateDir = m.stateDir ?? '.corenel';
939
+ this.clearHelloDeadline();
940
+ this.hostStateDir = m.stateDir ?? DEFAULT_STATE_DIR;
683
941
  // Runtime-validate identity, even though the declared type is `string` --
684
942
  // the message crossed the wire via `JSON.parse(...) as SidecarMsg`, so
685
943
  // nothing actually guaranteed that. A non-string `clientId` (e.g. a number
@@ -745,6 +1003,7 @@ export class SidecarServer {
745
1003
  }
746
1004
  }
747
1005
  this.clientId = clientId;
1006
+ this.accounts = accounts;
748
1007
  // The FRIENDLY label, which falls back to the WIRE id and never to the
749
1008
  // mapped key: the name is what seeds a directory name and what an
750
1009
  // operator reads in `hosts list`, and an `acct:<digest>` there would name
@@ -773,12 +1032,91 @@ export class SidecarServer {
773
1032
  this.opts.log?.(`onClient hook threw: ${errMessage(err)}`);
774
1033
  }
775
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;
776
1113
  if (this.opts.attendance) {
777
1114
  const attended = {
778
1115
  send: (ask) => this.ch.send({ t: 'permission-ask', ...ask }),
779
1116
  resolved: (id, decision, by, byClientId) => this.ch.send({ t: 'permission-resolved', id, decision, by, ...(byClientId ? { byClientId } : {}) }),
780
1117
  runActivity: (activity) => this.ch.send({ t: 'run-activity', activity }),
781
1118
  registrations: (registrations) => this.ch.send({ t: 'registrations', registrations }),
1119
+ approvalSuggestion: (s) => this.ch.send({ t: 'approvals-suggest', ...s }),
782
1120
  };
783
1121
  // Retained so `permission-answer` can name THIS connection as the
784
1122
  // answerer -- the registry identifies an answerer by the object it
@@ -788,39 +1126,125 @@ export class SidecarServer {
788
1126
  * the key this connection was FILED under (mapped through `clientKey`
789
1127
  * where the daemon supplies one), i.e. the same value its RunIdentity
790
1128
  * carries -- which is what makes a run's owner and its ask's recipient
791
- * the same connection. `accounts` are CLAIMED and only ever widen a
792
- * delivery; see the registry's `recipients`. */
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`. */
793
1133
  this.attendedDetach = this.opts.attendance.attach(attended, {
794
1134
  clientId: this.clientId ?? '',
795
1135
  accounts,
1136
+ ...(this.verifiedUserId ? { verifiedUserId: this.verifiedUserId } : {}),
796
1137
  });
797
1138
  }
798
1139
  this.opts.log?.(`paired — ${this.tools.size} ${this.tools.size === 1 ? 'tool' : 'tools'} advertised (host dir: ${this.hostStateDir})`);
799
- /* THE ONE THING THAT WAITS. The welcome carries the synced directory name
800
- * this daemon resolved for this connection, and the client has no other way
801
- * to learn it -- it could previously only deduce it, and got null whenever
802
- * no workspace was open or two hosts held the same workspace id, leaving a
803
- * management surface unable to say which registrations were its own.
804
- *
805
- * Bounded, and every failure ends the same way as an older daemon: no
806
- * `dirName` on the welcome. A handshake that never arrives would be a far
807
- * worse trade than a client that falls back to deducing. */
808
- const identity = this.clientId === undefined
809
- ? undefined
810
- : { clientId: this.clientId, name: this.clientName ?? this.clientId };
811
- if (this.opts.clientDirName && identity) {
812
- void this.resolveDirName(this.opts.clientDirName, identity).then((dirName) => {
813
- this.sendWelcome(dirName);
814
- });
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)
815
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;
816
1240
  }
817
- this.sendWelcome(null);
818
1241
  }
819
1242
  /** Resolve the connection's synced directory name, or null for every failure
820
1243
  * mode there is (throw, rejection, non-string, timeout). */
821
1244
  async resolveDirName(resolve, identity) {
1245
+ let timer;
822
1246
  try {
823
- 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); });
824
1248
  const got = await Promise.race([Promise.resolve(resolve(identity)), timed]);
825
1249
  return typeof got === 'string' && got ? got : null;
826
1250
  }
@@ -828,6 +1252,10 @@ export class SidecarServer {
828
1252
  this.opts.log?.(`clientDirName failed: ${errMessage(err)}`);
829
1253
  return null;
830
1254
  }
1255
+ finally {
1256
+ if (timer !== undefined)
1257
+ clearTimeout(timer);
1258
+ }
831
1259
  }
832
1260
  /** Whether the identity rule can produce an account key right now. A
833
1261
  * predicate that throws answers NO: an advertisement is a promise to the
@@ -874,6 +1302,11 @@ export class SidecarServer {
874
1302
  // default, whose scheduled runs would otherwise fail one by one with
875
1303
  // nothing on screen to say why.
876
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] : []),
877
1310
  ...(this.opts.acp ? ['acp'] : []),
878
1311
  ...(this.opts.roots?.areas.length ? ['add-roots'] : []),
879
1312
  ...(this.opts.roots?.areas.length && this.opts.roots.create ? ['create-roots'] : []),
@@ -894,7 +1327,22 @@ export class SidecarServer {
894
1327
  // behind it, so a client offers "Pause" only where pausing would actually
895
1328
  // stop the agent -- the alternative is a button that reports success on a
896
1329
  // daemon that keeps firing.
897
- ...(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'] : []),
898
1346
  // Whether this daemon can report and reclaim its synced host directories.
899
1347
  // Advertised only with a real manifest behind it, so a client never
900
1348
  // offers a destructive control to a daemon that would refuse it.
@@ -917,11 +1365,20 @@ export class SidecarServer {
917
1365
  // why it must be true at the moment it is said, not merely true of how
918
1366
  // the daemon was built. See `clientKeyActive`.
919
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'] : []),
920
1375
  ];
921
1376
  this.ch.send({
922
1377
  t: 'welcome', tools, features,
923
1378
  ...(this.opts.info ? { info: this.opts.info } : {}),
924
1379
  ...(dirName ? { dirName } : {}),
1380
+ ...(this.opts.push ? { vapidPublicKey: this.opts.push.publicKey } : {}),
1381
+ ...(this.opts.identity ? { identity: this.identityResult } : {}),
925
1382
  });
926
1383
  }
927
1384
  /** Proxy a host HTTP request server-side. Restricted to LOOPBACK targets — the
@@ -940,7 +1397,20 @@ export class SidecarServer {
940
1397
  throw new Error('http(s) URLs only');
941
1398
  if (!isLoopbackHost(u.hostname))
942
1399
  throw new Error(`the sidecar proxy only reaches loopback hosts, not "${u.hostname}"`);
943
- 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' });
944
1414
  const headers = {};
945
1415
  res.headers.forEach((v, k) => { headers[k] = v; });
946
1416
  this.ch.send({ t: 'http-res', id: m.id, status: res.status, statusText: res.statusText, headers });
@@ -983,7 +1453,11 @@ export class SidecarServer {
983
1453
  * which URLs are refused all live on the daemon side, where the
984
1454
  * operator's declarations are. */
985
1455
  ...(m.repo ? { repo: m.repo } : {}),
986
- ...(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 } : {}),
987
1461
  ...(m.fromSnapshot ? { fromSnapshot: m.fromSnapshot } : {}),
988
1462
  // From the HANDSHAKE, never from this message: the dot-dir is a
989
1463
  // property of the client's workspace, and taking it from the
@@ -1212,7 +1686,12 @@ export class SidecarServer {
1212
1686
  this.ch.send({ t: 'invoke-result', id: m.id, ok: true, result });
1213
1687
  }
1214
1688
  catch (e) {
1215
- 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)}`);
1216
1695
  this.ch.send({ t: 'invoke-result', id: m.id, ok: false, error: errMessage(e) });
1217
1696
  }
1218
1697
  }
@@ -1243,6 +1722,10 @@ export class SidecarServer {
1243
1722
  * user has -- the client does -- and the ACP client drops them if the
1244
1723
  * agent never advertised mcpCapabilities.http. */
1245
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 } : {}),
1246
1729
  });
1247
1730
  this.ch.send({ t: 'acp-started', id: m.id, ...started });
1248
1731
  }
@@ -1333,6 +1816,19 @@ export class SidecarServer {
1333
1816
  detachOnce();
1334
1817
  }
1335
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
+ }
1336
1832
  /**
1337
1833
  * What this caller's agent has actually done.
1338
1834
  *
@@ -1395,6 +1891,14 @@ export class SidecarServer {
1395
1891
  * says so rather than running the work in its own process anyway — silently
1396
1892
  * downgrading is the failure IsolationLevel exists to prevent, and it would
1397
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
+ }
1398
1902
  if (m.sessionId !== undefined) {
1399
1903
  const sessions = this.opts.sessions;
1400
1904
  if (!sessions?.run) {
@@ -1417,7 +1921,10 @@ export class SidecarServer {
1417
1921
  // would grant itself mutating tools inside a sandbox. Deliberately
1418
1922
  // absent -- see session/guestRunner.ts.
1419
1923
  this.opts.sessions.run(m.sessionId, agent, input, onEvent, identity, { keepTranscript: m.keepTranscript === true })
1420
- : (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 });
1421
1928
  let runId;
1422
1929
  const pending = [];
1423
1930
  const onEvent = (event) => {
@@ -1573,6 +2080,17 @@ export class SidecarServer {
1573
2080
  }
1574
2081
  }
1575
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
+ }
1576
2094
  if (!this.opts.pauses) {
1577
2095
  // An honest empty answer for a daemon without the capability: it is
1578
2096
  // holding no pauses, which is true. The feature gate is what tells a
@@ -1588,7 +2106,7 @@ export class SidecarServer {
1588
2106
  }
1589
2107
  }
1590
2108
  async onAgentPauseSet(m) {
1591
- if (!this.opts.pauses) {
2109
+ if (!this.opts.pauses && !this.opts.crewDeploy) {
1592
2110
  // NOT an empty success. Accepting a pause this daemon cannot hold would
1593
2111
  // leave the operator believing an agent had been stopped while it kept
1594
2112
  // firing on the hour -- the exact failure the feature exists to end.
@@ -1602,6 +2120,23 @@ export class SidecarServer {
1602
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' });
1603
2121
  return;
1604
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;
1605
2140
  try {
1606
2141
  const pauses = await this.opts.pauses.set(m.owner, m.agent, m.paused === true);
1607
2142
  // Republish before replying: the caller's own row and every other
@@ -1614,6 +2149,241 @@ export class SidecarServer {
1614
2149
  this.ch.send({ t: 'agent-pause-set-result', id: m.id, ok: false, error: errMessage(err) });
1615
2150
  }
1616
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
+ }
1617
2387
  async onRoomOpen(m) {
1618
2388
  if (!this.opts.rooms) {
1619
2389
  this.ch.send({ t: 'room-open-result', id: m.id, ok: false, problems: ['this sidecar does not host rooms'] });
@@ -1659,7 +2429,7 @@ export class SidecarServer {
1659
2429
  * unconditionally would make the opt-in flag not an opt-in -- see the
1660
2430
  * team-mode-off test in host.rooms.test.ts, which is the merge-safety
1661
2431
  * claim for this whole branch. */
1662
- if (this.opts.teamMode && this.clientId === undefined) {
2432
+ if (this.opts.teamMode && this.roomIdentity() === undefined) {
1663
2433
  this.ch.send({
1664
2434
  t: 'room-open-result',
1665
2435
  id: m.id,
@@ -1668,7 +2438,7 @@ export class SidecarServer {
1668
2438
  });
1669
2439
  return;
1670
2440
  }
1671
- const clientId = this.clientId;
2441
+ const clientId = this.roomIdentity();
1672
2442
  const owner = clientId === undefined
1673
2443
  ? undefined
1674
2444
  : m.req.participants.find((p) => p.kind === 'human' && p.role === 'owner');
@@ -1702,8 +2472,8 @@ export class SidecarServer {
1702
2472
  * returned, unnarrowed, for every existing room test and every daemon
1703
2473
  * nobody has reconfigured.
1704
2474
  *
1705
- * Team mode on: membership is judged against `this.clientId`, the
1706
- * connection's OWN proven identity, never a participant id read out of
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
1707
2477
  * the request -- that is what `onRoomOpen` just finished making
1708
2478
  * trustworthy. A caller with NO clientId is not "an older client that
1709
2479
  * predates identity" here the way it is at `hello` -- membership
@@ -1713,9 +2483,9 @@ export class SidecarServer {
1713
2483
  * opt-out: connect without a clientId and see it all anyway. */
1714
2484
  const mine = !this.opts.teamMode
1715
2485
  ? rooms
1716
- : this.clientId === undefined
2486
+ : this.roomIdentity() === undefined
1717
2487
  ? []
1718
- : rooms.filter((r) => r.participants.some((p) => p.id === this.clientId));
2488
+ : rooms.filter((r) => r.participants.some((p) => p.id === this.roomIdentity()));
1719
2489
  this.ch.send({ t: 'room-list-result', id: m.id, ok: true, rooms: mine });
1720
2490
  }
1721
2491
  catch (err) {
@@ -1743,17 +2513,18 @@ export class SidecarServer {
1743
2513
  * `room: null`, which is the honest "no such room" answer and would be
1744
2514
  * a lie about a room that does in fact exist. */
1745
2515
  if (found && this.opts.teamMode) {
1746
- if (this.clientId === undefined) {
2516
+ const me = this.roomIdentity();
2517
+ if (me === undefined) {
1747
2518
  this.ch.send({
1748
2519
  t: 'room-read-result', id: m.id, ok: false,
1749
2520
  error: 'This connection has no verified identity, so membership in this room cannot be proven.',
1750
2521
  });
1751
2522
  return;
1752
2523
  }
1753
- if (!found.room.participants.some((p) => p.id === this.clientId)) {
2524
+ if (!found.room.participants.some((p) => p.id === me)) {
1754
2525
  this.ch.send({
1755
2526
  t: 'room-read-result', id: m.id, ok: false,
1756
- error: `"${this.clientId}" is not a member of room "${m.roomId}".`,
2527
+ error: `"${me}" is not a member of room "${m.roomId}".`,
1757
2528
  });
1758
2529
  return;
1759
2530
  }
@@ -1787,10 +2558,26 @@ export class SidecarServer {
1787
2558
  this.ch.send({ t: 'room-resume-result', id: m.id, ok: false, error: errMessage(err) });
1788
2559
  }
1789
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
+ }
1790
2577
  /* ONE identity rule for every room verb.
1791
2578
  *
1792
- * `room-read` judges membership on `this.clientId` -- the identity this
1793
- * connection PROVED at `hello`. The mutating verbs used to hand
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
1794
2581
  * `m.participantId` straight off the wire to `requireRole`/`mayAct`, so one
1795
2582
  * branch enforced while another trusted the caller: a viewer could send the
1796
2583
  * owner's participantId and close the room. Under team mode every verb now
@@ -1810,10 +2597,11 @@ export class SidecarServer {
1810
2597
  actingId(claimed) {
1811
2598
  if (!this.opts.teamMode)
1812
2599
  return { ok: true, id: claimed };
1813
- if (this.clientId === undefined) {
2600
+ const me = this.roomIdentity();
2601
+ if (me === undefined) {
1814
2602
  return { ok: false, error: 'This connection has no verified identity (no clientId from hello), so it cannot act on a room.' };
1815
2603
  }
1816
- return { ok: true, id: this.clientId };
2604
+ return { ok: true, id: me };
1817
2605
  }
1818
2606
  async onRoomAnswer(m) {
1819
2607
  if (!this.opts.rooms) {