@corenel/harness 0.10.0 → 0.12.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 (251) 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 +67 -6
  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 +41 -6
  106. package/dist/guardrail/permission-service.d.ts.map +1 -1
  107. package/dist/guardrail/permission-service.js +118 -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 +112 -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 +41 -0
  136. package/dist/orchestrator/planDoc.d.ts.map +1 -0
  137. package/dist/orchestrator/planDoc.js +181 -0
  138. package/dist/orchestrator/planDoc.js.map +1 -0
  139. package/dist/orchestrator/record.d.ts +110 -0
  140. package/dist/orchestrator/record.d.ts.map +1 -0
  141. package/dist/orchestrator/record.js +221 -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/init.d.ts +12 -0
  155. package/dist/prompts/init.d.ts.map +1 -0
  156. package/dist/prompts/init.js +48 -0
  157. package/dist/prompts/init.js.map +1 -0
  158. package/dist/prompts/library/defaults.generated.d.ts.map +1 -1
  159. package/dist/prompts/library/defaults.generated.js +29 -12
  160. package/dist/prompts/library/defaults.generated.js.map +1 -1
  161. package/dist/redact.d.ts.map +1 -1
  162. package/dist/redact.js +1 -0
  163. package/dist/redact.js.map +1 -1
  164. package/dist/rooms/findings.d.ts +14 -0
  165. package/dist/rooms/findings.d.ts.map +1 -1
  166. package/dist/rooms/findings.js +37 -0
  167. package/dist/rooms/findings.js.map +1 -1
  168. package/dist/sessions/blobGc.d.ts.map +1 -1
  169. package/dist/sessions/blobGc.js +2 -5
  170. package/dist/sessions/blobGc.js.map +1 -1
  171. package/dist/sessions/childSessions.d.ts +12 -0
  172. package/dist/sessions/childSessions.d.ts.map +1 -0
  173. package/dist/sessions/childSessions.js +43 -0
  174. package/dist/sessions/childSessions.js.map +1 -0
  175. package/dist/sessions/fileRecallStore.d.ts.map +1 -1
  176. package/dist/sessions/fileRecallStore.js +1 -3
  177. package/dist/sessions/fileRecallStore.js.map +1 -1
  178. package/dist/sessions/fileSessionStore.d.ts +11 -7
  179. package/dist/sessions/fileSessionStore.d.ts.map +1 -1
  180. package/dist/sessions/fileSessionStore.js +27 -16
  181. package/dist/sessions/fileSessionStore.js.map +1 -1
  182. package/dist/sessions/inflight.js +0 -0
  183. package/dist/sessions/inflight.js.map +1 -1
  184. package/dist/sessions/jsonl.d.ts +4 -0
  185. package/dist/sessions/jsonl.d.ts.map +1 -1
  186. package/dist/sessions/jsonl.js +1 -0
  187. package/dist/sessions/jsonl.js.map +1 -1
  188. package/dist/sessions/testFs.d.ts.map +1 -1
  189. package/dist/sessions/testFs.js +1 -1
  190. package/dist/sessions/testFs.js.map +1 -1
  191. package/dist/sidecar/host.d.ts +194 -3
  192. package/dist/sidecar/host.d.ts.map +1 -1
  193. package/dist/sidecar/host.js +401 -6
  194. package/dist/sidecar/host.js.map +1 -1
  195. package/dist/sidecar/identity.d.ts +44 -0
  196. package/dist/sidecar/identity.d.ts.map +1 -0
  197. package/dist/sidecar/identity.js +45 -0
  198. package/dist/sidecar/identity.js.map +1 -0
  199. package/dist/sidecar/server.d.ts +250 -4
  200. package/dist/sidecar/server.d.ts.map +1 -1
  201. package/dist/sidecar/server.js +866 -46
  202. package/dist/sidecar/server.js.map +1 -1
  203. package/dist/skillSource.js +1 -1
  204. package/dist/skillSource.js.map +1 -1
  205. package/dist/skills.d.ts +23 -7
  206. package/dist/skills.d.ts.map +1 -1
  207. package/dist/skills.js +62 -37
  208. package/dist/skills.js.map +1 -1
  209. package/dist/tools/callAgent.d.ts +29 -12
  210. package/dist/tools/callAgent.d.ts.map +1 -1
  211. package/dist/tools/callAgent.js +120 -14
  212. package/dist/tools/callAgent.js.map +1 -1
  213. package/dist/tools/crewList.d.ts +33 -0
  214. package/dist/tools/crewList.d.ts.map +1 -0
  215. package/dist/tools/crewList.js +66 -0
  216. package/dist/tools/crewList.js.map +1 -0
  217. package/dist/tools/files.d.ts.map +1 -1
  218. package/dist/tools/files.js +11 -5
  219. package/dist/tools/files.js.map +1 -1
  220. package/dist/tools/findAgent.d.ts +23 -0
  221. package/dist/tools/findAgent.d.ts.map +1 -0
  222. package/dist/tools/findAgent.js +58 -0
  223. package/dist/tools/findAgent.js.map +1 -0
  224. package/dist/tools/namespaces.d.ts.map +1 -1
  225. package/dist/tools/namespaces.js +7 -0
  226. package/dist/tools/namespaces.js.map +1 -1
  227. package/dist/tools/orchestrate.d.ts +4 -0
  228. package/dist/tools/orchestrate.d.ts.map +1 -0
  229. package/dist/tools/orchestrate.js +38 -0
  230. package/dist/tools/orchestrate.js.map +1 -0
  231. package/dist/tools/orchestration.d.ts +24 -0
  232. package/dist/tools/orchestration.d.ts.map +1 -0
  233. package/dist/tools/orchestration.js +173 -0
  234. package/dist/tools/orchestration.js.map +1 -0
  235. package/dist/tools/orchestrationSet.d.ts +6 -0
  236. package/dist/tools/orchestrationSet.d.ts.map +1 -0
  237. package/dist/tools/orchestrationSet.js +12 -0
  238. package/dist/tools/orchestrationSet.js.map +1 -0
  239. package/dist/tools/roomTools.d.ts +6 -0
  240. package/dist/tools/roomTools.d.ts.map +1 -1
  241. package/dist/tools/roomTools.js +26 -2
  242. package/dist/tools/roomTools.js.map +1 -1
  243. package/dist/tools/skillTool.d.ts +15 -0
  244. package/dist/tools/skillTool.d.ts.map +1 -0
  245. package/dist/tools/skillTool.js +39 -0
  246. package/dist/tools/skillTool.js.map +1 -0
  247. package/dist/tools/urlGuard.d.ts +7 -0
  248. package/dist/tools/urlGuard.d.ts.map +1 -1
  249. package/dist/tools/urlGuard.js +54 -18
  250. package/dist/tools/urlGuard.js.map +1 -1
  251. 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, RUN_PLAN_FEATURE, CREW_DEPLOY_FEATURE, denialNote } 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)}`));
330
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
+ }
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' });
@@ -397,7 +584,9 @@ export class SidecarServer {
397
584
  // answer is dropped silently rather than forwarded into the registry.
398
585
  if (!this.authed)
399
586
  return;
400
- this.opts.attendance?.answer(m.id, m.decision, this.attended);
587
+ // The note is the client's text: cleaned HERE, at the daemon's edge,
588
+ // and only a deny carries one.
589
+ this.opts.attendance?.answer(m.id, m.decision, this.attended, m.decision === 'deny' ? denialNote(m.note) : undefined);
401
590
  return;
402
591
  case 'roots-browse':
403
592
  // Auth-gated like every other request: directory enumeration must never
@@ -542,6 +731,76 @@ export class SidecarServer {
542
731
  }
543
732
  void this.onAgentPauseSet(m);
544
733
  return;
734
+ case 'crew-deployments':
735
+ case 'crew-deploy':
736
+ case 'crew-undeploy':
737
+ case 'crew-pause':
738
+ case 'crew-resume':
739
+ case 'crew-stop':
740
+ if (!this.authed) {
741
+ this.ch.send({ t: 'crew-deploy-result', id: m.id, reply: { ok: false, error: 'not authenticated' } });
742
+ return;
743
+ }
744
+ void this.onCrewDeploy(m);
745
+ return;
746
+ case 'approvals-accept':
747
+ if (!this.authed) {
748
+ this.ch.send({ t: 'approvals-accept-result', id: m.id, ok: false, error: 'not authenticated' });
749
+ return;
750
+ }
751
+ void this.onApprovalsAccept(m);
752
+ return;
753
+ case 'approvals-list':
754
+ if (!this.authed) {
755
+ this.ch.send({ t: 'approvals-list-result', id: m.id, ok: false, error: 'not authenticated' });
756
+ return;
757
+ }
758
+ void this.onApprovalsList(m);
759
+ return;
760
+ case 'approvals-revoke':
761
+ if (!this.authed) {
762
+ this.ch.send({ t: 'approvals-revoke-result', id: m.id, ok: false, error: 'not authenticated' });
763
+ return;
764
+ }
765
+ void this.onApprovalsRevoke(m);
766
+ return;
767
+ case 'checks-get':
768
+ if (!this.authed) {
769
+ this.ch.send({ t: 'checks-get-result', id: m.id, ok: false, error: 'not authenticated' });
770
+ return;
771
+ }
772
+ void this.onChecksGet(m);
773
+ return;
774
+ case 'checks-pin':
775
+ if (!this.authed) {
776
+ this.ch.send({ t: 'checks-pin-result', id: m.id, ok: false, error: 'not authenticated' });
777
+ return;
778
+ }
779
+ void this.onChecksPin(m);
780
+ return;
781
+ case 'workspace-release':
782
+ if (!this.authed) {
783
+ this.ch.send({ t: 'workspace-release-result', id: m.id, ok: false, error: 'not authenticated' });
784
+ return;
785
+ }
786
+ void this.onWorkspaceRelease(m);
787
+ return;
788
+ case 'run-plan-get':
789
+ // Authorized exactly like `subscribe` (watching a run): authenticated,
790
+ // any run id. The answer is narrower than the event stream a watcher
791
+ // already receives.
792
+ if (!this.authed) {
793
+ this.ch.send({ t: 'run-plan-get-result', id: m.id, ok: false, error: 'not authenticated' });
794
+ return;
795
+ }
796
+ void this.onRunPlanGet(m);
797
+ return;
798
+ case 'push-subscribe':
799
+ void this.onPushSubscribe(m);
800
+ return;
801
+ case 'push-unsubscribe':
802
+ void this.onPushUnsubscribe(m);
803
+ return;
545
804
  case 'hosts-list':
546
805
  // `ok: false`, not an empty estate: telling an unauthenticated caller
547
806
  // "this daemon holds no synced hosts" is a claim it has not earned --
@@ -667,19 +926,30 @@ export class SidecarServer {
667
926
  return;
668
927
  }
669
928
  }
670
- onHello(m) {
929
+ /** Close a connection that keeps failing to pair. */
930
+ countFailedHello() {
931
+ this.failedHellos++;
932
+ if (this.failedHellos >= MAX_FAILED_HELLOS) {
933
+ this.opts.log?.(`closing a connection after ${this.failedHellos} denied hellos`);
934
+ this.ch.close();
935
+ }
936
+ }
937
+ async onHello(m) {
671
938
  if (m.protocol !== SIDECAR_PROTOCOL) {
672
939
  this.opts.log?.(`denied: unsupported protocol ${m.protocol}`);
673
940
  this.ch.send({ t: 'denied', reason: `protocol ${m.protocol} unsupported` });
941
+ this.countFailedHello();
674
942
  return;
675
943
  }
676
944
  if (!safeEqual(m.token ?? '', this.opts.token)) {
677
945
  this.opts.log?.('denied: invalid token');
678
946
  this.ch.send({ t: 'denied', reason: 'invalid token' });
947
+ this.countFailedHello();
679
948
  return;
680
949
  }
681
950
  this.authed = true;
682
- this.hostStateDir = m.stateDir ?? '.corenel';
951
+ this.clearHelloDeadline();
952
+ this.hostStateDir = m.stateDir ?? DEFAULT_STATE_DIR;
683
953
  // Runtime-validate identity, even though the declared type is `string` --
684
954
  // the message crossed the wire via `JSON.parse(...) as SidecarMsg`, so
685
955
  // nothing actually guaranteed that. A non-string `clientId` (e.g. a number
@@ -745,6 +1015,7 @@ export class SidecarServer {
745
1015
  }
746
1016
  }
747
1017
  this.clientId = clientId;
1018
+ this.accounts = accounts;
748
1019
  // The FRIENDLY label, which falls back to the WIRE id and never to the
749
1020
  // mapped key: the name is what seeds a directory name and what an
750
1021
  // operator reads in `hosts list`, and an `acct:<digest>` there would name
@@ -773,12 +1044,91 @@ export class SidecarServer {
773
1044
  this.opts.log?.(`onClient hook threw: ${errMessage(err)}`);
774
1045
  }
775
1046
  }
1047
+ /* THE WAITS, started together. The welcome carries the synced directory
1048
+ * name this daemon resolved for this connection, and the client has no
1049
+ * other way to learn it -- it could previously only deduce it, and got null
1050
+ * whenever no workspace was open or two hosts held the same workspace id,
1051
+ * leaving a management surface unable to say which registrations were its
1052
+ * own.
1053
+ *
1054
+ * The dirName lookup is STARTED here, before the identity verdict is
1055
+ * awaited, so the two bounded waits (DIR_NAME_TIMEOUT_MS,
1056
+ * IDENTITY_TIMEOUT_MS) overlap: the worst case stays one bound, not two.
1057
+ * Both only ever resolve (every failure is null / unverified), so starting
1058
+ * one and then awaiting the other cannot leave a rejection unhandled.
1059
+ *
1060
+ * Bounded, and every dirName failure ends the same way as an older daemon:
1061
+ * no `dirName` on the welcome. A handshake that never arrives would be a
1062
+ * far worse trade than a client that falls back to deducing. With neither
1063
+ * a resolver nor a verifier nothing is awaited at all, and the welcome
1064
+ * goes out in the same tick as today. */
1065
+ const dirIdentity = this.clientId === undefined
1066
+ ? undefined
1067
+ : { clientId: this.clientId, name: this.clientName ?? this.clientId };
1068
+ const dirNamePending = this.opts.clientDirName && dirIdentity
1069
+ ? this.resolveDirName(this.opts.clientDirName, dirIdentity)
1070
+ : undefined;
1071
+ /* IDENTITY, decided BEFORE attendance or push can see this connection.
1072
+ * Reached only past the pairing-token check above -- the challenge is
1073
+ * never sent for a bad pairing token. Whatever the verdict, the
1074
+ * connection pairs: unverified only withholds the widened trust a verified
1075
+ * id earns. `verifiedUserId` is assigned here and nowhere else, once. The
1076
+ * assertion is never logged; only the reason is. The Clerk token itself
1077
+ * never reaches this class -- this exchange only ever sees a corenel-api
1078
+ * pairing assertion (spec amendment 2026-09-25b). */
1079
+ if (this.opts.identity) {
1080
+ if (!m.wantsIdentity) {
1081
+ this.identityResult = { verified: false, reason: 'not requested' };
1082
+ }
1083
+ else if (!this.opts.info?.id) {
1084
+ this.identityResult = { verified: false, reason: 'no daemon id' };
1085
+ }
1086
+ else {
1087
+ const sidecarId = this.opts.info.id;
1088
+ /* The hash of the pairing token THIS connection presented (it passed
1089
+ * the check above). The assertion's `cnf.tkh` must equal it: that
1090
+ * binds the assertion to the pairing the browser actually made, so a
1091
+ * daemon that holds our token cannot relay our challenge through a
1092
+ * browser paired with IT (that browser hashed its own, different,
1093
+ * token). STARTED here but not awaited: the digest is async, and the
1094
+ * challenge, the resolver and the bound timer must all be in place
1095
+ * synchronously -- it is awaited only once an assertion is in hand
1096
+ * (see verdictFromAssertion), inside the same bound. A digest that
1097
+ * rejects pairs unverified ('error'), never refused. */
1098
+ const tokenHash = pairingTokenHash(m.token ?? '').catch(() => null);
1099
+ const nonce = makeNonce();
1100
+ this.ch.send({ t: 'identity-challenge', nonce, sidecarId });
1101
+ const verdict = await this.awaitAndVerifyIdentity(sidecarId, nonce, tokenHash);
1102
+ if (verdict.ok)
1103
+ this.verifiedUserId = verdict.identity.userId;
1104
+ this.identityResult = verdict.ok ? { verified: true } : { verified: false, reason: verdict.reason };
1105
+ }
1106
+ this.opts.log?.(`identity: ${this.identityResult.verified ? 'verified' : `unverified (${this.identityResult.reason})`}`);
1107
+ }
1108
+ /* OLDER APPS (crew deploy). A client that does not announce it can deploy
1109
+ * has agents that sync here but never fire until deployed, and nothing on
1110
+ * its screen can say so -- the daemon says it, once per client (the port
1111
+ * de-duplicates). The list is only ever informational: it grants nothing.
1112
+ * A malformed one counts as absent. Best-effort: never breaks a handshake. */
1113
+ if (this.opts.crewDeploy && !announcedCapabilities(m.capabilities).includes(CREW_DEPLOY_FEATURE)) {
1114
+ try {
1115
+ this.opts.crewDeploy.noteOlderClient(this.clientId ?? '', this.clientName ?? 'unknown app');
1116
+ }
1117
+ catch (err) {
1118
+ this.opts.log?.(`noteOlderClient threw: ${errMessage(err)}`);
1119
+ }
1120
+ }
1121
+ // Dropped while verifying: attaching now would leave a dead client in the
1122
+ // registry that the (already-run) close handler can never detach.
1123
+ if (this.closed)
1124
+ return;
776
1125
  if (this.opts.attendance) {
777
1126
  const attended = {
778
1127
  send: (ask) => this.ch.send({ t: 'permission-ask', ...ask }),
779
1128
  resolved: (id, decision, by, byClientId) => this.ch.send({ t: 'permission-resolved', id, decision, by, ...(byClientId ? { byClientId } : {}) }),
780
1129
  runActivity: (activity) => this.ch.send({ t: 'run-activity', activity }),
781
1130
  registrations: (registrations) => this.ch.send({ t: 'registrations', registrations }),
1131
+ approvalSuggestion: (s) => this.ch.send({ t: 'approvals-suggest', ...s }),
782
1132
  };
783
1133
  // Retained so `permission-answer` can name THIS connection as the
784
1134
  // answerer -- the registry identifies an answerer by the object it
@@ -788,39 +1138,125 @@ export class SidecarServer {
788
1138
  * the key this connection was FILED under (mapped through `clientKey`
789
1139
  * where the daemon supplies one), i.e. the same value its RunIdentity
790
1140
  * 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`. */
