@intentic/sandbox-contract 1.176.2 → 1.209.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 (340) hide show
  1. package/README.md +65 -0
  2. package/dist/agent-catalog.d.ts +15 -2
  3. package/dist/agent-catalog.d.ts.map +1 -1
  4. package/dist/agent-catalog.js +50 -1
  5. package/dist/agent-catalog.js.map +1 -1
  6. package/dist/agent-run-model.d.ts +3 -0
  7. package/dist/agent-run-model.d.ts.map +1 -0
  8. package/dist/agent-run-model.js +10 -0
  9. package/dist/agent-run-model.js.map +1 -0
  10. package/dist/capability-env.d.ts +2 -0
  11. package/dist/capability-env.d.ts.map +1 -0
  12. package/dist/capability-env.js +2 -0
  13. package/dist/capability-env.js.map +1 -0
  14. package/dist/chores/chores.d.ts +9 -0
  15. package/dist/chores/chores.d.ts.map +1 -1
  16. package/dist/chores/chores.js +257 -18
  17. package/dist/chores/chores.js.map +1 -1
  18. package/dist/chores/digest.js +0 -0
  19. package/dist/chores/digest.js.map +1 -1
  20. package/dist/chores/extension-update.d.ts +9 -0
  21. package/dist/chores/extension-update.d.ts.map +1 -0
  22. package/dist/chores/extension-update.js +15 -0
  23. package/dist/chores/extension-update.js.map +1 -0
  24. package/dist/chores/fix-deps.d.ts +10 -0
  25. package/dist/chores/fix-deps.d.ts.map +1 -0
  26. package/dist/chores/fix-deps.js +18 -0
  27. package/dist/chores/fix-deps.js.map +1 -0
  28. package/dist/chores/index.d.ts +7 -2
  29. package/dist/chores/index.d.ts.map +1 -1
  30. package/dist/chores/index.js +4 -1
  31. package/dist/chores/index.js.map +1 -1
  32. package/dist/chores/probes.d.ts +1 -0
  33. package/dist/chores/probes.d.ts.map +1 -1
  34. package/dist/chores/probes.js +131 -3
  35. package/dist/chores/probes.js.map +1 -1
  36. package/dist/chores/stack.d.ts +27 -0
  37. package/dist/chores/stack.d.ts.map +1 -0
  38. package/dist/chores/stack.js +121 -0
  39. package/dist/chores/stack.js.map +1 -0
  40. package/dist/chores/verdict.d.ts +2 -1
  41. package/dist/chores/verdict.d.ts.map +1 -1
  42. package/dist/chores/verdict.js +57 -7
  43. package/dist/chores/verdict.js.map +1 -1
  44. package/dist/contract-lock.d.ts +3 -0
  45. package/dist/contract-lock.d.ts.map +1 -0
  46. package/dist/contract-lock.js +35 -0
  47. package/dist/contract-lock.js.map +1 -0
  48. package/dist/contracts/activity.contract.d.ts +10 -0
  49. package/dist/contracts/activity.contract.d.ts.map +1 -1
  50. package/dist/contracts/agent.contract.d.ts +230 -20
  51. package/dist/contracts/agent.contract.d.ts.map +1 -1
  52. package/dist/contracts/agent.contract.js +2 -1
  53. package/dist/contracts/agent.contract.js.map +1 -1
  54. package/dist/contracts/agents.contract.d.ts +847 -1
  55. package/dist/contracts/agents.contract.d.ts.map +1 -1
  56. package/dist/contracts/agents.contract.js +3 -1
  57. package/dist/contracts/agents.contract.js.map +1 -1
  58. package/dist/contracts/automations.contract.d.ts +89 -0
  59. package/dist/contracts/automations.contract.d.ts.map +1 -1
  60. package/dist/contracts/automations.contract.js +3 -1
  61. package/dist/contracts/automations.contract.js.map +1 -1
  62. package/dist/contracts/capabilities.contract.d.ts +108 -32
  63. package/dist/contracts/capabilities.contract.d.ts.map +1 -1
  64. package/dist/contracts/capabilities.contract.js +5 -1
  65. package/dist/contracts/capabilities.contract.js.map +1 -1
  66. package/dist/contracts/chores.contract.d.ts +43 -0
  67. package/dist/contracts/chores.contract.d.ts.map +1 -1
  68. package/dist/contracts/ci.contract.d.ts +5 -0
  69. package/dist/contracts/ci.contract.d.ts.map +1 -1
  70. package/dist/contracts/ci.contract.js +2 -2
  71. package/dist/contracts/ci.contract.js.map +1 -1
  72. package/dist/contracts/claude.contract.d.ts +3 -14
  73. package/dist/contracts/claude.contract.d.ts.map +1 -1
  74. package/dist/contracts/claude.contract.js +2 -3
  75. package/dist/contracts/claude.contract.js.map +1 -1
  76. package/dist/contracts/drafts.contract.d.ts +4 -0
  77. package/dist/contracts/drafts.contract.d.ts.map +1 -1
  78. package/dist/contracts/endpoints.contract.d.ts +28 -12
  79. package/dist/contracts/endpoints.contract.d.ts.map +1 -1
  80. package/dist/contracts/endpoints.contract.js +9 -0
  81. package/dist/contracts/endpoints.contract.js.map +1 -1
  82. package/dist/contracts/extensions.contract.d.ts +399 -69
  83. package/dist/contracts/extensions.contract.d.ts.map +1 -1
  84. package/dist/contracts/extensions.contract.js +15 -1
  85. package/dist/contracts/extensions.contract.js.map +1 -1
  86. package/dist/contracts/git.contract.d.ts +209 -11
  87. package/dist/contracts/git.contract.d.ts.map +1 -1
  88. package/dist/contracts/git.contract.js +12 -2
  89. package/dist/contracts/git.contract.js.map +1 -1
  90. package/dist/contracts/grok.contract.d.ts +0 -13
  91. package/dist/contracts/grok.contract.d.ts.map +1 -1
  92. package/dist/contracts/grok.contract.js +1 -2
  93. package/dist/contracts/grok.contract.js.map +1 -1
  94. package/dist/contracts/host.contract.d.ts +39 -0
  95. package/dist/contracts/host.contract.d.ts.map +1 -1
  96. package/dist/contracts/host.contract.js +3 -2
  97. package/dist/contracts/host.contract.js.map +1 -1
  98. package/dist/contracts/loops.contract.d.ts +343 -0
  99. package/dist/contracts/loops.contract.d.ts.map +1 -0
  100. package/dist/contracts/loops.contract.js +11 -0
  101. package/dist/contracts/loops.contract.js.map +1 -0
  102. package/dist/contracts/panels.contract.d.ts +6 -0
  103. package/dist/contracts/panels.contract.d.ts.map +1 -1
  104. package/dist/contracts/personas.contract.d.ts +112 -0
  105. package/dist/contracts/personas.contract.d.ts.map +1 -0
  106. package/dist/contracts/personas.contract.js +13 -0
  107. package/dist/contracts/personas.contract.js.map +1 -0
  108. package/dist/contracts/ports.contract.d.ts +1 -0
  109. package/dist/contracts/ports.contract.d.ts.map +1 -1
  110. package/dist/contracts/prepush.contract.d.ts +1 -0
  111. package/dist/contracts/prepush.contract.d.ts.map +1 -1
  112. package/dist/contracts/prepush.contract.js.map +1 -1
  113. package/dist/contracts/{codex.contract.d.ts → providers.contract.d.ts} +11 -3
  114. package/dist/contracts/providers.contract.d.ts.map +1 -0
  115. package/dist/contracts/providers.contract.js +6 -0
  116. package/dist/contracts/providers.contract.js.map +1 -0
  117. package/dist/contracts/public.contract.d.ts +24 -0
  118. package/dist/contracts/public.contract.d.ts.map +1 -0
  119. package/dist/contracts/public.contract.js +8 -0
  120. package/dist/contracts/public.contract.js.map +1 -0
  121. package/dist/contracts/push.contract.d.ts +3 -3
  122. package/dist/contracts/secrets.contract.d.ts +9 -0
  123. package/dist/contracts/secrets.contract.d.ts.map +1 -1
  124. package/dist/contracts/sessions.contract.d.ts +16 -1
  125. package/dist/contracts/sessions.contract.d.ts.map +1 -1
  126. package/dist/contracts/sessions.contract.js +1 -1
  127. package/dist/contracts/sessions.contract.js.map +1 -1
  128. package/dist/contracts/settings.contract.d.ts +352 -58
  129. package/dist/contracts/settings.contract.d.ts.map +1 -1
  130. package/dist/contracts/settings.contract.js +2 -1
  131. package/dist/contracts/settings.contract.js.map +1 -1
  132. package/dist/contracts/share.contract.d.ts +55 -0
  133. package/dist/contracts/share.contract.d.ts.map +1 -0
  134. package/dist/contracts/share.contract.js +9 -0
  135. package/dist/contracts/share.contract.js.map +1 -0
  136. package/dist/contracts/skills.contract.d.ts +41 -0
  137. package/dist/contracts/skills.contract.d.ts.map +1 -0
  138. package/dist/contracts/skills.contract.js +9 -0
  139. package/dist/contracts/skills.contract.js.map +1 -0
  140. package/dist/contracts/system.contract.d.ts +218 -22
  141. package/dist/contracts/system.contract.d.ts.map +1 -1
  142. package/dist/contracts/system.contract.js +10 -2
  143. package/dist/contracts/system.contract.js.map +1 -1
  144. package/dist/contracts/workflows.contract.d.ts +575 -0
  145. package/dist/contracts/workflows.contract.d.ts.map +1 -0
  146. package/dist/contracts/workflows.contract.js +13 -0
  147. package/dist/contracts/workflows.contract.js.map +1 -0
  148. package/dist/contracts/workspace.contract.d.ts +18 -4
  149. package/dist/contracts/workspace.contract.d.ts.map +1 -1
  150. package/dist/contracts/workspace.contract.js +6 -3
  151. package/dist/contracts/workspace.contract.js.map +1 -1
  152. package/dist/conversation-ids.d.ts +2 -0
  153. package/dist/conversation-ids.d.ts.map +1 -0
  154. package/dist/conversation-ids.js +113 -0
  155. package/dist/conversation-ids.js.map +1 -0
  156. package/dist/events.d.ts +523 -17
  157. package/dist/events.d.ts.map +1 -1
  158. package/dist/events.js +106 -13
  159. package/dist/events.js.map +1 -1
  160. package/dist/history-state.d.ts +3 -0
  161. package/dist/history-state.d.ts.map +1 -0
  162. package/dist/history-state.js +53 -0
  163. package/dist/history-state.js.map +1 -0
  164. package/dist/hostnames.d.ts +4 -0
  165. package/dist/hostnames.d.ts.map +1 -1
  166. package/dist/hostnames.js +7 -2
  167. package/dist/hostnames.js.map +1 -1
  168. package/dist/index.d.ts +3793 -478
  169. package/dist/index.d.ts.map +1 -1
  170. package/dist/index.js +41 -17
  171. package/dist/index.js.map +1 -1
  172. package/dist/listener-protocol.d.ts +77 -0
  173. package/dist/listener-protocol.d.ts.map +1 -0
  174. package/dist/listener-protocol.js +33 -0
  175. package/dist/listener-protocol.js.map +1 -0
  176. package/dist/model-order.d.ts +1 -0
  177. package/dist/model-order.d.ts.map +1 -1
  178. package/dist/model-order.js +16 -0
  179. package/dist/model-order.js.map +1 -1
  180. package/dist/output-fields.d.ts +30 -0
  181. package/dist/output-fields.d.ts.map +1 -0
  182. package/dist/output-fields.js +57 -0
  183. package/dist/output-fields.js.map +1 -0
  184. package/dist/path-refs.d.ts.map +1 -1
  185. package/dist/path-refs.js.map +1 -1
  186. package/dist/publish-drafts.d.ts +7 -0
  187. package/dist/publish-drafts.d.ts.map +1 -0
  188. package/dist/publish-drafts.js +20 -0
  189. package/dist/publish-drafts.js.map +1 -0
  190. package/dist/quick-model.d.ts +2 -1
  191. package/dist/quick-model.d.ts.map +1 -1
  192. package/dist/quick-model.js +18 -14
  193. package/dist/quick-model.js.map +1 -1
  194. package/dist/routes.d.ts +3 -0
  195. package/dist/routes.d.ts.map +1 -1
  196. package/dist/routes.js +63 -0
  197. package/dist/routes.js.map +1 -1
  198. package/dist/runtime-state.d.ts +33 -0
  199. package/dist/runtime-state.d.ts.map +1 -0
  200. package/dist/runtime-state.js +14 -0
  201. package/dist/runtime-state.js.map +1 -0
  202. package/dist/schemas.d.ts +4938 -866
  203. package/dist/schemas.d.ts.map +1 -1
  204. package/dist/schemas.js +1011 -192
  205. package/dist/schemas.js.map +1 -1
  206. package/dist/search-globs.d.ts +6 -0
  207. package/dist/search-globs.d.ts.map +1 -0
  208. package/dist/search-globs.js +39 -0
  209. package/dist/search-globs.js.map +1 -0
  210. package/dist/share-paths.d.ts +8 -0
  211. package/dist/share-paths.d.ts.map +1 -0
  212. package/dist/share-paths.js +21 -0
  213. package/dist/share-paths.js.map +1 -0
  214. package/dist/state-portability.d.ts +8 -0
  215. package/dist/state-portability.d.ts.map +1 -0
  216. package/dist/state-portability.js +4 -0
  217. package/dist/state-portability.js.map +1 -0
  218. package/dist/title.d.ts.map +1 -1
  219. package/dist/title.js +1 -1
  220. package/dist/title.js.map +1 -1
  221. package/dist/tunnel-ids.d.ts +1 -0
  222. package/dist/tunnel-ids.d.ts.map +1 -1
  223. package/dist/tunnel-ids.js +1 -0
  224. package/dist/tunnel-ids.js.map +1 -1
  225. package/dist/versions.d.ts +4 -0
  226. package/dist/versions.d.ts.map +1 -0
  227. package/dist/versions.js +15 -0
  228. package/dist/versions.js.map +1 -0
  229. package/dist/workflow-faults.d.ts +4 -0
  230. package/dist/workflow-faults.d.ts.map +1 -0
  231. package/dist/workflow-faults.js +115 -0
  232. package/dist/workflow-faults.js.map +1 -0
  233. package/dist/workspace-state.d.ts +282 -3
  234. package/dist/workspace-state.d.ts.map +1 -1
  235. package/dist/workspace-state.js +198 -12
  236. package/dist/workspace-state.js.map +1 -1
  237. package/package.json +30 -6
  238. package/src/agent-catalog.test.ts +147 -7
  239. package/src/agent-catalog.ts +233 -21
  240. package/src/agent-run-model.test.ts +60 -0
  241. package/src/agent-run-model.ts +51 -0
  242. package/src/capability-env.ts +10 -0
  243. package/src/capability-ledger.test.ts +110 -0
  244. package/src/chores/chores.test.ts +32 -0
  245. package/src/chores/chores.ts +456 -32
  246. package/src/chores/digest.ts +0 -0
  247. package/src/chores/extension-update.ts +37 -0
  248. package/src/chores/fix-deps.ts +40 -0
  249. package/src/chores/index.ts +7 -2
  250. package/src/chores/probes.test.ts +155 -3
  251. package/src/chores/probes.ts +209 -4
  252. package/src/chores/stack.test.ts +126 -0
  253. package/src/chores/stack.ts +244 -0
  254. package/src/chores/verdict.test.ts +357 -19
  255. package/src/chores/verdict.ts +102 -22
  256. package/src/contract-lock.test.ts +17 -0
  257. package/src/contract-lock.ts +66 -0
  258. package/src/contracts/agent.contract.ts +6 -0
  259. package/src/contracts/agents.contract.ts +12 -0
  260. package/src/contracts/automations.contract.ts +17 -1
  261. package/src/contracts/capabilities.contract.ts +22 -0
  262. package/src/contracts/ci.contract.ts +10 -2
  263. package/src/contracts/claude.contract.ts +8 -4
  264. package/src/contracts/endpoints.contract.ts +21 -0
  265. package/src/contracts/extensions.contract.ts +42 -0
  266. package/src/contracts/git.contract.ts +33 -6
  267. package/src/contracts/grok.contract.ts +4 -3
  268. package/src/contracts/host.contract.ts +13 -2
  269. package/src/contracts/loops.contract.ts +62 -0
  270. package/src/contracts/personas.contract.ts +56 -0
  271. package/src/contracts/prepush.contract.ts +4 -3
  272. package/src/contracts/providers.contract.ts +23 -0
  273. package/src/contracts/public.contract.ts +14 -0
  274. package/src/contracts/sessions.contract.ts +5 -1
  275. package/src/contracts/settings.contract.ts +5 -0
  276. package/src/contracts/share.contract.ts +20 -0
  277. package/src/contracts/skills.contract.ts +24 -0
  278. package/src/contracts/system.contract.ts +27 -3
  279. package/src/contracts/workflows.contract.ts +78 -0
  280. package/src/contracts/workspace.contract.ts +10 -2
  281. package/src/conversation-ids.test.ts +25 -0
  282. package/src/conversation-ids.ts +160 -0
  283. package/src/events.test.ts +20 -1
  284. package/src/events.ts +404 -43
  285. package/src/history-state.ts +129 -0
  286. package/src/hostnames.ts +20 -6
  287. package/src/index.ts +60 -17
  288. package/src/listener-protocol.ts +96 -0
  289. package/src/model-order.test.ts +32 -1
  290. package/src/model-order.ts +56 -3
  291. package/src/output-fields.ts +111 -0
  292. package/src/path-refs.test.ts +4 -4
  293. package/src/path-refs.ts +5 -5
  294. package/src/publish-drafts.ts +59 -0
  295. package/src/quick-model.test.ts +70 -27
  296. package/src/quick-model.ts +76 -40
  297. package/src/routes.test.ts +91 -3
  298. package/src/routes.ts +135 -0
  299. package/src/runtime-state.test.ts +45 -0
  300. package/src/runtime-state.ts +124 -0
  301. package/src/schemas.test.ts +16 -29
  302. package/src/schemas.ts +3526 -553
  303. package/src/search-globs.test.ts +46 -0
  304. package/src/search-globs.ts +76 -0
  305. package/src/share-paths.test.ts +48 -0
  306. package/src/share-paths.ts +68 -0
  307. package/src/state-portability.ts +56 -0
  308. package/src/title.test.ts +20 -1
  309. package/src/title.ts +12 -3
  310. package/src/tunnel-ids.ts +15 -1
  311. package/src/versions.test.ts +39 -0
  312. package/src/versions.ts +48 -0
  313. package/src/workflow-faults.test.ts +127 -0
  314. package/src/workflow-faults.ts +188 -0
  315. package/src/workspace-state.test.ts +189 -17
  316. package/src/workspace-state.ts +500 -30
  317. package/dist/contracts/codex.contract.d.ts.map +0 -1
  318. package/dist/contracts/codex.contract.js +0 -6
  319. package/dist/contracts/codex.contract.js.map +0 -1
  320. package/dist/contracts/gemini.contract.d.ts +0 -16
  321. package/dist/contracts/gemini.contract.d.ts.map +0 -1
  322. package/dist/contracts/gemini.contract.js +0 -6
  323. package/dist/contracts/gemini.contract.js.map +0 -1
  324. package/dist/contracts/kimi.contract.d.ts +0 -16
  325. package/dist/contracts/kimi.contract.d.ts.map +0 -1
  326. package/dist/contracts/kimi.contract.js +0 -6
  327. package/dist/contracts/kimi.contract.js.map +0 -1
  328. package/dist/contracts/komodo.contract.d.ts +0 -125
  329. package/dist/contracts/komodo.contract.d.ts.map +0 -1
  330. package/dist/contracts/komodo.contract.js +0 -14
  331. package/dist/contracts/komodo.contract.js.map +0 -1
  332. package/dist/contracts/memory.contract.d.ts +0 -34
  333. package/dist/contracts/memory.contract.d.ts.map +0 -1
  334. package/dist/contracts/memory.contract.js +0 -9
  335. package/dist/contracts/memory.contract.js.map +0 -1
  336. package/src/contracts/codex.contract.ts +0 -10
  337. package/src/contracts/gemini.contract.ts +0 -11
  338. package/src/contracts/kimi.contract.ts +0 -9
  339. package/src/contracts/komodo.contract.ts +0 -36
  340. package/src/contracts/memory.contract.ts +0 -13