1141
+ * the same connection. `accounts` are CLAIMED, carried for display, and
1142
+ * widen nothing; only `verifiedUserId` (proven at pairing) widens a
1143
+ * delivery to the same user's other devices. See the registry's
1144
+ * `recipients`. */
793
1145
  this.attendedDetach = this.opts.attendance.attach(attended, {
794
1146
  clientId: this.clientId ?? '',
795
1147
  accounts,
1148
+ ...(this.verifiedUserId ? { verifiedUserId: this.verifiedUserId } : {}),
796
1149
  });
797
1150
  }
798
1151
  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
- });
1152
+ if (!dirNamePending) {
1153
+ this.sendWelcome(null);
1154
+ return;
1155
+ }
1156
+ const dirName = await dirNamePending;
1157
+ // Same reasoning as above: a dropped connection must not attach to the
1158
+ // hosts live set, which only a close handler that already ran would undo.
1159
+ if (this.closed)
815
1160
  return;
1161
+ /* CREW DEPLOY OWNERSHIP: a verified connection filed as a synced folder
1162
+ * binds that folder to its user (first one wins; the daemon never
1163
+ * rebinds). Before the welcome, so nothing this client sends can race it.
1164
+ * Best-effort: never breaks a handshake. */
1165
+ if (dirName && this.verifiedUserId && this.opts.crewDeploy?.noteVerifiedDir) {
1166
+ try {
1167
+ this.opts.crewDeploy.noteVerifiedDir(dirName, this.verifiedUserId);
1168
+ }
1169
+ catch (err) {
1170
+ this.opts.log?.(`noteVerifiedDir threw: ${errMessage(err)}`);
1171
+ }
1172
+ }
1173
+ this.sendWelcome(dirName);
1174
+ }
1175
+ /** Waits for this connection's `identity-assert`, with NO timeout of its
1176
+ * own -- the overall bound is `awaitAndVerifyIdentity`'s, covering this wait
1177
+ * AND the verify call together, so the whole leg is bounded at
1178
+ * IDENTITY_TIMEOUT_MS from the challenge, not up to double that. Also
1179
+ * settled (to a timeout-shaped answer) from the close handler, so a
1180
+ * dropped connection cannot leave this pending forever. */
1181
+ awaitIdentityAssertion() {
1182
+ return new Promise((resolve) => {
1183
+ this.identityAssertResolve = resolve;
1184
+ });
1185
+ }
1186
+ /** Turns an `identity-assert` answer into a verdict: 'no assertion' or
1187
+ * 'timed out' unverified for the non-assertion answers, 'assertion too
1188
+ * long' WITHOUT calling the verifier for an oversized one, and otherwise
1189
+ * the verifier's own verdict (never thrown -- a throw or rejection becomes
1190
+ * an unverified 'error'). The expectation is built here, once the
1191
+ * pairing-token hash has settled; a hash that could not be computed is an
1192
+ * unverified 'error' without calling the verifier. */
1193
+ async verdictFromAssertion(answer, audience, nonce, tokenHashPending) {
1194
+ if (answer.kind === 'none')
1195
+ return { ok: false, reason: 'no assertion' };
1196
+ if (answer.kind === 'timeout')
1197
+ return { ok: false, reason: 'timed out' };
1198
+ const assertion = answer.value;
1199
+ if (assertion.length > MAX_ASSERTION_LEN)
1200
+ return { ok: false, reason: 'assertion too long' };
1201
+ const v = this.opts.identity;
1202
+ if (!v)
1203
+ return { ok: false, reason: 'no verifier' };
1204
+ const tokenHash = await tokenHashPending;
1205
+ if (tokenHash === null)
1206
+ return { ok: false, reason: 'error' };
1207
+ const expect = { audience, nonce, tokenHash };
1208
+ try {
1209
+ const verdict = v.explain
1210
+ ? await v.explain(assertion, expect)
1211
+ : await v.verify(assertion, expect).then((id) => (id ? { ok: true, identity: id } : { ok: false, reason: 'rejected' }));
1212
+ return normalizeVerdict(verdict);
1213
+ }
1214
+ catch (err) {
1215
+ this.opts.log?.(`identity verification failed: error (${sanitizeReason(errMessage(err))})`);
1216
+ return { ok: false, reason: 'error' };
1217
+ }
1218
+ }
1219
+ /** The identity verdict for this connection's challenge/assert exchange,
1220
+ * bounded by IDENTITY_TIMEOUT_MS FROM THE CHALLENGE -- covering both the
1221
+ * wait for `identity-assert` and the verify call, in one race, so a slow
1222
+ * verifier cannot add a second timeout's worth of wait on top of a slow
1223
+ * browser. Every failure mode (no assertion, an oversized one, a throw, a
1224
+ * rejection, a malformed answer, a timeout) is an UNVERIFIED verdict with a
1225
+ * reason -- never a refusal. The assertion is passed to the verifier and
1226
+ * nowhere else. */
1227
+ async awaitAndVerifyIdentity(audience, nonce, tokenHash) {
1228
+ let timer;
1229
+ try {
1230
+ const timed = new Promise((res) => {
1231
+ timer = setTimeout(() => res({ ok: false, reason: 'timed out' }), IDENTITY_TIMEOUT_MS);
1232
+ });
1233
+ const answered = this.awaitIdentityAssertion().then((answer) => this.verdictFromAssertion(answer, audience, nonce, tokenHash));
1234
+ const got = await Promise.race([answered, timed]);
1235
+ return normalizeVerdict(got);
1236
+ }
1237
+ finally {
1238
+ if (timer !== undefined)
1239
+ clearTimeout(timer);
1240
+ /* Whichever side of the race won, nothing may still be waiting on
1241
+ * `identity-assert` once this settles. If `answered` won, the message
1242
+ * handler already cleared this itself. If `timed` won (the timeout),
1243
+ * NOTHING has cleared it yet -- `awaitIdentityAssertion`'s promise is
1244
+ * still pending, just no longer awaited by anyone. Leaving the
1245
+ * resolver live would let a LATE `identity-assert` (arriving after the
1246
+ * bound this method already returned past) still call it, running
1247
+ * `verdictFromAssertion` -- and therefore the verifier, and possibly a
1248
+ * JWKS fetch -- for a result nothing reads, while the message handler's
1249
+ * own comment claims a late message is ignored. Clearing here is what
1250
+ * makes that claim true. */
1251
+ this.identityAssertResolve = undefined;
816
1252
  }
817
- this.sendWelcome(null);
818
1253
  }
819
1254
  /** Resolve the connection's synced directory name, or null for every failure
820
1255
  * mode there is (throw, rejection, non-string, timeout). */
821
1256
  async resolveDirName(resolve, identity) {
1257
+ let timer;
822
1258
  try {
823
- const timed = new Promise((res) => setTimeout(() => res(null), DIR_NAME_TIMEOUT_MS));
1259
+ const timed = new Promise((res) => { timer = setTimeout(() => res(null), DIR_NAME_TIMEOUT_MS); });
824
1260
  const got = await Promise.race([Promise.resolve(resolve(identity)), timed]);
825
1261
  return typeof got === 'string' && got ? got : null;
826
1262
  }
@@ -828,6 +1264,10 @@ export class SidecarServer {
828
1264
  this.opts.log?.(`clientDirName failed: ${errMessage(err)}`);
829
1265
  return null;
830
1266
  }
1267
+ finally {
1268
+ if (timer !== undefined)
1269
+ clearTimeout(timer);
1270
+ }
831
1271
  }
832
1272
  /** Whether the identity rule can produce an account key right now. A
833
1273
  * predicate that throws answers NO: an advertisement is a promise to the
@@ -874,6 +1314,13 @@ export class SidecarServer {
874
1314
  // default, whose scheduled runs would otherwise fail one by one with
875
1315
  // nothing on screen to say why.
876
1316
  ...(this.opts.runner?.hasDefaultModel ? ['default-model'] : []),
1317
+ // A client offers Stop on a watched daemon run only where it would work.
1318
+ ...(this.opts.runner?.cancel ? ['run-cancel'] : []),
1319
+ // A client starts a task only where the daemon will honour it: an older
1320
+ // runner ignores `task`, and a dropped task must never look like a start.
1321
+ ...(this.opts.runner?.supportsTasks ? [RUN_TASK_FEATURE] : []),
1322
+ // Whether this daemon answers run-plan-get (a runner that reads run records).
1323
+ ...(this.opts.runner?.runPlan ? [RUN_PLAN_FEATURE] : []),
877
1324
  ...(this.opts.acp ? ['acp'] : []),
878
1325
  ...(this.opts.roots?.areas.length ? ['add-roots'] : []),
879
1326
  ...(this.opts.roots?.areas.length && this.opts.roots.create ? ['create-roots'] : []),
@@ -894,7 +1341,22 @@ export class SidecarServer {
894
1341
  // behind it, so a client offers "Pause" only where pausing would actually
895
1342
  // stop the agent -- the alternative is a button that reports success on a
896
1343
  // daemon that keeps firing.
897
- ...(this.opts.pauses ? ['agent-pause'] : []),
1344
+ // A deploy list holds pauses too (it answers the agent-pause-* messages
1345
+ // through the deploy core), so it earns the flag on its own.
1346
+ ...(this.opts.pauses || this.opts.crewDeploy ? ['agent-pause'] : []),
1347
+ // Whether this daemon keeps a deploy list (crew deploy per machine).
1348
+ // Only with a real port behind it, so a client offers Deploy and
1349
+ // Undeploy only where the daemon would record them -- anywhere else a
1350
+ // client shows the old read-only view.
1351
+ ...(this.opts.crewDeploy ? [CREW_DEPLOY_FEATURE] : []),
1352
+ // Whether this daemon keeps standing approvals (plan 3). Only with a real
1353
+ // service behind it, so a client shows the "This machine" group only
1354
+ // where there is something to list and revoke.
1355
+ ...(this.opts.approvals ? ['approvals'] : []),
1356
+ // Whether this daemon runs confirmed checks and holds the orchestrator's
1357
+ // workspace lock. Only with a real service behind it (--allow-shell) that
1358
+ // is not switched off right now (see SidecarChecks.unavailable).
1359
+ ...(this.checksRefusal() === null ? ['checks'] : []),
898
1360
  // Whether this daemon can report and reclaim its synced host directories.
899
1361
  // Advertised only with a real manifest behind it, so a client never
900
1362
  // offers a destructive control to a daemon that would refuse it.
@@ -917,11 +1379,20 @@ export class SidecarServer {
917
1379
  // why it must be true at the moment it is said, not merely true of how
918
1380
  // the daemon was built. See `clientKeyActive`.
919
1381
  ...(this.opts.clientKey && this.clientKeyIsActive() ? ['account-identity'] : []),
1382
+ // Whether this daemon can send web push. Advertised only with a real
1383
+ // notifier behind it, so a client offers "notify me" only where a
1384
+ // subscription would actually be reached.
1385
+ ...(this.opts.push ? ['web-push'] : []),
1386
+ // Whether this daemon verifies the browser's identity token. Advertised
1387
+ // only with a real verifier behind it; the verdict itself is `identity`.
1388
+ ...(this.opts.identity ? ['verified-identity'] : []),
920
1389
  ];
921
1390
  this.ch.send({
922
1391
  t: 'welcome', tools, features,
923
1392
  ...(this.opts.info ? { info: this.opts.info } : {}),
924
1393
  ...(dirName ? { dirName } : {}),
1394
+ ...(this.opts.push ? { vapidPublicKey: this.opts.push.publicKey } : {}),
1395
+ ...(this.opts.identity ? { identity: this.identityResult } : {}),
925
1396
  });
926
1397
  }
927
1398
  /** Proxy a host HTTP request server-side. Restricted to LOOPBACK targets — the
@@ -940,7 +1411,20 @@ export class SidecarServer {
940
1411
  throw new Error('http(s) URLs only');
941
1412
  if (!isLoopbackHost(u.hostname))
942
1413
  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 });
1414
+ // A NAME (only `localhost` passes the check above) is resolved and every
1415
+ // address re-checked, so a hosts-file or resolver entry cannot point it
1416
+ // off the machine. IP literals need no lookup.
1417
+ const bare = u.hostname.replace(/^\[/, '').replace(/\]$/, '');
1418
+ if (!isLoopbackAddress(bare) && this.opts.resolveHost) {
1419
+ const addrs = await this.opts.resolveHost(bare);
1420
+ if (addrs.length === 0 || addrs.some((a) => !isLoopbackAddress(a))) {
1421
+ throw new Error(`"${u.hostname}" resolves to a non-loopback address; the sidecar proxy only reaches loopback hosts`);
1422
+ }
1423
+ }
1424
+ // redirect:'manual' -- a loopback service with an open redirect must not
1425
+ // bounce this request to a metadata or LAN host. The 3xx (with its
1426
+ // Location) goes back to the caller, which can decide what to do.
1427
+ const res = await fetch(m.url, { method: m.method, headers: m.headers, body: m.body, redirect: 'manual' });
944
1428
  const headers = {};
945
1429
  res.headers.forEach((v, k) => { headers[k] = v; });
946
1430
  this.ch.send({ t: 'http-res', id: m.id, status: res.status, statusText: res.statusText, headers });
@@ -983,7 +1467,11 @@ export class SidecarServer {
983
1467
  * which URLs are refused all live on the daemon side, where the
984
1468
  * operator's declarations are. */