@@ -1,7 +1,8 @@
1
- import { oc } from "@orpc/contract";
1
+ import { eventIterator, oc } from "@orpc/contract";
2
2
  import { describe, expect, it } from "vitest";
3
- import { SANDBOX_ROUTE_NAMES, SANDBOX_ROUTES, sandboxRouteName } from "./index.js";
4
- import { contractRoutes, routeNameForRequest } from "./routes.js";
3
+ import { z } from "zod";
4
+ import { SANDBOX_ROUTE_NAMES, SANDBOX_ROUTE_SHAPES, SANDBOX_ROUTES, sandboxRouteName } from "./index.js";
5
+ import { contractRoutes, routeNameForRequest, routeShapes } from "./routes.js";
5
6
 
6
7
  const fixture = {
7
8
  vpn: {
@@ -13,6 +14,9 @@ const fixture = {
13
14
  },
14
15
  };
15
16
 
17
+ // A contract whose one route carries real schemas, so a shape can actually change between "builds".
18
+ const shaped = (output: z.ZodType) => ({ vpn: { list: oc.route({ method: "GET", path: "/vpn" }).output(output) } });
19
+
16
20
  describe(`contractRoutes`, () => {
17
21
  it(`names every procedure <group>.<route>, sorted`, () => {
18
22
  expect(contractRoutes(fixture).map((route) => route.name)).toEqual([`system.killTerminal`, `vpn.connect`, `vpn.list`]);
@@ -65,7 +69,91 @@ describe(`routeNameForRequest`, () => {
65
69
  });
66
70
  });
67
71
 
72
+ describe(`routeShapes`, () => {
73
+ it(`gives the same fingerprint for the same shape, twice`, () => {
74
+ expect(routeShapes(shaped(z.object({ a: z.string() })))).toEqual(routeShapes(shaped(z.object({ a: z.string() }))));
75
+ });
76
+
77
+ it(`changes the fingerprint when a field is added`, () => {
78
+ const before = routeShapes(shaped(z.object({ a: z.string() })))[`vpn.list`];
79
+ const after = routeShapes(shaped(z.object({ a: z.string(), b: z.number() })))[`vpn.list`];
80
+ expect(before).toBeDefined();
81
+ expect(after).not.toBe(before);
82
+ });
83
+
84
+ it(`changes the fingerprint when a field's type changes`, () => {
85
+ const before = routeShapes(shaped(z.object({ a: z.string() })))[`vpn.list`];
86
+ const after = routeShapes(shaped(z.object({ a: z.number() })))[`vpn.list`];
87
+ expect(after).not.toBe(before);
88
+ });
89
+
90
+ it(`is blind to the order fields are declared in — a reordered object is not a wire change`, () => {
91
+ const one = routeShapes(shaped(z.object({ a: z.string(), b: z.number() })))[`vpn.list`];
92
+ const other = routeShapes(shaped(z.object({ b: z.number(), a: z.string() })))[`vpn.list`];
93
+ expect(other).toBe(one);
94
+ });
95
+
96
+ it(`reads a defaulted field differently on the way in than on the way out`, () => {
97
+ // `.default()` makes a field optional going in and required coming out — the same declaration, two
98
+ // wire shapes. Reading both directions the same way would call them identical.
99
+ const one = z.object({ a: z.string().default(`x`) });
100
+ const asOutput = routeShapes(shaped(one))[`vpn.list`];
101
+ const asInput = routeShapes({ vpn: { list: oc.route({ method: "GET", path: "/vpn" }).input(one) } })[`vpn.list`];
102
+ expect(asOutput).toBeDefined();
103
+ expect(asInput).not.toBe(asOutput);
104
+ });
105
+
106
+ it(`treats a route declaring no schemas as a shape of its own`, () => {
107
+ // Not a special case: "this route carries nothing" is a real shape, and a build that later gives it an
108
+ // output has genuinely changed it. All three fixture routes declare nothing, so all three agree.
109
+ const shapes = routeShapes(fixture);
110
+ expect(Object.keys(shapes).toSorted()).toEqual([`system.killTerminal`, `vpn.connect`, `vpn.list`]);
111
+ expect(new Set(Object.values(shapes)).size).toBe(1);
112
+ expect(routeShapes(shaped(z.object({ a: z.string() })))[`vpn.list`]).not.toBe(shapes[`vpn.list`]);
113
+ });
114
+
115
+ it(`omits a route whose shape cannot be expressed rather than failing the walk`, () => {
116
+ // An oRPC event iterator wraps its output in an opaque type with no schema underneath. The route keeps
117
+ // its NAME (it is still advertised as existing) and simply carries no shape — which reads downstream as
118
+ // "assume compatible". The two neighbours still get fingerprinted, which is the part that matters: one
119
+ // unexpressable route must not cost the whole walk.
120
+ const withStream = {
121
+ vpn: {
122
+ list: fixture.vpn.list,
123
+ watch: oc.route({ method: "GET", path: "/vpn/watch" }).output(eventIterator(z.object({ a: z.string() }))),
124
+ },
125
+ };
126
+ expect(Object.keys(routeShapes(withStream)).toSorted()).toEqual([`vpn.list`]);
127
+ expect(
128
+ contractRoutes(withStream)
129
+ .map((route) => route.name)
130
+ .toSorted(),
131
+ ).toEqual([`vpn.list`, `vpn.watch`]);
132
+ });
133
+ });
134
+
68
135
  describe(`the real sandbox contract`, () => {
136
+ it(`fingerprints all but the streaming routes`, () => {
137
+ const unshaped = SANDBOX_ROUTE_NAMES.filter((name) => !(name in SANDBOX_ROUTE_SHAPES));
138
+ // oRPC wraps an event iterator's output in an opaque type with no schema under it, so these seven
139
+ // cannot be fingerprinted and are assumed compatible. Named rather than counted: a NEW entry here is
140
+ // a route that quietly lost its shape check, which is worth failing a test over.
141
+ expect(unshaped.toSorted()).toEqual([
142
+ `agent.attach`,
143
+ `capabilities.add`,
144
+ `intentic.applyEvents`,
145
+ `intentic.run`,
146
+ `system.events`,
147
+ `system.manageMachineSandbox`,
148
+ `vpn.connect`,
149
+ ]);
150
+ });
151
+
152
+ it(`fingerprints every other route exactly once`, () => {
153
+ expect(Object.keys(SANDBOX_ROUTE_SHAPES).every((name) => SANDBOX_ROUTE_NAMES.includes(name))).toBe(true);
154
+ expect(Object.keys(SANDBOX_ROUTE_SHAPES).length).toBe(SANDBOX_ROUTE_NAMES.length - 7);
155
+ });
156
+
69
157
  it(`derives a route table with no duplicate names`, () => {
70
158
  expect(SANDBOX_ROUTE_NAMES.length).toBe(SANDBOX_ROUTES.length);
71
159
  expect(new Set(SANDBOX_ROUTE_NAMES).size).toBe(SANDBOX_ROUTE_NAMES.length);
package/src/routes.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { z } from "zod";
2
+
1
3
  /* The daemon's route surface, named. A sandbox daemon is baked into an image, so the browser talking to it is
2
4
  * routinely NEWER than the daemon: a released app plane serves every user's sandbox, whatever image they last
3
5
  * pulled, and in local development the web app is always ahead of the last `pnpm build:sandbox`. Both are
@@ -63,6 +65,114 @@ export const contractRoutes = (contract: Record<string, unknown>): ContractRoute
63
65
  return routes.toSorted((a, b) => a.name.localeCompare(b.name));
64
66
  };
65
67
 
68
+ /* ---- the SHAPE of a route, not just its name ----
69
+ *
70
+ * The route list above answers "does this daemon have it". It cannot answer the other half: a route that
71
+ * exists on BOTH sides but whose payload changed between the two builds. Names match, so the call goes out,
72
+ * and a field the browser expects is simply missing from the answer — no 404, no message, nothing to attribute
73
+ * it to. That is the one skew failure the named-gap mechanism still leaves silent.
74
+ *
75
+ * So each route also advertises a fingerprint of its wire shape: input and output schema together, reduced to
76
+ * one short string. Same string ⇒ the two builds agree, which is the overwhelmingly common case and costs a
77
+ * comparison. Different ⇒ a NAMED route whose payload moved, which the UI can explain in exactly the words it
78
+ * already uses for a missing one.
79
+ *
80
+ * NOTHING IS HAND-MAINTAINED, which is the whole reason this is a fingerprint and not a version number. The
81
+ * codebase already rejected the hand-bumped alternative once (see the web app's buildEpoch.ts: a SCHEMA_VERSION
82
+ * "hangs on someone remembering"). Changing a field in a contract schema changes that route's fingerprint by
83
+ * itself, on both sides, with nobody to remember anything. */
84
+
85
+ // The schemas oRPC hangs off a contract procedure, read structurally for the same reason as `route` above —
86
+ // they are internal metadata, not part of its public surface.
87
+ interface ContractSchemasLike {
88
+ readonly "~orpc": { readonly inputSchema?: unknown; readonly outputSchema?: unknown };
89
+ }
90
+
91
+ /* The JSON Schema keywords whose array value is a SET — order carries no meaning, so two schemas that differ
92
+ * only in it are the same wire shape. `required` is the one that matters in practice: JSON Schema spells an
93
+ * object's fields twice, once as `properties` (an object, so key-sorting settles it) and once as `required`
94
+ * (an array, in declaration order) — so without this, moving a field up a `z.object` for readability reads as
95
+ * drift on every route that carries it. The union keywords are here for the same reason a step lower down. */
96
+ const UNORDERED = new Set(["required", "enum", "anyOf", "oneOf", "allOf"]);
97
+
98
+ /* Canonical JSON: object keys sorted, set-valued arrays sorted, everything else left in the order it was
99
+ * written. So the fingerprint depends on what the schema SAYS rather than how zod happened to emit it — which
100
+ * is what keeps a purely cosmetic edit from being reported to a user as a payload that moved.
101
+ *
102
+ * Ordered arrays stay ordered: a tuple's `prefixItems` is positional, and sorting it would call two genuinely
103
+ * different shapes identical — the failure that actually costs something here. */
104
+ const canonical = (value: unknown, key?: string): unknown => {
105
+ if (Array.isArray(value)) {
106
+ const items = value.map((item) => canonical(item));
107
+ return key !== undefined && UNORDERED.has(key) ? items.toSorted((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))) : items;
108
+ }
109
+ if (typeof value !== "object" || value === null) {
110
+ return value;
111
+ }
112
+ const entries = Object.entries(value as Record<string, unknown>).toSorted(([a], [b]) => a.localeCompare(b));
113
+ return entries.map(([name, nested]) => [name, canonical(nested, name)]);
114
+ };
115
+
116
+ /* FNV-1a over the canonical JSON, base36. Deliberately not a cryptographic hash: this is a drift SIGNAL, and
117
+ * the cost of a collision is one warning that never appears — never a wrong action, never a blocked call. A
118
+ * crypto digest would mean a subtle-crypto round trip in the browser (async, and unavailable on insecure
119
+ * origins) to buy nothing this use has any need of. */
120
+ const fingerprint = (value: unknown): string => {
121
+ const text = JSON.stringify(canonical(value));
122
+ let hash = 0x811c9dc5;
123
+ for (let i = 0; i < text.length; i++) {
124
+ hash ^= text.charCodeAt(i);
125
+ hash = Math.imul(hash, 0x01000193) >>> 0;
126
+ }
127
+ return hash.toString(36);
128
+ };
129
+
130
+ /* One route's wire shape, or undefined when it cannot be expressed.
131
+ *
132
+ * `io` matters and is not cosmetic: a field with `.default()` is OPTIONAL on the way in and REQUIRED on the
133
+ * way out, so reading both sides in the same direction would call two genuinely different shapes identical.
134
+ *
135
+ * A schema that will not convert yields undefined rather than throwing the walk away. Every such route in the
136
+ * contract today is a streaming one — oRPC wraps an event iterator's output in an opaque type with no schema
137
+ * underneath it — and their absence lands them in exactly the right place: a route with no advertised shape is
138
+ * assumed compatible, which is the same "positive evidence only" rule the missing-route check already runs on. */
139
+ const procedureShape = (value: unknown): string | undefined => {
140
+ if (typeof value !== "object" || value === null || !("~orpc" in value)) {
141
+ return undefined;
142
+ }
143
+ const { inputSchema, outputSchema } = (value as ContractSchemasLike)["~orpc"];
144
+ try {
145
+ return fingerprint({
146
+ in: inputSchema === undefined ? undefined : z.toJSONSchema(inputSchema as z.ZodType, { io: "input" }),
147
+ out: outputSchema === undefined ? undefined : z.toJSONSchema(outputSchema as z.ZodType, { io: "output" }),
148
+ });
149
+ } catch {
150
+ return undefined;
151
+ }
152
+ };
153
+
154
+ // Every route whose shape THIS build can express, as name → fingerprint. Same walk as contractRoutes, and
155
+ // deliberately a separate map rather than a field on ContractRoute: the two are advertised independently
156
+ // (existence covers every route, shape covers the ones that can be expressed) and read by different checks.
157
+ export const routeShapes = (contract: Record<string, unknown>): Record<string, string> => {
158
+ const shapes: Record<string, string> = {};
159
+ for (const [group, procedures] of Object.entries(contract)) {
160
+ if (typeof procedures !== "object" || procedures === null) {
161
+ continue;
162
+ }
163
+ for (const [name, procedure] of Object.entries(procedures as Record<string, unknown>)) {
164
+ if (procedureRoute(procedure) === undefined) {
165
+ continue;
166
+ }
167
+ const shape = procedureShape(procedure);
168
+ if (shape !== undefined) {
169
+ shapes[`${group}.${name}`] = shape;
170
+ }
171
+ }
172
+ }
173
+ return shapes;
174
+ };
175
+
66
176
  // Does a concrete request path match this route's template? Segment-wise, with `{param}` matching exactly one
67
177
  // segment — the same shape oRPC mounts, so a template can never match a longer or shorter path.
68
178
  const pathMatches = (template: string, path: string): boolean => {
@@ -82,3 +192,28 @@ export const routeNameForRequest = (routes: readonly ContractRoute[], method: st
82
192
  const upper = method.toUpperCase();
83
193
  return routes.find((route) => route.method.toUpperCase() === upper && pathMatches(route.path, path))?.name;
84
194
  };
195
+
196
+ // The route a typed client call belongs to. oRPC addresses a procedure by its position in the contract —
197
+ // `['git','stashApply']` — which is the contract's own name for it, so this is a lookup rather than a match.
198
+ export const routeForProcedure = (routes: readonly ContractRoute[], procedure: readonly string[]): ContractRoute | undefined =>
199
+ routes.find((route) => route.name === procedure.join("."));
200
+
201
+ // THE CONCRETE PATH A TYPED CALL WILL CARRY — the route template with every `{param}` replaced by the input
202
+ // field of the same name, which is how the OpenAPI link fills them.
203
+ //
204
+ // This exists so a permission gate can check the string the daemon will actually route on rather than the
205
+ // template it came from. Checking the template would quietly widen every grant: a wildcard glob matches the
206
+ // literal `{repo}` braces just as happily as it matches a repo name, so a manifest that narrowed the grant to
207
+ // one repo would still admit calls for every other — the gate would be comparing two patterns instead of
208
+ // testing a value against one.
209
+ //
210
+ // A param with no matching input field keeps its placeholder. That cannot be reached through the typed client
211
+ // (the contract's input schema requires the field), and leaving the braces in is the safe failure: a glob
212
+ // segment still matches it, a literal segment does not.
213
+ export const requestPathFor = (route: ContractRoute, input: unknown): string => {
214
+ const fields = typeof input === "object" && input !== null ? (input as Record<string, unknown>) : {};
215
+ return route.path.replace(/\{([^}]+)\}/g, (placeholder, name: string) => {
216
+ const value = fields[name];
217
+ return value === undefined ? placeholder : encodeURIComponent(String(value));
218
+ });
219
+ };
@@ -0,0 +1,45 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { RUNTIME_DOMAIN_BINDINGS, runtimeBoundQueryKeys, staleRuntimeQueryKeys } from "./runtime-state.js";
3
+
4
+ describe(`staleRuntimeQueryKeys`, () => {
5
+ it(`maps a pushed domain to the queries it makes stale`, () => {
6
+ expect(staleRuntimeQueryKeys([`terminals`])).toEqual([[`terminals`]]);
7
+ });
8
+
9
+ it(`dedupes across a batch, because one sweep can move several domains at once`, () => {
10
+ // The port sampler publishes both: a dev server binding its port is a new port AND the panel above it
11
+ // turning healthy (panels.ts reads health off the listening sockets). Order is the TABLE's, not the
12
+ // frame's, so a repeated domain and a reordered frame produce the same list.
13
+ expect(staleRuntimeQueryKeys([`ports`, `panels`, `ports`])).toEqual([[`panels`], [`apps`], [`ports`]]);
14
+ });
15
+
16
+ it(`ignores a domain this build has never heard of`, () => {
17
+ // A daemon newer than the browser names domains this table doesn't carry. Refreshing what we understand
18
+ // and dropping the rest beats throwing away the whole frame.
19
+ expect(staleRuntimeQueryKeys([`terminals`, `something-later`])).toEqual([[`terminals`]]);
20
+ });
21
+
22
+ it(`names a NESTED key whole, so a domain can refresh one family without its siblings`, () => {
23
+ // The review is filed under ["git","changes"] and the commit log under ["git","log"]. A landing's drafted
24
+ // message belongs to the first and has nothing to say about the second, which is the case the single
25
+ // segment this table used to carry could not express.
26
+ expect(staleRuntimeQueryKeys([`landings`])).toEqual([[`git`, `changes`]]);
27
+ });
28
+
29
+ it(`asks for nothing when nothing matched`, () => {
30
+ expect(staleRuntimeQueryKeys([])).toEqual([]);
31
+ });
32
+ });
33
+
34
+ describe(`runtimeBoundQueryKeys`, () => {
35
+ it(`covers every declared domain, since a reconnect is the only recovery for a frame nobody received`, () => {
36
+ // Compared as joined paths: the assertion is about which keys are covered, and two equal paths are two
37
+ // different arrays.
38
+ const joined = (keys: readonly (readonly string[])[]): string[] => [...new Set(keys.map((key) => key.join(`/`)))].toSorted();
39
+ expect(joined(runtimeBoundQueryKeys())).toEqual(joined(RUNTIME_DOMAIN_BINDINGS.flatMap((binding) => binding.invalidates)));
40
+ });
41
+
42
+ it(`leaves no domain declaring no keys — a domain nothing renders has no reason to be pushed`, () => {
43
+ expect(RUNTIME_DOMAIN_BINDINGS.filter((binding) => binding.invalidates.length === 0)).toEqual([]);
44
+ });
45
+ });
@@ -0,0 +1,124 @@
1
+ /* WHICH RUNNING THING BACKS WHICH VIEW — the twin of workspace-state.ts, for state that never touches a file.
2
+ *
3
+ * That table answers "the agent wrote a file, so which view is stale". This one answers the same question for
4
+ * the half of the sandbox that has no file to watch: the tmux sessions, the dev-server panels, the listening
5
+ * sockets, the agent's Chromiums, the children its turns spawn. None of them are on disk, so no
6
+ * `workspaceChanged` batch can ever mention them — and for want of a push, every one of those views polled.
7
+ *
8
+ * The polls were the tell. Six always-on timers (a 10s terminals relist behind THREE mounted surfaces, a 15s
9
+ * port scan behind the shell rail, a 4s panel relist for as long as any dev server ran) that each asked a
10
+ * question whose answer is almost always "nothing changed", forever, in every open tab. The daemon knew the real
11
+ * answer the whole time: it is the process that starts the panel, mints the browser, and opens the session.
12
+ *
13
+ * So the daemon says so, on the stream that already carries every other change, and the browser asks only when
14
+ * told to. The declaration lives HERE for the reason its twin does: the daemon publishes a DOMAIN and the
15
+ * browser turns it into query keys, and those two facts drift the moment they are written down in two packages.
16
+ *
17
+ * A key belongs to whoever queries it — the extension views that render ports and panels ask under the SAME key
18
+ * as core (api.sandbox.key("ports") is sandboxKey("ports")), so one entry here refreshes both surfaces and an
19
+ * extension needs no declaration of its own. */
20
+
21
+ /* One query key as the browser files it, a segment per level — so a binding can name a NESTED key and not only
22
+ * a top-level one. Most domains here are a single segment and read as they always did; the review is not. Its
23
+ * key is `["git", "changes"]` (the browser appends the sandbox id, and every open file diff hangs beneath it),
24
+ * and a domain that could only say `"git"` would drop the commit log along with it — a second read of the graph
25
+ * to deliver a sentence that has nothing to do with it. */
26
+ export type QueryKeyPath = readonly string[];
27
+
28
+ export interface RuntimeDomainBinding {
29
+ // What moved, in the daemon's words. Wire data: it rides the runtimeChanged frame.
30
+ readonly domain: string;
31
+ /* The browser query keys this domain's state feeds. Invalidation reaches only queries something is
32
+ * OBSERVING, so a domain nobody has on screen costs a frame and no request — which is what lets a domain be
33
+ * pushed eagerly without billing every tab for a view it isn't showing. */
34
+ readonly invalidates: readonly QueryKeyPath[];
35
+ }
36
+
37
+ /* Declared `as const` so the domain names survive as literal types (see RuntimeDomain below), then published
38
+ * under the interface — the same two-binding shape, for the same reason, as WORKSPACE_STATE_FILES. */
39
+ const RUNTIME_DOMAINS = [
40
+ /* Every attachable tmux session: the terminal panel's tab strip, the rail's activity badge, and the work
41
+ * popover's background-process rows all read this one list. Sampled rather than announced — a pane dies
42
+ * when its command exits and tmux tells nobody — but sampled ONCE in the daemon, on a connection the
43
+ * browsers already hold, instead of once per browser per 10s over the tunnel. */
44
+ { domain: "terminals", invalidates: [["terminals"]] },
45
+
46
+ /* A repo's dev server: running, healthy, and the preview URL it answers on. Two independent things move it,
47
+ * and it needs both — the process manager starting or reaping a session (announced), and the server
48
+ * actually binding its port some seconds later, which is what flips "starting" to "healthy" (seen by the
49
+ * port sampler, since panel health is read off the listening sockets — see panels.ts listenersByRepo).
50
+ *
51
+ * `apps` rides it because a monorepo's per-app previews ARE managed processes under the same manager
52
+ * (workspace.routes appsList reads processes.portOf), just listed per repo instead of per repo-root. One
53
+ * domain, because there is one fact: a dev server the daemon runs started, settled, or died. */
54
+ { domain: "panels", invalidates: [["panels"], ["apps"]] },
55
+
56
+ /* Every listening TCP socket in the sandbox, and which of them are forwarded to a public preview hostname.
57
+ * The daemon runs no port poller for the ANSWER — attributing a socket to its process walks every /proc fd
58
+ * table, far too much to do on a timer — so what is sampled is only the LISTEN set out of /proc/net/tcp:
59
+ * two file reads, enough to know that the answer changed and worth nobody's while to compute until a view
60
+ * asks. Change detection and payload are deliberately different sizes here. */
61
+ { domain: "ports", invalidates: [["ports"]] },
62
+
63
+ // The agent's Chromiums and the pages each holds open — daemon-held records, minted from the hooks that see
64
+ // the agent's own browser tool calls, so every change to this roster passes through this process.
65
+ { domain: "browsers", invalidates: [["browsers"]] },
66
+
67
+ // The agents this sandbox's agents started. Daemon-held like the browsers, and the one roster here that
68
+ // changes while nothing starts or stops: a working child reports tool uses and tokens continuously, which
69
+ // is why the daemon rate-limits this domain rather than pushing every mutation (see runtime-watch.ts).
70
+ { domain: "subagents", invalidates: [["subagents"]] },
71
+
72
+ /* The post queue, when the DAEMON moves it rather than the owner. Approving is the owner's own mutation and
73
+ * refetches itself, but everything after that happens while nobody is touching the page: a held post coming
74
+ * due, a Discord send landing, a publish turn writing back what went out. Those are the moments the row on
75
+ * screen stops being true — and this queue is watched precisely because its rows go out in public, so it is
76
+ * the last place to leave someone reading a stale one. */
77
+ { domain: "drafts", invalidates: [["drafts"]] },
78
+
79
+ /* WHAT A LANDED AGENT'S WORK IS CALLED — the commit message drafted from the diff the moment that work
80
+ * reaches the main tree (agents/landed-subject.ts), which the review's "From" chip files into the commit box.
81
+ *
82
+ * It needs a push of its own because it arrives LATE, and alone. The review refreshes when a turn ends; the
83
+ * sentence is a model call that STARTS there and answers seconds later, so the refresh the landing itself
84
+ * causes is always too early to carry it. Nothing followed. The message then sat in the daemon, correct and
85
+ * unread, until some unrelated write happened to refresh the panel again — and a chip clicked in that window
86
+ * filed nothing at all, which is indistinguishable from the feature having been removed.
87
+ *
88
+ * Neither a file nor a ref, so this is the only feed that could carry it: the entry holding it lives on
89
+ * /history, outside the watched tree, and no ref moves when a sentence is written.
90
+ *
91
+ * Being in this table also puts the review on the reconnect re-ask (runtimeBoundQueryKeys), and that is the
92
+ * half of the fix nothing else covers: a publish with no browser connected is dropped, so a landing drafted
93
+ * while the app was closed or the tunnel was down would otherwise stay invisible for as long as the panel's
94
+ * snapshot survived — which, at staleTime Infinity, is until something unrelated moved. It costs one review
95
+ * read per (re)connect, against a chip that files nothing for the rest of the session. */
96
+ { domain: "landings", invalidates: [["git", "changes"]] },
97
+ ] as const satisfies readonly RuntimeDomainBinding[];
98
+
99
+ export const RUNTIME_DOMAIN_BINDINGS: readonly RuntimeDomainBinding[] = RUNTIME_DOMAINS;
100
+
101
+ /* Every domain this table declares, as a type — so a publish site names one of THESE and nothing else, and a
102
+ * renamed domain is a compile error in the daemon rather than a frame the browser silently routes nowhere. */
103
+ export type RuntimeDomain = (typeof RUNTIME_DOMAINS)[number]["domain"];
104
+
105
+ /* Distinct key paths, in table order — two domains in one frame routinely feed the same view, and a Set cannot
106
+ * see that because each path is its own array. Compared by their segments joined, which is exact: a segment is
107
+ * one identifier from this file, never a caller's string, so there is no separator to collide on. */
108
+ const dedupe = (keys: readonly QueryKeyPath[]): readonly QueryKeyPath[] => [...new Map(keys.map((key) => [key.join(`/`), key])).values()];
109
+
110
+ /* The query keys a pushed set of domains makes stale, deduped and stable — the browser's `/events` handler
111
+ * calls this, exactly as it calls staleQueryKeys for a path batch. Kept here rather than in the web so the rule
112
+ * is unit-testable without a query client, and so the daemon can assert against the same table.
113
+ *
114
+ * An unknown domain contributes nothing rather than throwing: a daemon newer than the browser may name a domain
115
+ * this build has never heard of, and the honest response to that is to refresh what we do understand. */
116
+ export const staleRuntimeQueryKeys = (domains: readonly string[]): readonly QueryKeyPath[] =>
117
+ dedupe(RUNTIME_DOMAIN_BINDINGS.filter((binding) => domains.includes(binding.domain)).flatMap((binding) => binding.invalidates));
118
+
119
+ /* Every query key any runtime domain feeds — what a NEW /events connection re-asks wholesale, for precisely the
120
+ * reason fileBoundQueryKeys exists: this push is these views' ONLY live feed, and a frame produced while the
121
+ * stream was down is a frame nobody will resend. A panel that finished starting, a session that exited, a port
122
+ * that closed while the browser was away would otherwise sit wrong until the next unrelated change. Re-asking
123
+ * on connect bounds that at one cheap read per key, which is what lets these views go entirely unpolled. */
124
+ export const runtimeBoundQueryKeys = (): readonly QueryKeyPath[] => dedupe(RUNTIME_DOMAIN_BINDINGS.flatMap((binding) => binding.invalidates));
@@ -1,5 +1,5 @@
1
1
  import { expect, test } from "vitest";
2
- import { CapabilitiesListSchema, DeployOverviewResponseSchema, SandboxSettingsSchema } from "./schemas.js";
2
+ import { CapabilitiesListSchema, IpsecVpnConfigSchema, SandboxSettingsSchema } from "./schemas.js";
3
3
 
4
4
  /* The settings shape spans a version seam that really moves: the browser ships with the platform, the daemon
5
5
  * ships inside the user's sandbox image, so a web build routinely parses a payload from an OLDER daemon. These
@@ -34,7 +34,7 @@ test("a payload from a build that predates a toggle parses, with the new toggle
34
34
  * failed on every field the product added. */
35
35
  test("no field is required — a workspace that has never written settings parses", () => {
36
36
  const defaults = SandboxSettingsSchema.parse({});
37
- expect(Object.keys(defaults).sort()).toEqual(Object.keys(SandboxSettingsSchema.shape).sort());
37
+ expect(Object.keys(defaults).toSorted()).toEqual(Object.keys(SandboxSettingsSchema.shape).toSorted());
38
38
  });
39
39
 
40
40
  test("a key of the wrong type is still a parse failure — tolerance is for absence, not for garbage", () => {
@@ -53,34 +53,21 @@ test("a capability list from a daemon that predates recommendations parses, with
53
53
  expect(CapabilitiesListSchema.parse(older).recommendations).toEqual([]);
54
54
  });
55
55
 
56
- /* The deployments board crosses the same seam and learned it the hard way. `repos` (workspace repo Komodo
57
- * stack links) shipped REQUIRED, and the first sandbox whose daemon predated it rendered
58
- * `Invalid input: expected array, received undefined at repos` instead of the board a dead page, on the one
59
- * surface whose job is to say whether production is up, to hide a band of suggestions.
60
- *
61
- * `viewer` is the deliberate contrast: also added later, also absent from an older daemon, but OPTIONAL rather
62
- * than defaulted, because its absence is information. The empty state tells "the key can see nothing" apart
63
- * from "we could not tell", and defaulting it would have collapsed the two. */
56
+ /* An ipsec tunnel's routed networks decide whether it is split or full, and both ends of that are load-bearing:
57
+ * a value the daemon splices into rightsubnet unchecked reaches charon as a config it refuses WHOLESALE (every
58
+ * tunnel on the sandbox stops loading, and the error names the file rather than the field), while a default that
59
+ * stopped being 0.0.0.0/0 would silently narrow tunnels that reach those networks today. */
60
+ const ipsec = { provider: "ipsec", server: "gw.example.com", presharedKey: "group-secret" };
64
61
 
65
- test("an overview from a daemon that predates repo links parses, with no links rather than no board", () => {
66
- const older = { komodoUrl: "https://komodo.example.com", reachable: true, resources: [], servers: [], alerts: [] };
67
- const parsed = DeployOverviewResponseSchema.parse(older);
68
- expect(parsed.repos).toEqual([]);
69
- // Absent, NOT defaulted: the empty state reads this to avoid claiming an empty Komodo it cannot vouch for.
70
- expect(parsed.viewer).toBeUndefined();
62
+ test("an ipsec tunnel is a full tunnel unless it says otherwise", () => {
63
+ expect(IpsecVpnConfigSchema.parse(ipsec).routedNetworks).toBe("0.0.0.0/0");
71
64
  });
72
65
 
73
- test("a board that did carry links keeps them, and garbage in them is still a failure", () => {
74
- const current = {
75
- komodoUrl: "https://komodo.example.com",
76
- reachable: true,
77
- viewer: { username: "intentic", admin: false },
78
- repos: [{ repo: "app", projectName: "app", composePath: "app/compose.yaml", suggestions: ["app-prod"] }],
79
- resources: [],
80
- servers: [],
81
- alerts: [],
82
- };
83
- expect(DeployOverviewResponseSchema.parse(current).repos[0]?.suggestions).toEqual(["app-prod"]);
84
- // Tolerance is for absence, not for the wrong shape — a `repos` that is present and wrong is real drift.
85
- expect(DeployOverviewResponseSchema.safeParse({ ...current, repos: "none" }).success).toBe(false);
66
+ test("routed networks take a CIDR list and reject what charon could not load", () => {
67
+ expect(IpsecVpnConfigSchema.parse({ ...ipsec, routedNetworks: "10.0.0.0/8, 192.168.0.0/16" }).routedNetworks).toBe("10.0.0.0/8, 192.168.0.0/16");
68
+ expect(IpsecVpnConfigSchema.parse({ ...ipsec, routedNetworks: "fd00::/8" }).routedNetworks).toBe("fd00::/8");
69
+ // A bare host address is the easy mistake — strongSwan wants the prefix, and the message says so.
70
+ expect(IpsecVpnConfigSchema.safeParse({ ...ipsec, routedNetworks: "192.168.0.168" }).success).toBe(false);
71
+ expect(IpsecVpnConfigSchema.safeParse({ ...ipsec, routedNetworks: "10.0.0.0/8,nonsense" }).success).toBe(false);
72
+ expect(IpsecVpnConfigSchema.safeParse({ ...ipsec, routedNetworks: "" }).success).toBe(false);
86
73
  });