985
1469
  ...(m.repo ? { repo: m.repo } : {}),
986
- ...(m.owner ? { owner: m.owner } : {}),
1470
+ // From the AUTHENTICATED connection, never from this message: a
1471
+ // client naming an owner could label a session as someone
1472
+ // else's. `m.owner` is ignored; a connection that sent no
1473
+ // clientId files the session under nobody.
1474
+ ...(this.clientId ? { owner: this.clientId } : {}),
987
1475
  ...(m.fromSnapshot ? { fromSnapshot: m.fromSnapshot } : {}),
988
1476
  // From the HANDSHAKE, never from this message: the dot-dir is a
989
1477
  // property of the client's workspace, and taking it from the
@@ -1212,7 +1700,12 @@ export class SidecarServer {
1212
1700
  this.ch.send({ t: 'invoke-result', id: m.id, ok: true, result });
1213
1701
  }
1214
1702
  catch (e) {
1215
- this.opts.log?.(`invoke ${m.name} failed: ${errMessage(e)}`);
1703
+ // A read that finds nothing there (ENOENT) answered the question -- the
1704
+ // crew page probes every project for a crew folder most do not have.
1705
+ // Every other failure, and any from a tool that changes things, is logged.
1706
+ const notThere = tool.mutates !== true && e?.code === 'ENOENT';
1707
+ if (!notThere)
1708
+ this.opts.log?.(`invoke ${m.name} failed: ${errMessage(e)}`);
1216
1709
  this.ch.send({ t: 'invoke-result', id: m.id, ok: false, error: errMessage(e) });
1217
1710
  }
1218
1711
  }
@@ -1243,6 +1736,10 @@ export class SidecarServer {
1243
1736
  * user has -- the client does -- and the ACP client drops them if the
1244
1737
  * agent never advertised mcpCapabilities.http. */
1245
1738
  ...(m.mcpServers?.length ? { mcpServers: m.mcpServers } : {}),
1739
+ /* The connection's own filed key -- the same value it attached to the
1740
+ * registry under -- so the proxied agent's asks reach whoever started
1741
+ * it under `--mode team`, and not their teammates. Never from `m`. */
1742
+ ...(this.clientId ? { owner: this.clientId } : {}),
1246
1743
  });
1247
1744
  this.ch.send({ t: 'acp-started', id: m.id, ...started });
1248
1745
  }
@@ -1333,6 +1830,19 @@ export class SidecarServer {
1333
1830
  detachOnce();
1334
1831
  }
1335
1832
  }
1833
+ /** Stop a run, whoever started it. Same trust as `run`: a connection that may
1834
+ * start runs on this daemon may stop them. */
1835
+ onCancelRun(m) {
1836
+ const cancel = this.opts.runner?.cancel;
1837
+ if (!cancel) {
1838
+ this.ch.send({ t: 'cancel-run-result', id: m.id, runId: m.runId, ok: false, reason: 'unsupported' });
1839
+ return;
1840
+ }
1841
+ const out = cancel.call(this.opts.runner, m.runId);
1842
+ this.ch.send(out === 'cancelled'
1843
+ ? { t: 'cancel-run-result', id: m.id, runId: m.runId, ok: true }
1844
+ : { t: 'cancel-run-result', id: m.id, runId: m.runId, ok: false, reason: 'no-such-run' });
1845
+ }
1336
1846
  /**
1337
1847
  * What this caller's agent has actually done.
1338
1848
  *
@@ -1395,6 +1905,14 @@ export class SidecarServer {
1395
1905
  * says so rather than running the work in its own process anyway — silently
1396
1906
  * downgrading is the failure IsolationLevel exists to prevent, and it would
1397
1907
  * be invisible to the client that asked. */
1908
+ // A task is its own run with its own record and autonomy ceiling; the
1909
+ // session layer knows neither, so it is refused rather than dropped. So is
1910
+ // an autonomy on its own: the session path would ignore it, and a ceiling
1911
+ // the client asked for must never be silently dropped.
1912
+ if ((m.task || m.autonomy) && m.sessionId !== undefined) {
1913
+ this.ch.send({ t: 'run-error', id: m.id, error: 'A task starts its own run; it cannot run inside an existing session.' });
1914
+ return;
1915
+ }
1398
1916
  if (m.sessionId !== undefined) {
1399
1917
  const sessions = this.opts.sessions;
1400
1918
  if (!sessions?.run) {
@@ -1417,7 +1935,10 @@ export class SidecarServer {
1417
1935
  // would grant itself mutating tools inside a sandbox. Deliberately
1418
1936
  // absent -- see session/guestRunner.ts.
1419
1937
  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);
1938
+ : (agent, input, onEvent, identity) =>
1939
+ // Task and autonomy are forwarded as asked; the runner, not this
1940
+ // class, decides whether the agent may run that way.
1941
+ this.opts.runner.run(agent, input, onEvent, identity, undefined, undefined, { task: m.task, autonomy: m.autonomy });
1421
1942
  let runId;
1422
1943
  const pending = [];
1423
1944
  const onEvent = (event) => {
@@ -1573,6 +2094,17 @@ export class SidecarServer {
1573
2094
  }
1574
2095
  }
1575
2096
  onAgentPauseList(m) {
2097
+ const deploy = this.opts.crewDeploy;
2098
+ if (deploy) {
2099
+ // A pause is a deploy entry now: the list comes from the deploy list.
2100
+ try {
2101
+ this.ch.send({ t: 'agent-pause-list-result', id: m.id, ok: true, pauses: deploy.legacyPauses() });
2102
+ }
2103
+ catch (err) {
2104
+ this.ch.send({ t: 'agent-pause-list-result', id: m.id, ok: false, error: errMessage(err) });
2105
+ }
2106
+ return;
2107
+ }
1576
2108
  if (!this.opts.pauses) {
1577
2109
  // An honest empty answer for a daemon without the capability: it is
1578
2110
  // holding no pauses, which is true. The feature gate is what tells a
@@ -1588,7 +2120,7 @@ export class SidecarServer {
1588
2120
  }
1589
2121
  }
1590
2122
  async onAgentPauseSet(m) {
1591
- if (!this.opts.pauses) {
2123
+ if (!this.opts.pauses && !this.opts.crewDeploy) {
1592
2124
  // NOT an empty success. Accepting a pause this daemon cannot hold would
1593
2125
  // leave the operator believing an agent had been stopped while it kept
1594
2126
  // firing on the hour -- the exact failure the feature exists to end.
@@ -1602,6 +2134,23 @@ export class SidecarServer {
1602
2134
  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
2135
  return;
1604
2136
  }
2137
+ const deploy = this.opts.crewDeploy;
2138
+ if (deploy) {
2139
+ // An older app's pause goes through the deploy core, recorded as THIS
2140
+ // connection (the core adds who and why; an older app gives no reason).
2141
+ try {
2142
+ const out = await deploy.pauseByOwnerKey(this.deployCaller(), m.owner, m.agent, m.paused === true);
2143
+ this.ch.send(out.ok
2144
+ ? { t: 'agent-pause-set-result', id: m.id, ok: true, pauses: out.pauses }
2145
+ : { t: 'agent-pause-set-result', id: m.id, ok: false, error: out.error });
2146
+ }
2147
+ catch (err) {
2148
+ this.ch.send({ t: 'agent-pause-set-result', id: m.id, ok: false, error: errMessage(err) });
2149
+ }
2150
+ return;
2151
+ }
2152
+ if (!this.opts.pauses)
2153
+ return;
1605
2154
  try {
1606
2155
  const pauses = await this.opts.pauses.set(m.owner, m.agent, m.paused === true);
1607
2156
  // Republish before replying: the caller's own row and every other
@@ -1614,6 +2163,259 @@ export class SidecarServer {
1614
2163
  this.ch.send({ t: 'agent-pause-set-result', id: m.id, ok: false, error: errMessage(err) });
1615
2164
  }
1616
2165
  }
2166
+ /** The caller for a deploy action: THIS connection, never a message field.
2167
+ * `dirName` is the directory the daemon resolved for this connection and
2168
+ * `verifiedUserId` the id it proved at pairing; both are absent until (and
2169
+ * unless) the daemon established them, which only ever narrows what the
2170
+ * core permits. */
2171
+ deployCaller() {
2172
+ return {
2173
+ kind: 'client',
2174
+ name: this.clientName ?? this.clientId ?? 'unknown app',
2175
+ ...(this.dirName !== undefined ? { dirName: this.dirName } : {}),
2176
+ ...(this.verifiedUserId !== undefined ? { verifiedUserId: this.verifiedUserId } : {}),
2177
+ };
2178
+ }
2179
+ async onCrewDeploy(m) {
2180
+ const port = this.opts.crewDeploy;
2181
+ const answer = (reply) => this.ch.send({ t: 'crew-deploy-result', id: m.id, reply });
2182
+ if (!port) {
2183
+ answer({ ok: false, error: 'this daemon does not keep a deploy list' });
2184
+ return;
2185
+ }
2186
+ // Runtime-typed: the message arrived via JSON.parse(...) as SidecarMsg.
2187
+ // The ref goes on as `unknown` (the core validates it); a reason or run id
2188
+ // that is not a string is dropped, never coerced.
2189
+ const str = (v) => (typeof v === 'string' ? v : undefined);
2190
+ try {
2191
+ switch (m.t) {
2192
+ case 'crew-deployments':
2193
+ answer(await port.deployments());
2194
+ return;
2195
+ case 'crew-deploy':
2196
+ answer(await port.deploy(this.deployCaller(), m.ref, str(m.reason)));
2197
+ return;
2198
+ case 'crew-undeploy':
2199
+ answer(await port.undeploy(this.deployCaller(), m.ref, str(m.reason)));
2200
+ return;
2201
+ case 'crew-pause':
2202
+ answer(await port.pause(this.deployCaller(), m.ref, str(m.reason) ?? ''));
2203
+ return;
2204
+ case 'crew-resume':
2205
+ answer(await port.resume(this.deployCaller(), m.ref, str(m.reason)));
2206
+ return;
2207
+ case 'crew-stop':
2208
+ answer(await port.stop(this.deployCaller(), m.ref, str(m.runId) ?? '', str(m.reason) ?? ''));
2209
+ return;
2210
+ }
2211
+ }
2212
+ catch (err) {
2213
+ answer({ ok: false, error: errMessage(err) });
2214
+ }
2215
+ }
2216
+ async onApprovalsAccept(m) {
2217
+ const port = this.opts.approvals;
2218
+ if (!port) {
2219
+ this.ch.send({ t: 'approvals-accept-result', id: m.id, ok: false, error: 'this daemon does not keep standing approvals' });
2220
+ return;
2221
+ }
2222
+ // Runtime-validated: the message arrived via JSON.parse(...) as SidecarMsg.
2223
+ if (typeof m.suggestionId !== 'string' || !m.suggestionId || (m.choice !== 'global' && m.choice !== 'not-now' && m.choice !== 'never')) {
2224
+ 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' });
2225
+ return;
2226
+ }
2227
+ // A connection that never attached to attendance was never offered
2228
+ // anything, and has no identity to accept with.
2229
+ if (this.attended === undefined) {
2230
+ this.ch.send({ t: 'approvals-accept-result', id: m.id, ok: false, error: 'this connection was not offered that suggestion' });
2231
+ return;
2232
+ }
2233
+ try {
2234
+ // `this.attended` -- the object THIS connection attached -- is the only
2235
+ // accepter identity; the daemon compares it with the client it offered
2236
+ // to. Nothing in the message can name another connection.
2237
+ await port.accept(this.attended, m.suggestionId, m.choice);
2238
+ this.ch.send({ t: 'approvals-accept-result', id: m.id, ok: true });
2239
+ }
2240
+ catch (err) {
2241
+ this.ch.send({ t: 'approvals-accept-result', id: m.id, ok: false, error: errMessage(err) });
2242
+ }
2243
+ }
2244
+ async onApprovalsList(m) {
2245
+ const port = this.opts.approvals;
2246
+ if (!port) {
2247
+ this.ch.send({ t: 'approvals-list-result', id: m.id, ok: false, error: 'this daemon does not keep standing approvals' });
2248
+ return;
2249
+ }
2250
+ try {
2251
+ this.ch.send({ t: 'approvals-list-result', id: m.id, ok: true, state: await port.state() });
2252
+ }
2253
+ catch (err) {
2254
+ this.ch.send({ t: 'approvals-list-result', id: m.id, ok: false, error: errMessage(err) });
2255
+ }
2256
+ }
2257
+ async onApprovalsRevoke(m) {
2258
+ const port = this.opts.approvals;
2259
+ if (!port) {
2260
+ this.ch.send({ t: 'approvals-revoke-result', id: m.id, ok: false, error: 'this daemon does not keep standing approvals' });
2261
+ return;
2262
+ }
2263
+ if (typeof m.tool !== 'string' || !m.tool) {
2264
+ this.ch.send({ t: 'approvals-revoke-result', id: m.id, ok: false, error: 'tool must be a non-empty string' });
2265
+ return;
2266
+ }
2267
+ try {
2268
+ this.ch.send({ t: 'approvals-revoke-result', id: m.id, ok: true, state: await port.revoke(m.tool) });
2269
+ }
2270
+ catch (err) {
2271
+ this.ch.send({ t: 'approvals-revoke-result', id: m.id, ok: false, error: errMessage(err) });
2272
+ }
2273
+ }
2274
+ /** Why the checks port cannot answer right now, or null when it can. */
2275
+ checksRefusal() {
2276
+ if (!this.opts.checks)
2277
+ return NO_CHECKS;
2278
+ try {
2279
+ return this.opts.checks.unavailable?.() ?? null;
2280
+ }
2281
+ catch (err) {
2282
+ // A guard that cannot answer is a guard that says no.
2283
+ return errMessage(err);
2284
+ }
2285
+ }
2286
+ async onChecksGet(m) {
2287
+ const refusal = this.checksRefusal();
2288
+ if (refusal !== null || !this.opts.checks) {
2289
+ this.ch.send({ t: 'checks-get-result', id: m.id, ok: false, error: refusal ?? NO_CHECKS });
2290
+ return;
2291
+ }
2292
+ // Runtime-validate: the message arrived via `JSON.parse(...) as SidecarMsg`.
2293
+ if (typeof m.root !== 'string' || typeof m.stateDir !== 'string') {
2294
+ this.ch.send({ t: 'checks-get-result', id: m.id, ok: false, error: 'root and stateDir must be strings' });
2295
+ return;
2296
+ }
2297
+ try {
2298
+ this.ch.send({ t: 'checks-get-result', id: m.id, ok: true, state: await this.opts.checks.get(m.root, m.stateDir) });
2299
+ }
2300
+ catch (err) {
2301
+ this.ch.send({ t: 'checks-get-result', id: m.id, ok: false, error: errMessage(err) });
2302
+ }
2303
+ }
2304
+ async onChecksPin(m) {
2305
+ const refusal = this.checksRefusal();
2306
+ if (refusal !== null || !this.opts.checks) {
2307
+ this.ch.send({ t: 'checks-pin-result', id: m.id, ok: false, error: refusal ?? NO_CHECKS });
2308
+ return;
2309
+ }
2310
+ if (typeof m.root !== 'string' || typeof m.stateDir !== 'string' || typeof m.content !== 'string') {
2311
+ this.ch.send({ t: 'checks-pin-result', id: m.id, ok: false, error: 'root, stateDir and content must be strings' });
2312
+ return;
2313
+ }
2314
+ try {
2315
+ this.ch.send({ t: 'checks-pin-result', id: m.id, ok: true, state: await this.opts.checks.pin(m.root, m.stateDir, m.content) });
2316
+ }
2317
+ catch (err) {
2318
+ this.ch.send({ t: 'checks-pin-result', id: m.id, ok: false, error: errMessage(err) });
2319
+ }
2320
+ }
2321
+ async onWorkspaceRelease(m) {
2322
+ // Deliberately NOT gated on checksRefusal(): releasing only removes a run's
2323
+ // own lock, and a run that started before the port switched off must still
2324
+ // hand its lock back.
2325
+ if (!this.opts.checks) {
2326
+ this.ch.send({ t: 'workspace-release-result', id: m.id, ok: false, error: NO_CHECKS });
2327
+ return;
2328
+ }
2329
+ if (typeof m.root !== 'string' || typeof m.stateDir !== 'string' || typeof m.runId !== 'string') {
2330
+ this.ch.send({ t: 'workspace-release-result', id: m.id, ok: false, error: 'root, stateDir and runId must be strings' });
2331
+ return;
2332
+ }
2333
+ try {
2334
+ const released = await this.opts.checks.release({ root: m.root, stateDir: m.stateDir, runId: m.runId });
2335
+ this.ch.send({ t: 'workspace-release-result', id: m.id, ok: true, released });
2336
+ }
2337
+ catch (err) {
2338
+ this.ch.send({ t: 'workspace-release-result', id: m.id, ok: false, error: errMessage(err) });
2339
+ }
2340
+ }
2341
+ async onRunPlanGet(m) {
2342
+ const runner = this.opts.runner;
2343
+ if (!runner?.runPlan) {
2344
+ this.ch.send({ t: 'run-plan-get-result', id: m.id, ok: false, error: 'this daemon does not report run plans' });
2345
+ return;
2346
+ }
2347
+ // Runtime-validate: the message arrived via `JSON.parse(...) as SidecarMsg`.
2348
+ if (typeof m.runId !== 'string' || !m.runId) {
2349
+ this.ch.send({ t: 'run-plan-get-result', id: m.id, ok: false, error: 'runId must be a non-empty string' });
2350
+ return;
2351
+ }
2352
+ try {
2353
+ this.ch.send({ t: 'run-plan-get-result', id: m.id, ok: true, plan: await runner.runPlan(m.runId) });
2354
+ }
2355
+ catch (err) {
2356
+ this.ch.send({ t: 'run-plan-get-result', id: m.id, ok: false, error: errMessage(err) });
2357
+ }
2358
+ }
2359
+ /** The identity a push subscription/unsubscription is filed under -- ALWAYS
2360
+ * this connection's own authenticated `clientId`/`accounts`, never anything
2361
+ * the message itself carries. Auth-gated first, same as every other
2362
+ * handler; a connection with no `clientId` (an older client, or one that
2363
+ * never claimed an identity) is refused rather than filed under nothing. */
2364
+ pushIdentity() {
2365
+ if (!this.authed)
2366
+ return { error: 'not authenticated' };
2367
+ if (!this.clientId)
2368
+ return { error: 'this connection carries no client identity, so there is nobody to notify' };
2369
+ return {
2370
+ clientId: this.clientId,
2371
+ accounts: this.accounts,
2372
+ ...(this.verifiedUserId ? { verifiedUserId: this.verifiedUserId } : {}),
2373
+ };
2374
+ }
2375
+ async onPushSubscribe(m) {
2376
+ const who = this.pushIdentity();
2377
+ if ('error' in who) {
2378
+ this.ch.send({ t: 'push-result', id: m.id, ok: false, error: who.error });
2379
+ return;
2380
+ }
2381
+ if (!this.opts.push) {
2382
+ this.ch.send({ t: 'push-result', id: m.id, ok: false, error: 'this daemon does not send web push' });
2383
+ return;
2384
+ }
2385
+ if (!isWebPushSubscription(m.subscription)) {
2386
+ this.ch.send({ t: 'push-result', id: m.id, ok: false, error: 'malformed push subscription' });
2387
+ return;
2388
+ }
2389
+ try {
2390
+ await this.opts.push.subscribe(who, m.subscription);
2391
+ this.ch.send({ t: 'push-result', id: m.id, ok: true });
2392
+ }
2393
+ catch (err) {
2394
+ this.ch.send({ t: 'push-result', id: m.id, ok: false, error: errMessage(err) });
2395
+ }
2396
+ }
2397
+ async onPushUnsubscribe(m) {
2398
+ const who = this.pushIdentity();
2399
+ if ('error' in who) {
2400
+ this.ch.send({ t: 'push-result', id: m.id, ok: false, error: who.error });
2401
+ return;
2402
+ }
2403
+ if (!this.opts.push) {
2404
+ this.ch.send({ t: 'push-result', id: m.id, ok: false, error: 'this daemon does not send web push' });
2405
+ return;
2406
+ }
2407
+ if (typeof m.endpoint !== 'string') {
2408
+ this.ch.send({ t: 'push-result', id: m.id, ok: false, error: 'malformed endpoint' });
2409
+ return;
2410
+ }
2411
+ try {
2412
+ await this.opts.push.unsubscribe(who, m.endpoint);
2413
+ this.ch.send({ t: 'push-result', id: m.id, ok: true });
2414
+ }
2415
+ catch (err) {
2416
+ this.ch.send({ t: 'push-result', id: m.id, ok: false, error: errMessage(err) });
2417
+ }
2418
+ }
1617
2419
  async onRoomOpen(m) {
1618
2420
  if (!this.opts.rooms) {
1619
2421
  this.ch.send({ t: 'room-open-result', id: m.id, ok: false, problems: ['this sidecar does not host rooms'] });
@@ -1659,7 +2461,7 @@ export class SidecarServer {
1659
2461
  * unconditionally would make the opt-in flag not an opt-in -- see the
1660
2462
  * team-mode-off test in host.rooms.test.ts, which is the merge-safety
1661
2463
  * claim for this whole branch. */
1662
- if (this.opts.teamMode && this.clientId === undefined) {
2464
+ if (this.opts.teamMode && this.roomIdentity() === undefined) {
1663
2465
  this.ch.send({
1664
2466
  t: 'room-open-result',
1665
2467
  id: m.id,
@@ -1668,7 +2470,7 @@ export class SidecarServer {
1668
2470
  });
1669
2471
  return;
1670
2472
  }
1671
- const clientId = this.clientId;
2473
+ const clientId = this.roomIdentity();
1672
2474
  const owner = clientId === undefined
1673
2475
  ? undefined
1674
2476
  : m.req.participants.find((p) => p.kind === 'human' && p.role === 'owner');
@@ -1702,8 +2504,8 @@ export class SidecarServer {
1702
2504
  * returned, unnarrowed, for every existing room test and every daemon
1703
2505
  * nobody has reconfigured.
1704
2506
  *
1705
- * Team mode on: membership is judged against `this.clientId`, the
1706
- * connection's OWN proven identity, never a participant id read out of
2507
+ * Team mode on: membership is judged against `roomIdentity()`, the
2508
+ * connection's OWN proven identity (its verified user, else its clientId), never a participant id read out of
1707
2509
  * the request -- that is what `onRoomOpen` just finished making
1708
2510
  * trustworthy. A caller with NO clientId is not "an older client that
1709
2511
  * predates identity" here the way it is at `hello` -- membership
@@ -1713,9 +2515,9 @@ export class SidecarServer {
1713
2515
  * opt-out: connect without a clientId and see it all anyway. */
1714
2516
  const mine = !this.opts.teamMode
1715
2517
  ? rooms
1716
- : this.clientId === undefined
2518
+ : this.roomIdentity() === undefined
1717
2519
  ? []
1718
- : rooms.filter((r) => r.participants.some((p) => p.id === this.clientId));
2520
+ : rooms.filter((r) => r.participants.some((p) => p.id === this.roomIdentity()));
1719
2521
  this.ch.send({ t: 'room-list-result', id: m.id, ok: true, rooms: mine });
1720
2522
  }
1721
2523
  catch (err) {
@@ -1743,17 +2545,18 @@ export class SidecarServer {
1743
2545
  * `room: null`, which is the honest "no such room" answer and would be
1744
2546
  * a lie about a room that does in fact exist. */
1745
2547
  if (found && this.opts.teamMode) {
1746
- if (this.clientId === undefined) {
2548
+ const me = this.roomIdentity();
2549
+ if (me === undefined) {
1747
2550
  this.ch.send({
1748
2551
  t: 'room-read-result', id: m.id, ok: false,
1749
2552
  error: 'This connection has no verified identity, so membership in this room cannot be proven.',
1750
2553
  });
1751
2554
  return;
1752
2555
  }
1753
- if (!found.room.participants.some((p) => p.id === this.clientId)) {
2556
+ if (!found.room.participants.some((p) => p.id === me)) {
1754
2557
  this.ch.send({
1755
2558
  t: 'room-read-result', id: m.id, ok: false,
1756
- error: `"${this.clientId}" is not a member of room "${m.roomId}".`,
2559
+ error: `"${me}" is not a member of room "${m.roomId}".`,
1757
2560
  });
1758
2561
  return;
1759
2562
  }
@@ -1787,10 +2590,26 @@ export class SidecarServer {
1787
2590
  this.ch.send({ t: 'room-resume-result', id: m.id, ok: false, error: errMessage(err) });
1788
2591
  }
1789
2592
  }
2593
+ /** WHO THIS CONNECTION IS, INSIDE A ROOM.
2594
+ *
2595
+ * The user its pairing JWT proved (`verifiedUserId`: API-signed, aud = this
2596
+ * daemon, single-use nonce, allowlisted azp) whenever there is one -- the
2597
+ * same user id the browser seats a person under, sanitized by the same rule
2598
+ * (an OpenAI `name` field accepts only [A-Za-z0-9_-]; src/rooms/participantId.ts).
2599
+ * Keying on `clientId` instead -- the browser's random wire id -- refused
2600
+ * people their own rooms: the room seated `user_...`, the check compared
2601
+ * `1463897a-...`.
2602
+ *
2603
+ * No verified user (no verifier configured, an older client, a JWT that did
2604
+ * not verify): `clientId`, exactly as before. Never a claimed id. */
2605
+ roomIdentity() {
2606
+ const verified = this.verifiedUserId?.replace(/[^A-Za-z0-9_-]/g, '');
2607
+ return verified || this.clientId;
2608
+ }
1790
2609
  /* ONE identity rule for every room verb.
1791
2610
  *
1792
- * `room-read` judges membership on `this.clientId` -- the identity this
1793
- * connection PROVED at `hello`. The mutating verbs used to hand
2611
+ * `room-read` judges membership on `roomIdentity()` -- the user this
2612
+ * connection's pairing JWT proved, else the clientId it PROVED at `hello`. The mutating verbs used to hand
1794
2613
  * `m.participantId` straight off the wire to `requireRole`/`mayAct`, so one
1795
2614
  * branch enforced while another trusted the caller: a viewer could send the
1796
2615
  * owner's participantId and close the room. Under team mode every verb now
@@ -1810,10 +2629,11 @@ export class SidecarServer {
1810
2629
  actingId(claimed) {
1811
2630
  if (!this.opts.teamMode)
1812
2631
  return { ok: true, id: claimed };
1813
- if (this.clientId === undefined) {
2632
+ const me = this.roomIdentity();
2633
+ if (me === undefined) {
1814
2634
  return { ok: false, error: 'This connection has no verified identity (no clientId from hello), so it cannot act on a room.' };
1815
2635
  }
1816
- return { ok: true, id: this.clientId };
2636
+ return { ok: true, id: me };
1817
2637
  }
1818
2638
  async onRoomAnswer(m) {
1819
2639
  if (!this.opts.rooms) {