@intentic/sandbox-contract 1.240.0 → 1.242.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 (310) hide show
  1. package/README.md +52 -16
  2. package/dist/agent-catalog.d.ts +5 -28
  3. package/dist/agent-catalog.d.ts.map +1 -1
  4. package/dist/agent-catalog.js +21 -157
  5. package/dist/agent-catalog.js.map +1 -1
  6. package/dist/agent-run-model.d.ts +3 -2
  7. package/dist/agent-run-model.d.ts.map +1 -1
  8. package/dist/agent-run-model.js +9 -6
  9. package/dist/agent-run-model.js.map +1 -1
  10. package/dist/agent-runtimes.d.ts +27 -0
  11. package/dist/agent-runtimes.d.ts.map +1 -0
  12. package/dist/agent-runtimes.js +113 -0
  13. package/dist/agent-runtimes.js.map +1 -0
  14. package/dist/approvals-execution.d.ts +11 -0
  15. package/dist/approvals-execution.d.ts.map +1 -0
  16. package/dist/approvals-execution.js +44 -0
  17. package/dist/approvals-execution.js.map +1 -0
  18. package/dist/arrival.d.ts +151 -0
  19. package/dist/arrival.d.ts.map +1 -0
  20. package/dist/arrival.js +56 -0
  21. package/dist/arrival.js.map +1 -0
  22. package/dist/card-status.d.ts +5 -0
  23. package/dist/card-status.d.ts.map +1 -0
  24. package/dist/card-status.js +74 -0
  25. package/dist/card-status.js.map +1 -0
  26. package/dist/chores/chores.d.ts.map +1 -1
  27. package/dist/chores/chores.js +1 -1
  28. package/dist/chores/chores.js.map +1 -1
  29. package/dist/command-classes.d.ts +0 -1
  30. package/dist/command-classes.d.ts.map +1 -1
  31. package/dist/command-classes.js +9 -2
  32. package/dist/command-classes.js.map +1 -1
  33. package/dist/command-run.d.ts +10 -0
  34. package/dist/command-run.d.ts.map +1 -0
  35. package/dist/command-run.js +46 -0
  36. package/dist/command-run.js.map +1 -0
  37. package/dist/contracts/agent.contract.d.ts +991 -771
  38. package/dist/contracts/agent.contract.d.ts.map +1 -1
  39. package/dist/contracts/agents.contract.d.ts +345 -337
  40. package/dist/contracts/agents.contract.d.ts.map +1 -1
  41. package/dist/contracts/agents.contract.js +20 -2
  42. package/dist/contracts/agents.contract.js.map +1 -1
  43. package/dist/contracts/approvals.contract.d.ts +99 -0
  44. package/dist/contracts/approvals.contract.d.ts.map +1 -0
  45. package/dist/contracts/approvals.contract.js +32 -0
  46. package/dist/contracts/approvals.contract.js.map +1 -0
  47. package/dist/contracts/ci.contract.d.ts +1 -4
  48. package/dist/contracts/ci.contract.d.ts.map +1 -1
  49. package/dist/contracts/ci.contract.js +1 -9
  50. package/dist/contracts/ci.contract.js.map +1 -1
  51. package/dist/contracts/claude.contract.d.ts +9 -0
  52. package/dist/contracts/claude.contract.d.ts.map +1 -1
  53. package/dist/contracts/cursor.contract.d.ts +6 -0
  54. package/dist/contracts/cursor.contract.d.ts.map +1 -1
  55. package/dist/contracts/git.contract.d.ts +63 -1
  56. package/dist/contracts/git.contract.d.ts.map +1 -1
  57. package/dist/contracts/git.contract.js +22 -4
  58. package/dist/contracts/git.contract.js.map +1 -1
  59. package/dist/contracts/grok.contract.d.ts +3 -0
  60. package/dist/contracts/grok.contract.d.ts.map +1 -1
  61. package/dist/contracts/keys.contract.d.ts +81 -0
  62. package/dist/contracts/keys.contract.d.ts.map +1 -0
  63. package/dist/contracts/keys.contract.js +51 -0
  64. package/dist/contracts/keys.contract.js.map +1 -0
  65. package/dist/contracts/panels.contract.d.ts +7 -0
  66. package/dist/contracts/panels.contract.d.ts.map +1 -1
  67. package/dist/contracts/prepush.contract.js +2 -2
  68. package/dist/contracts/providers.contract.d.ts +2 -0
  69. package/dist/contracts/providers.contract.d.ts.map +1 -1
  70. package/dist/contracts/runner.contract.d.ts +84 -86
  71. package/dist/contracts/runner.contract.d.ts.map +1 -1
  72. package/dist/contracts/safety.contract.d.ts +35 -0
  73. package/dist/contracts/safety.contract.d.ts.map +1 -0
  74. package/dist/contracts/safety.contract.js +32 -0
  75. package/dist/contracts/safety.contract.js.map +1 -0
  76. package/dist/contracts/sessions.contract.d.ts +73 -337
  77. package/dist/contracts/sessions.contract.d.ts.map +1 -1
  78. package/dist/contracts/settings.contract.d.ts +30 -32
  79. package/dist/contracts/settings.contract.d.ts.map +1 -1
  80. package/dist/contracts/system.contract.d.ts +139 -337
  81. package/dist/contracts/system.contract.d.ts.map +1 -1
  82. package/dist/contracts/translator.contract.d.ts +31 -3
  83. package/dist/contracts/translator.contract.d.ts.map +1 -1
  84. package/dist/contracts/usage.contract.d.ts +29 -20
  85. package/dist/contracts/usage.contract.d.ts.map +1 -1
  86. package/dist/contracts/usage.contract.js +13 -0
  87. package/dist/contracts/usage.contract.js.map +1 -1
  88. package/dist/contracts/workspace.contract.d.ts +8 -0
  89. package/dist/contracts/workspace.contract.d.ts.map +1 -1
  90. package/dist/conversation-ids.d.ts +2 -0
  91. package/dist/conversation-ids.d.ts.map +1 -1
  92. package/dist/conversation-ids.js +8 -0
  93. package/dist/conversation-ids.js.map +1 -1
  94. package/dist/credential-material.d.ts +1 -0
  95. package/dist/credential-material.d.ts.map +1 -1
  96. package/dist/credential-material.js +24 -2
  97. package/dist/credential-material.js.map +1 -1
  98. package/dist/definition.d.ts +51 -93
  99. package/dist/definition.d.ts.map +1 -1
  100. package/dist/definition.js +6 -28
  101. package/dist/definition.js.map +1 -1
  102. package/dist/events.d.ts +1831 -2285
  103. package/dist/events.d.ts.map +1 -1
  104. package/dist/events.js +155 -55
  105. package/dist/events.js.map +1 -1
  106. package/dist/history-state.d.ts.map +1 -1
  107. package/dist/history-state.js +1 -0
  108. package/dist/history-state.js.map +1 -1
  109. package/dist/index.d.ts +1959 -1903
  110. package/dist/index.d.ts.map +1 -1
  111. package/dist/index.js +20 -5
  112. package/dist/index.js.map +1 -1
  113. package/dist/ingress-protocol.d.ts +13 -1
  114. package/dist/ingress-protocol.d.ts.map +1 -1
  115. package/dist/ingress-protocol.js +19 -0
  116. package/dist/ingress-protocol.js.map +1 -1
  117. package/dist/mentions.d.ts +3 -0
  118. package/dist/mentions.d.ts.map +1 -0
  119. package/dist/mentions.js +13 -0
  120. package/dist/mentions.js.map +1 -0
  121. package/dist/needs-action.d.ts +7 -0
  122. package/dist/needs-action.d.ts.map +1 -0
  123. package/dist/needs-action.js +3 -0
  124. package/dist/needs-action.js.map +1 -0
  125. package/dist/overlay-lint.d.ts +7 -0
  126. package/dist/overlay-lint.d.ts.map +1 -0
  127. package/dist/overlay-lint.js +68 -0
  128. package/dist/overlay-lint.js.map +1 -0
  129. package/dist/owner-ticket.d.ts +18 -0
  130. package/dist/owner-ticket.d.ts.map +1 -0
  131. package/dist/owner-ticket.js +51 -0
  132. package/dist/owner-ticket.js.map +1 -0
  133. package/dist/plan-pools.d.ts +12 -0
  134. package/dist/plan-pools.d.ts.map +1 -0
  135. package/dist/plan-pools.js +30 -0
  136. package/dist/plan-pools.js.map +1 -0
  137. package/dist/provider-specs.d.ts +228 -0
  138. package/dist/provider-specs.d.ts.map +1 -0
  139. package/dist/provider-specs.js +124 -0
  140. package/dist/provider-specs.js.map +1 -0
  141. package/dist/quick-model.d.ts.map +1 -1
  142. package/dist/quick-model.js +2 -1
  143. package/dist/quick-model.js.map +1 -1
  144. package/dist/runner-protocol.js +2 -2
  145. package/dist/runner-protocol.js.map +1 -1
  146. package/dist/runtime-state.d.ts +2 -2
  147. package/dist/runtime-state.d.ts.map +1 -1
  148. package/dist/runtime-state.js +1 -1
  149. package/dist/runtime-state.js.map +1 -1
  150. package/dist/safety-policy.d.ts +49 -0
  151. package/dist/safety-policy.d.ts.map +1 -0
  152. package/dist/safety-policy.js +52 -0
  153. package/dist/safety-policy.js.map +1 -0
  154. package/dist/schemas/agent.d.ts +21 -3
  155. package/dist/schemas/agent.d.ts.map +1 -1
  156. package/dist/schemas/agent.js +18 -8
  157. package/dist/schemas/agent.js.map +1 -1
  158. package/dist/schemas/agents.d.ts +19 -0
  159. package/dist/schemas/agents.d.ts.map +1 -1
  160. package/dist/schemas/agents.js +15 -0
  161. package/dist/schemas/agents.js.map +1 -1
  162. package/dist/schemas/approvals.d.ts +241 -0
  163. package/dist/schemas/approvals.d.ts.map +1 -0
  164. package/dist/schemas/approvals.js +63 -0
  165. package/dist/schemas/approvals.js.map +1 -0
  166. package/dist/schemas/automations.d.ts +5 -0
  167. package/dist/schemas/automations.d.ts.map +1 -1
  168. package/dist/schemas/ci.d.ts +5 -9
  169. package/dist/schemas/ci.d.ts.map +1 -1
  170. package/dist/schemas/ci.js +3 -10
  171. package/dist/schemas/ci.js.map +1 -1
  172. package/dist/schemas/computers.d.ts +31 -5
  173. package/dist/schemas/computers.d.ts.map +1 -1
  174. package/dist/schemas/computers.js +7 -5
  175. package/dist/schemas/computers.js.map +1 -1
  176. package/dist/schemas/environment.d.ts +11 -131
  177. package/dist/schemas/environment.d.ts.map +1 -1
  178. package/dist/schemas/environment.js +5 -42
  179. package/dist/schemas/environment.js.map +1 -1
  180. package/dist/schemas/git-history.d.ts +4 -0
  181. package/dist/schemas/git-history.d.ts.map +1 -1
  182. package/dist/schemas/git.d.ts +179 -0
  183. package/dist/schemas/git.d.ts.map +1 -1
  184. package/dist/schemas/git.js +39 -1
  185. package/dist/schemas/git.js.map +1 -1
  186. package/dist/schemas/panels.d.ts +21 -0
  187. package/dist/schemas/panels.d.ts.map +1 -1
  188. package/dist/schemas/panels.js +5 -0
  189. package/dist/schemas/panels.js.map +1 -1
  190. package/dist/schemas/plan-limits.d.ts +50 -3
  191. package/dist/schemas/plan-limits.d.ts.map +1 -1
  192. package/dist/schemas/plan-limits.js +11 -6
  193. package/dist/schemas/plan-limits.js.map +1 -1
  194. package/dist/schemas/provider-oauth.d.ts +6 -0
  195. package/dist/schemas/provider-oauth.d.ts.map +1 -1
  196. package/dist/schemas/provider-subscriptions.d.ts +2 -1
  197. package/dist/schemas/provider-subscriptions.d.ts.map +1 -1
  198. package/dist/schemas/provider-subscriptions.js +2 -1
  199. package/dist/schemas/provider-subscriptions.js.map +1 -1
  200. package/dist/schemas/settings.d.ts +29 -16
  201. package/dist/schemas/settings.d.ts.map +1 -1
  202. package/dist/schemas/settings.js +13 -20
  203. package/dist/schemas/settings.js.map +1 -1
  204. package/dist/schemas/system.d.ts +3 -0
  205. package/dist/schemas/system.d.ts.map +1 -1
  206. package/dist/schemas/system.js +5 -1
  207. package/dist/schemas/system.js.map +1 -1
  208. package/dist/schemas/workspace-repos.d.ts +14 -0
  209. package/dist/schemas/workspace-repos.d.ts.map +1 -1
  210. package/dist/schemas/workspace-repos.js +5 -0
  211. package/dist/schemas/workspace-repos.js.map +1 -1
  212. package/dist/schemas/workspace-tree.d.ts +1 -0
  213. package/dist/schemas/workspace-tree.d.ts.map +1 -1
  214. package/dist/schemas/workspace-tree.js +3 -0
  215. package/dist/schemas/workspace-tree.js.map +1 -1
  216. package/dist/transcript-fold.d.ts +31 -0
  217. package/dist/transcript-fold.d.ts.map +1 -0
  218. package/dist/transcript-fold.js +457 -0
  219. package/dist/transcript-fold.js.map +1 -0
  220. package/dist/workspace-state.d.ts +20 -3
  221. package/dist/workspace-state.d.ts.map +1 -1
  222. package/dist/workspace-state.js +22 -2
  223. package/dist/workspace-state.js.map +1 -1
  224. package/package.json +27 -5
  225. package/src/agent-catalog.test.ts +1 -1
  226. package/src/agent-catalog.ts +76 -555
  227. package/src/agent-run-model.test.ts +31 -15
  228. package/src/agent-run-model.ts +26 -12
  229. package/src/agent-runtimes.ts +419 -0
  230. package/src/approvals-execution.ts +96 -0
  231. package/src/arrival.ts +160 -0
  232. package/src/capability-ledger.test.ts +6 -4
  233. package/src/card-status.ts +93 -0
  234. package/src/chores/chores.ts +6 -3
  235. package/src/command-classes.test.ts +25 -0
  236. package/src/command-classes.ts +47 -5
  237. package/src/command-run.ts +78 -0
  238. package/src/contract-lock.test.ts +1 -1
  239. package/src/contract-lock.ts +2 -2
  240. package/src/contracts/agents.contract.ts +35 -2
  241. package/src/contracts/approvals.contract.ts +37 -0
  242. package/src/contracts/ci.contract.ts +0 -13
  243. package/src/contracts/git.contract.ts +28 -3
  244. package/src/contracts/keys.contract.ts +79 -0
  245. package/src/contracts/prepush.contract.ts +3 -3
  246. package/src/contracts/safety.contract.ts +48 -0
  247. package/src/contracts/usage.contract.ts +22 -0
  248. package/src/conversation-ids.test.ts +31 -1
  249. package/src/conversation-ids.ts +37 -3
  250. package/src/credential-material.test.ts +69 -1
  251. package/src/credential-material.ts +86 -5
  252. package/src/definition.ts +25 -53
  253. package/src/events.ts +374 -173
  254. package/src/history-state.ts +6 -0
  255. package/src/index.ts +24 -5
  256. package/src/ingress-protocol.ts +52 -1
  257. package/src/mentions.test.ts +22 -0
  258. package/src/mentions.ts +25 -0
  259. package/src/needs-action.ts +14 -0
  260. package/src/overlay-lint.test.ts +89 -0
  261. package/src/overlay-lint.ts +116 -0
  262. package/src/owner-ticket.test.ts +54 -0
  263. package/src/owner-ticket.ts +102 -0
  264. package/src/plan-pools.test.ts +71 -0
  265. package/src/plan-pools.ts +82 -0
  266. package/src/provider-specs.test.ts +130 -0
  267. package/src/provider-specs.ts +352 -0
  268. package/src/quick-model.ts +9 -5
  269. package/src/runner-protocol.ts +2 -2
  270. package/src/runtime-state.ts +6 -6
  271. package/src/safety-policy.ts +165 -0
  272. package/src/schemas/agent.ts +62 -17
  273. package/src/schemas/agents.ts +71 -0
  274. package/src/schemas/approvals.ts +174 -0
  275. package/src/schemas/ci.ts +19 -26
  276. package/src/schemas/computers.ts +52 -11
  277. package/src/schemas/environment.ts +21 -100
  278. package/src/schemas/git.ts +121 -6
  279. package/src/schemas/issues.ts +1 -1
  280. package/src/schemas/panels.ts +20 -0
  281. package/src/schemas/plan-limits.ts +58 -11
  282. package/src/schemas/provider-subscriptions.ts +7 -2
  283. package/src/schemas/settings.ts +67 -93
  284. package/src/schemas/system.ts +12 -1
  285. package/src/schemas/workspace-repos.ts +9 -0
  286. package/src/schemas/workspace-tree.ts +11 -0
  287. package/src/transcript-fold.test.ts +493 -0
  288. package/src/transcript-fold.ts +655 -0
  289. package/src/workspace-state.test.ts +77 -29
  290. package/src/workspace-state.ts +99 -31
  291. package/dist/contracts/drafts.contract.d.ts +0 -57
  292. package/dist/contracts/drafts.contract.d.ts.map +0 -1
  293. package/dist/contracts/drafts.contract.js +0 -32
  294. package/dist/contracts/drafts.contract.js.map +0 -1
  295. package/dist/definition-action.d.ts +0 -7
  296. package/dist/definition-action.d.ts.map +0 -1
  297. package/dist/definition-action.js +0 -3
  298. package/dist/definition-action.js.map +0 -1
  299. package/dist/publish-drafts.d.ts +0 -7
  300. package/dist/publish-drafts.d.ts.map +0 -1
  301. package/dist/publish-drafts.js +0 -20
  302. package/dist/publish-drafts.js.map +0 -1
  303. package/dist/schemas/drafts.d.ts +0 -84
  304. package/dist/schemas/drafts.d.ts.map +0 -1
  305. package/dist/schemas/drafts.js +0 -44
  306. package/dist/schemas/drafts.js.map +0 -1
  307. package/src/contracts/drafts.contract.ts +0 -35
  308. package/src/definition-action.ts +0 -8
  309. package/src/publish-drafts.ts +0 -59
  310. package/src/schemas/drafts.ts +0 -108
@@ -105,6 +105,12 @@ export const HISTORY_STATE_FILES: readonly StateFile[] = [
105
105
  * export would pack THAT, each one a multiple of the last. Living on this volume is the other half of the
106
106
  * same guard; under `/work` the file would also be watched, indexed by iq, and snapshotted into history. */
107
107
  { path: "exports/", portability: "derived" },
108
+ /* The other end of the same volume: a bundle being taken IN, spooled here while its owner reads the plan
109
+ * it produced. `derived` for the export directory's reason and one more — this is somebody else's bundle,
110
+ * mid-review, and packing a half-reviewed arrival into an export would carry a sandbox that was never
111
+ * this one. The pipeline deletes each spool on apply or abandon, and boot sweeps whatever a crash left
112
+ * (portability/bundle-arrival.ts). */
113
+ { path: "arrivals/", portability: "derived" },
108
114
 
109
115
  /* ---- credentials ---- */
110
116
 
package/src/index.ts CHANGED
@@ -3,13 +3,13 @@ import { contractRoutes, requestPathFor, routeForProcedure, routeNameForRequest,
3
3
  import { activityContract } from "./contracts/activity.contract.js";
4
4
  import { agentContract } from "./contracts/agent.contract.js";
5
5
  import { agentsContract } from "./contracts/agents.contract.js";
6
+ import { approvalsContract } from "./contracts/approvals.contract.js";
6
7
  import { automationsContract } from "./contracts/automations.contract.js";
7
8
  import { capabilitiesContract } from "./contracts/capabilities.contract.js";
8
9
  import { choresContract } from "./contracts/chores.contract.js";
9
10
  import { ciContract } from "./contracts/ci.contract.js";
10
11
  import { claudeContract } from "./contracts/claude.contract.js";
11
12
  import { cursorContract } from "./contracts/cursor.contract.js";
12
- import { draftsContract } from "./contracts/drafts.contract.js";
13
13
  import { endpointsContract } from "./contracts/endpoints.contract.js";
14
14
  import { exitContract } from "./contracts/exit.contract.js";
15
15
  import { extensionsContract } from "./contracts/extensions.contract.js";
@@ -26,8 +26,10 @@ import { panelsContract } from "./contracts/panels.contract.js";
26
26
  import { portsContract } from "./contracts/ports.contract.js";
27
27
  import { publicContract } from "./contracts/public.contract.js";
28
28
  import { prepushContract } from "./contracts/prepush.contract.js";
29
+ import { keysContract } from "./contracts/keys.contract.js";
29
30
  import { providersContract } from "./contracts/providers.contract.js";
30
31
  import { pushContract } from "./contracts/push.contract.js";
32
+ import { safetyContract } from "./contracts/safety.contract.js";
31
33
  import { secretsContract } from "./contracts/secrets.contract.js";
32
34
  import { sessionsContract } from "./contracts/sessions.contract.js";
33
35
  import { settingsContract } from "./contracts/settings.contract.js";
@@ -43,13 +45,13 @@ import { workspaceContract } from "./contracts/workspace.contract.js";
43
45
  export { activityContract } from "./contracts/activity.contract.js";
44
46
  export { agentContract } from "./contracts/agent.contract.js";
45
47
  export { agentsContract } from "./contracts/agents.contract.js";
48
+ export { approvalsContract } from "./contracts/approvals.contract.js";
46
49
  export { automationsContract } from "./contracts/automations.contract.js";
47
50
  export { capabilitiesContract } from "./contracts/capabilities.contract.js";
48
51
  export { choresContract } from "./contracts/chores.contract.js";
49
52
  export { ciContract } from "./contracts/ci.contract.js";
50
53
  export { claudeContract } from "./contracts/claude.contract.js";
51
54
  export { cursorContract } from "./contracts/cursor.contract.js";
52
- export { draftsContract } from "./contracts/drafts.contract.js";
53
55
  export { endpointsContract, type TrialHealth, TrialStatusSchema, type TrialStatusResponse } from "./contracts/endpoints.contract.js";
54
56
  export { exitContract } from "./contracts/exit.contract.js";
55
57
  export { extensionsContract } from "./contracts/extensions.contract.js";
@@ -75,8 +77,10 @@ export { panelsContract } from "./contracts/panels.contract.js";
75
77
  export { portsContract } from "./contracts/ports.contract.js";
76
78
  export { publicContract } from "./contracts/public.contract.js";
77
79
  export { prepushContract } from "./contracts/prepush.contract.js";
80
+ export { keysContract } from "./contracts/keys.contract.js";
78
81
  export { providersContract } from "./contracts/providers.contract.js";
79
82
  export { pushContract } from "./contracts/push.contract.js";
83
+ export { safetyContract } from "./contracts/safety.contract.js";
80
84
  export { secretsContract } from "./contracts/secrets.contract.js";
81
85
  export { sessionsContract } from "./contracts/sessions.contract.js";
82
86
  export { settingsContract } from "./contracts/settings.contract.js";
@@ -89,6 +93,8 @@ export { vpnContract } from "./contracts/vpn.contract.js";
89
93
  export { workflowsContract } from "./contracts/workflows.contract.js";
90
94
  export { workspaceContract } from "./contracts/workspace.contract.js";
91
95
  export * from "./events.js";
96
+ export * from "./card-status.js";
97
+ export * from "./mentions.js";
92
98
  export * from "./sse.js";
93
99
  export * from "./routes.js";
94
100
  /* THE CONTAINER'S FIXED DIRECTORY LAYOUT, re-exported so extensions can reach it.
@@ -106,10 +112,18 @@ export * from "./workspace-state.js";
106
112
  export * from "./runtime-state.js";
107
113
  export * from "./state-portability.js";
108
114
  export * from "./history-state.js";
115
+ /* THE PROVIDER VOCABULARY, bottom-up: what each agentic loop can do (agent-runtimes), the one row-per-provider
116
+ * table built on those records (provider-specs), and the shapes each surface reads that table in
117
+ * (agent-catalog). Exported as three modules rather than folded into one, because the arrow between them only
118
+ * points one way and saying so here is what stops it curling back. */
119
+ export * from "./agent-runtimes.js";
120
+ export * from "./provider-specs.js";
109
121
  export * from "./agent-catalog.js";
110
122
  export * from "./agent-run-model.js";
111
123
  export * from "./capability-env.js";
112
124
  export * from "./command-classes.js";
125
+ export * from "./command-run.js";
126
+ export * from "./safety-policy.js";
113
127
  export * from "./credential-material.js";
114
128
  export * from "./capability-secrets.js";
115
129
  export * from "./conversation-ids.js";
@@ -121,12 +135,14 @@ export * from "./webext-links.js";
121
135
  export * from "./runner-protocol.js";
122
136
  export * from "./listener-protocol.js";
123
137
  export * from "./hostnames.js";
138
+ export * from "./overlay-lint.js";
124
139
  export * from "./model-order.js";
140
+ export * from "./plan-pools.js";
125
141
  export * from "./path-refs.js";
126
142
  export * from "./prompt-complexity.js";
127
143
  export * from "./quick-model.js";
128
144
  export * from "./output-fields.js";
129
- export * from "./publish-drafts.js";
145
+ export * from "./approvals-execution.js";
130
146
  /* The wire shapes, one module per subject area, mirroring `contracts/` above: every request and response the
131
147
  * daemon speaks is declared in exactly one of them. Inputs that carry a `{param}` in their route path (repo /
132
148
  * id / name) merge the path param into the same flat object, oRPC fills the path placeholder from the matching
@@ -137,13 +153,13 @@ export * from "./publish-drafts.js";
137
153
  export * from "./schemas/activity.js";
138
154
  export * from "./schemas/agent.js";
139
155
  export * from "./schemas/agents.js";
156
+ export * from "./schemas/approvals.js";
140
157
  export * from "./schemas/automations.js";
141
158
  export * from "./schemas/capabilities.js";
142
159
  export * from "./schemas/ci.js";
143
160
  export * from "./schemas/claude-gate.js";
144
161
  export * from "./schemas/codebase-health.js";
145
162
  export * from "./schemas/computers.js";
146
- export * from "./schemas/drafts.js";
147
163
  export * from "./schemas/engines.js";
148
164
  export * from "./schemas/environment.js";
149
165
  export * from "./schemas/exit.js";
@@ -183,6 +199,7 @@ export * from "./schemas/workspace-repos.js";
183
199
  export * from "./schemas/workspace-search.js";
184
200
  export * from "./schemas/workspace-setup.js";
185
201
  export * from "./schemas/workspace-tree.js";
202
+ export * from "./arrival.js";
186
203
  export * from "./definition.js";
187
204
  export * from "./search-globs.js";
188
205
  export * from "./starter.js";
@@ -198,16 +215,17 @@ export const sandboxContract = {
198
215
  activity: activityContract,
199
216
  agent: agentContract,
200
217
  agents: agentsContract,
218
+ approvals: approvalsContract,
201
219
  automations: automationsContract,
202
220
  capabilities: capabilitiesContract,
203
221
  chores: choresContract,
204
222
  ci: ciContract,
205
223
  claude: claudeContract,
206
224
  cursor: cursorContract,
207
- drafts: draftsContract,
208
225
  endpoints: endpointsContract,
209
226
  extensions: extensionsContract,
210
227
  personas: personasContract,
228
+ safety: safetyContract,
211
229
  sessions: sessionsContract,
212
230
  settings: settingsContract,
213
231
  share: shareContract,
@@ -225,6 +243,7 @@ export const sandboxContract = {
225
243
  ports: portsContract,
226
244
  public: publicContract,
227
245
  prepush: prepushContract,
246
+ keys: keysContract,
228
247
  providers: providersContract,
229
248
  push: pushContract,
230
249
  secrets: secretsContract,
@@ -8,7 +8,7 @@ import {
8
8
  type ServerHttp2Stream,
9
9
  } from "node:http2";
10
10
  import { type AddressInfo, createServer as createNetServer, connect as netConnect, type Socket } from "node:net";
11
- import type { Duplex } from "node:stream";
11
+ import { Duplex } from "node:stream";
12
12
 
13
13
  /* THE DATA PLANE OF THE INGRESS TUNNEL: both halves of it, over any node Duplex, in node core alone.
14
14
  *
@@ -572,3 +572,54 @@ export const serveIngressSession = async (duplex: Duplex, options: ServeIngressS
572
572
  },
573
573
  };
574
574
  };
575
+
576
+ /* ── THE WEBSOCKET, AS A DUPLEX ──────────────────────────────────────────────────────────────────────────
577
+ *
578
+ * `ws` ships this as `createWebSocketStream`, and BUN DOES NOT IMPLEMENT IT: the call throws
579
+ * `Error("Not supported yet in Bun")` out of ws's `Receiver` constructor (verified on 1.3.14, on 1.4.0, and on
580
+ * 1.4.1). The edge runs on Bun, and the throw landed inside its `upgrade` handler with no
581
+ * `try` of ours on the stack — so the FIRST sandbox to register a tunnel killed the process, taking every
582
+ * other sandbox's tunnel with it, and the container restarted straight into the same crash when that
583
+ * sandbox retried. It is the reason a hosted sandbox could be provisioned and then never come up.
584
+ *
585
+ * Reimplementing it is a dozen lines and buys more than the fix: both halves of the tunnel now get the same
586
+ * bytes on Bun and on node, so the runtime stops being a variable on the hot path. It belongs in this file
587
+ * because this file already owns both halves and the property below is one of its stated invariants.
588
+ *
589
+ * THE PROPERTY TO PRESERVE is the BYTE SAFETY note at the top: the write callback fires only once the socket
590
+ * has taken the frame (`send(chunk, …, callback)`), so the stream owns each chunk until it is out and no
591
+ * pooled Buffer is ever handed to an asynchronous writer. `read()` is a no-op because the socket pushes —
592
+ * there is nothing to pull — and a closed socket ends the readable side with `push(null)` so an h2 session
593
+ * over it sees a clean EOF rather than a hang. */
594
+ export interface TunnelWebSocket {
595
+ readonly send: (data: Buffer, options: { binary: boolean }, callback: (error?: Error) => void) => void;
596
+ readonly close: () => void;
597
+ readonly on: {
598
+ (event: `message`, listener: (data: unknown) => void): unknown;
599
+ (event: `close`, listener: () => void): unknown;
600
+ (event: `error`, listener: (error: Error) => void): unknown;
601
+ };
602
+ }
603
+
604
+ export const webSocketDuplex = (socket: TunnelWebSocket): Duplex => {
605
+ const duplex = new Duplex({
606
+ read: () => {},
607
+ write: (chunk: Buffer, _encoding, callback) => {
608
+ socket.send(chunk, { binary: true }, (error) => callback(error ?? null));
609
+ },
610
+ // Half-closing a WebSocket is not a thing: ending the writable side ends the socket, which is what
611
+ // every caller here means by it (a session that has written its GOAWAY is done with the transport).
612
+ final: (callback) => {
613
+ socket.close();
614
+ callback();
615
+ },
616
+ });
617
+ // `message` carries a Buffer for binary frames under both ws and Bun; a text frame (nothing on this
618
+ // protocol sends one) would arrive as a string, and coercing it is cheaper than dropping bytes silently.
619
+ socket.on(`message`, (data: unknown) => {
620
+ duplex.push(Buffer.isBuffer(data) ? data : Buffer.from(data as string));
621
+ });
622
+ socket.on(`close`, () => duplex.push(null));
623
+ socket.on(`error`, (error: Error) => duplex.destroy(error));
624
+ return duplex;
625
+ };
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { mentionedPathTokens, mentionPaths } from "./mentions.js";
3
+
4
+ describe(`mentionPaths`, () => {
5
+ it(`extracts path-looking tokens, deduped, with trailing punctuation stripped`, () => {
6
+ expect(mentionPaths(`see @src/app.ts and @readme.md, plus @src/app.ts again`)).toEqual([`src/app.ts`, `readme.md`]);
7
+ });
8
+
9
+ it(`skips prose handles and mid-word @`, () => {
10
+ expect(mentionPaths(`thanks @radarsu — mail me@example.com`)).toEqual([]);
11
+ });
12
+
13
+ it(`skips scoped package script prefixes in copied pnpm output`, () => {
14
+ expect(mentionPaths(`@intentic/iq-engine:test: failed\nsee @src/app.ts`)).toEqual([`src/app.ts`]);
15
+ });
16
+ });
17
+
18
+ describe(`mentionedPathTokens`, () => {
19
+ it(`keeps the package-script shape the composer refuses, so a transcript can still recognise it inline`, () => {
20
+ expect(mentionedPathTokens(`@intentic/iq-engine:test: failed\nsee @src/app.ts`)).toEqual([`intentic/iq-engine:test`, `src/app.ts`]);
21
+ });
22
+ });
@@ -0,0 +1,25 @@
1
+ /* @-FILE MENTIONS, the two readers of the same text: the composer, which sends every path a message names as
2
+ * an attachment (the daemon resolves workspace-relative paths and folds them into the prompt as a Read-tool
3
+ * note, no upload involved), and the fold, which draws a message's UPLOADED files as chips and its inline
4
+ * mentions as nothing at all, because they are already visible in the words. One tokenizer, so what the
5
+ * composer counts as a mention is what the transcript declines to redraw. */
6
+
7
+ // Every path-looking @ token in text, including tokens that are not valid composer mentions. Broad on purpose:
8
+ // old turns persisted these candidates in the shared attachment field, and the fold needs the broad set to
9
+ // recognise and hide those inline paths rather than redraw them as file chips.
10
+ export const mentionedPathTokens = (text: string): string[] => {
11
+ const paths = new Set<string>();
12
+ for (const match of text.matchAll(/(?:^|\s)@([^\s@]+)/g)) {
13
+ const token = (match[1] as string).replace(/[.,;:!?)]+$/, ``);
14
+ if (token.includes(`/`) || token.includes(`.`)) {
15
+ paths.add(token);
16
+ }
17
+ }
18
+ return [...paths];
19
+ };
20
+
21
+ // Workspace paths referenced as @-mentions in a prompt, deduped. A scoped package script prefix from copied
22
+ // pnpm output has the same opening shape (`@scope/package:test:`) but is not a file; accepting it hands the
23
+ // daemon a phantom attachment which only becomes visible when a transcript redraws the wire fields.
24
+ const PACKAGE_SCRIPT = /^[^/]+\/[^/]+:[^/]+$/u;
25
+ export const mentionPaths = (text: string): string[] => mentionedPathTokens(text).filter((token) => !PACKAGE_SCRIPT.test(token));
@@ -0,0 +1,14 @@
1
+ import { z } from "zod";
2
+
3
+ /* ONE "DO THIS BY HAND" LINE, the honesty unit three surfaces share: what an arrival could not do for the
4
+ * owner (arrival.ts), what a derived definition could not express (definition.ts), and where a runner has
5
+ * drifted from its parent (runner-protocol.ts). A subject the UI bolds, a detail written as an instruction.
6
+ *
7
+ * It lives in its own leaf module because runner-protocol.ts speaks it, and THAT file is imported by
8
+ * schemas/agent.ts, which definition.ts imports — the schema living in definition.ts made a cycle that left
9
+ * schemas half-initialized under module-eval.
10
+ *
11
+ * The name says the FIELD it fills (`needsAction`) rather than any one surface, because naming it for the
12
+ * definition is what let the arrival surfaces drift apart in the first place. */
13
+ export const NeedsActionSchema = z.object({ subject: z.string(), detail: z.string() });
14
+ export type NeedsAction = z.infer<typeof NeedsActionSchema>;
@@ -0,0 +1,89 @@
1
+ import { expect, test } from "vitest";
2
+ import { hasOfficialBase, invalidExtensionFragment, isOfficialSandboxImage, lintOverlay, overlayBase, rewriteOverlayBase } from "./overlay-lint.js";
3
+
4
+ test("accepts a RUN/ENV-only fragment, comments and blank lines included", () => {
5
+ const fragment = `# install the postgres client\nRUN apt-get update && apt-get install -y postgresql-client\nENV PGCLIENT=1\n`;
6
+ expect(invalidExtensionFragment(fragment)).toBeUndefined();
7
+ });
8
+
9
+ test("accepts a line-continued RUN body", () => {
10
+ const fragment = `RUN set -eux; \\\n apt-get update; \\\n apt-get install -y whisper\n`;
11
+ expect(invalidExtensionFragment(fragment)).toBeUndefined();
12
+ });
13
+
14
+ test("rejects FROM in a fragment (the daemon owns the base pin)", () => {
15
+ expect(invalidExtensionFragment(`FROM ubuntu:24.04\nRUN echo hi`)).toBe(`FROM ubuntu:24.04`);
16
+ });
17
+
18
+ test("rejects a non-RUN/ENV instruction in a fragment", () => {
19
+ expect(invalidExtensionFragment(`RUN echo ok\nCOPY x /x`)).toBe(`COPY x /x`);
20
+ expect(invalidExtensionFragment(`USER root`)).toBe(`USER root`);
21
+ });
22
+
23
+ test("rejects a privileged runtime directive, even hidden in a comment or a continued body", () => {
24
+ expect(invalidExtensionFragment(`# intentic:runtime --privileged`)).toBe(`# intentic:runtime --privileged`);
25
+ expect(invalidExtensionFragment(`RUN true \\\n # intentic:runtime --cap-add=NET_ADMIN`)).toBe(` # intentic:runtime --cap-add=NET_ADMIN`);
26
+ });
27
+
28
+ test("the official image is the published sandbox under any tag", () => {
29
+ expect(isOfficialSandboxImage(`ghcr.io/intentic/sandbox:stable`)).toBe(true);
30
+ expect(isOfficialSandboxImage(`ghcr.io/intentic/sandbox:1.52.0`)).toBe(true);
31
+ expect(isOfficialSandboxImage(`ghcr.io/intentic/sandbox:`)).toBe(false);
32
+ expect(isOfficialSandboxImage(`intentic-sandbox:dev`)).toBe(false);
33
+ expect(isOfficialSandboxImage(`ghcr.io/intentic/sandbox:stable extra`)).toBe(false);
34
+ });
35
+
36
+ test("overlayBase reads the first instruction's FROM and nothing else", () => {
37
+ expect(overlayBase(`# composed\n\nFROM ghcr.io/intentic/sandbox:stable\nRUN true\n`)).toBe(`ghcr.io/intentic/sandbox:stable`);
38
+ expect(overlayBase(`RUN true\nFROM ghcr.io/intentic/sandbox:stable\n`)).toBeUndefined();
39
+ expect(overlayBase(``)).toBeUndefined();
40
+ });
41
+
42
+ test("hasOfficialBase pins the first instruction to the official sandbox image", () => {
43
+ expect(hasOfficialBase("FROM ghcr.io/intentic/sandbox:stable\nRUN true\n")).toBe(true);
44
+ expect(hasOfficialBase("# comment\n\nFROM ghcr.io/intentic/sandbox:1.52.0\nRUN true\n")).toBe(true);
45
+ expect(hasOfficialBase("FROM alpine:latest\n")).toBe(false);
46
+ expect(hasOfficialBase("FROM ghcr.io/intentic/sandbox:\n")).toBe(false);
47
+ expect(hasOfficialBase("RUN true\nFROM ghcr.io/intentic/sandbox:stable\n")).toBe(false);
48
+ expect(hasOfficialBase("")).toBe(false);
49
+ });
50
+
51
+ const COMPOSED =
52
+ `# Composed by the intentic sandbox daemon: do not edit by hand.\n\n` +
53
+ `FROM ghcr.io/intentic/sandbox:stable\n\n` +
54
+ `# docker capability: this directive grants dockerd the privileges it needs\n` +
55
+ `# intentic:runtime --privileged\n\n` +
56
+ `# ---- custom (owner-approved) ----\n` +
57
+ `RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \\\n` +
58
+ ` apt-get update && apt-get install -y --no-install-recommends gnucobol\n` +
59
+ `ENV COBOL=1\n`;
60
+
61
+ test("lintOverlay accepts a composed overlay: official FROM, runtime directive comments, RUN/ENV", () => {
62
+ expect(lintOverlay(COMPOSED)).toBeUndefined();
63
+ });
64
+
65
+ test("lintOverlay names the line that breaks the grammar", () => {
66
+ expect(lintOverlay(`FROM alpine:3.20\nRUN true\n`)).toBe(`FROM alpine:3.20`);
67
+ expect(lintOverlay(`RUN true\nFROM ghcr.io/intentic/sandbox:stable\n`)).toBe(`RUN true`);
68
+ expect(lintOverlay(`FROM ghcr.io/intentic/sandbox:stable\nCOPY . /work\n`)).toBe(`COPY . /work`);
69
+ expect(lintOverlay(`FROM ghcr.io/intentic/sandbox:stable\nRUN true\nFROM ghcr.io/intentic/sandbox:beta\n`)).toBe(
70
+ `FROM ghcr.io/intentic/sandbox:beta`,
71
+ );
72
+ expect(lintOverlay(`FROM ghcr.io/intentic/sandbox:stable\nUSER nobody\n`)).toBe(`USER nobody`);
73
+ });
74
+
75
+ test("lintOverlay refuses an overlay with nothing to build", () => {
76
+ expect(lintOverlay(``)).toBe(``);
77
+ expect(lintOverlay(`# only a comment\n`)).toBe(``);
78
+ });
79
+
80
+ test("rewriteOverlayBase changes the first FROM and nothing else", () => {
81
+ const rewritten = rewriteOverlayBase(COMPOSED, `ghcr.io/intentic/sandbox:1.53.0`);
82
+ expect(overlayBase(rewritten)).toBe(`ghcr.io/intentic/sandbox:1.53.0`);
83
+ expect(rewritten.replace(`FROM ghcr.io/intentic/sandbox:1.53.0`, `FROM ghcr.io/intentic/sandbox:stable`)).toBe(COMPOSED);
84
+ });
85
+
86
+ test("rewriteOverlayBase leaves content alone when the first instruction is not a FROM", () => {
87
+ expect(rewriteOverlayBase(`RUN true\n`, `ghcr.io/intentic/sandbox:stable`)).toBe(`RUN true\n`);
88
+ expect(rewriteOverlayBase(``, `ghcr.io/intentic/sandbox:stable`)).toBe(``);
89
+ });
@@ -0,0 +1,116 @@
1
+ /* WHAT AN ENVIRONMENT OVERLAY MAY SAY, read the same way by everyone who handles one.
2
+ *
3
+ * An overlay is the Dockerfile that extends the sandbox image for one sandbox: a pinned FROM, the enabled
4
+ * capabilities' fragments, and the custom section the owner approved. Three readers apply the same grammar
5
+ * at three trust levels, and none of them may trust the one before:
6
+ * • the daemon admits an EXTENSION's fragment only if it is RUN/ENV (capabilities/handlers/extension.ts at
7
+ * install, environment/fragment-sources.ts again at compose, in case the checkout changed);
8
+ * • the daemon pins the composed file's first instruction to the official image (environment.ts);
9
+ * • the platform builds a HOSTED sandbox's overlay on a machine of its own, from content that reached it
10
+ * through an agent-writable volume and a browser, so it re-checks all of it before any machine exists.
11
+ * Text in, verdict out. No filesystem and no services, so the platform imports this the way it imports
12
+ * tunnel-ids, and the ic binary's Rust twin (recreate.rs `overlay_base`, runner.rs) reads the same shape. */
13
+
14
+ // The marker the rebuild executors read runtime privileges from (`# intentic:runtime --privileged`, the
15
+ // docker run flag it becomes is allowlisted there). A fragment that can be written by anything but the
16
+ // daemon's own code must not carry it, in a comment or anywhere else.
17
+ const RUNTIME_DIRECTIVE = "intentic:runtime";
18
+
19
+ const OFFICIAL_SANDBOX_IMAGE = /^ghcr\.io\/intentic\/sandbox:\S+$/;
20
+
21
+ // The published sandbox image under any tag, the only base an overlay may extend.
22
+ export const isOfficialSandboxImage = (ref: string): boolean => OFFICIAL_SANDBOX_IMAGE.test(ref);
23
+
24
+ interface OverlayLine {
25
+ readonly raw: string;
26
+ // `body` is a line inside a `\`-continued instruction, which carries no keyword of its own.
27
+ readonly kind: "blank" | "comment" | "instruction" | "body";
28
+ }
29
+
30
+ // Continuation-aware: a `\` at the end of any line, comments included, makes the next line a body line.
31
+ const overlayLines = (content: string): OverlayLine[] => {
32
+ let continued = false;
33
+ return content.split("\n").map((raw) => {
34
+ const line = raw.trim();
35
+ const wasContinued = continued;
36
+ continued = line.endsWith("\\");
37
+ const kind = line === "" ? "blank" : line.startsWith("#") ? "comment" : wasContinued ? "body" : "instruction";
38
+ return { raw, kind };
39
+ });
40
+ };
41
+
42
+ const FROM = /^from\s+(\S+)/i;
43
+ const RUN_OR_ENV = /^(run|env)\s/i;
44
+
45
+ /* An extension fragment may install and set variables, and nothing else. FROM is refused because the daemon
46
+ * owns the base pin; every other instruction because there is no build context to COPY from and nothing an
47
+ * extension has any business doing to USER, ENTRYPOINT or EXPOSE; and the runtime directive anywhere,
48
+ * because an out-of-band executor greps for it and a comment is exactly where it would hide. Answers the
49
+ * offending line, or undefined when the fragment is clean. */
50
+ export const invalidExtensionFragment = (content: string): string | undefined => {
51
+ for (const { raw, kind } of overlayLines(content)) {
52
+ if (raw.includes(RUNTIME_DIRECTIVE)) {
53
+ return raw;
54
+ }
55
+ if (kind === "instruction" && !RUN_OR_ENV.test(raw.trim())) {
56
+ return raw;
57
+ }
58
+ }
59
+ return undefined;
60
+ };
61
+
62
+ // The image a composed overlay extends: the first instruction, when it is a FROM.
63
+ export const overlayBase = (content: string): string | undefined => {
64
+ const first = overlayLines(content).find((line) => line.kind === "instruction");
65
+ return first === undefined ? undefined : FROM.exec(first.raw.trim())?.[1];
66
+ };
67
+
68
+ // Whether the overlay's first instruction pins it to the official sandbox image.
69
+ export const hasOfficialBase = (content: string): boolean => {
70
+ const base = overlayBase(content);
71
+ return base !== undefined && isOfficialSandboxImage(base);
72
+ };
73
+
74
+ /* A whole composed overlay, as an executor that did not compose it must read it: one leading FROM on the
75
+ * official image, then RUN and ENV only. Comments pass, the runtime directive included, since a composed
76
+ * overlay is where the daemon's own capability fragments put it and the executor that honours it (ic) reads
77
+ * it from there; an executor that cannot honour it (a VM is already privileged) ignores it. Answers the
78
+ * offending line, or undefined. An overlay with no instruction at all is refused: there is nothing to build. */
79
+ export const lintOverlay = (content: string): string | undefined => {
80
+ let first = true;
81
+ for (const { raw, kind } of overlayLines(content)) {
82
+ if (kind !== "instruction") {
83
+ continue;
84
+ }
85
+ const line = raw.trim();
86
+ if (first) {
87
+ first = false;
88
+ const base = FROM.exec(line)?.[1];
89
+ if (base === undefined || !isOfficialSandboxImage(base)) {
90
+ return raw;
91
+ }
92
+ continue;
93
+ }
94
+ if (!RUN_OR_ENV.test(line)) {
95
+ return raw;
96
+ }
97
+ }
98
+ return first ? "" : undefined;
99
+ };
100
+
101
+ /* The same overlay on a different base: the first FROM rewritten, everything else byte-identical. An
102
+ * executor applies an approved recipe on the base IT runs (a newer release than the one the daemon composed
103
+ * against, a rollback pin), and hashes the content the owner approved rather than this rewrite, so the
104
+ * environment hash stays the reviewed one. Content whose first instruction is not a FROM is returned as is. */
105
+ export const rewriteOverlayBase = (content: string, base: string): string => {
106
+ let done = false;
107
+ return overlayLines(content)
108
+ .map(({ raw, kind }) => {
109
+ if (done || kind !== "instruction") {
110
+ return raw;
111
+ }
112
+ done = true;
113
+ return FROM.test(raw.trim()) ? raw.replace(FROM, `FROM ${base}`) : raw;
114
+ })
115
+ .join("\n");
116
+ };
@@ -0,0 +1,54 @@
1
+ import { generateKeyPairSync } from "node:crypto";
2
+ import { describe, expect, it } from "vitest";
3
+ import { mintReachabilityGrant, verifyReachabilityGrant } from "./ingress-contract.js";
4
+ import { isOwnerTicket, mintOwnerTicket, OWNER_TICKET_TTL_MS, publicKeyPemOf, verifyOwnerTicket } from "./owner-ticket.js";
5
+
6
+ const pair = generateKeyPairSync("ed25519");
7
+ const privatePem = pair.privateKey.export({ type: "pkcs8", format: "pem" }) as string;
8
+ const publicPem = pair.publicKey.export({ type: "spki", format: "pem" }) as string;
9
+ const NOW = 1_800_000_000_000;
10
+
11
+ describe("owner ticket", () => {
12
+ it("round-trips the claim and expires when it says", () => {
13
+ const ticket = mintOwnerTicket(privatePem, { sandboxId: "0123456789ab", email: "Owner@Example.com", issuedAtMs: NOW });
14
+ expect(isOwnerTicket(ticket)).toBe(true);
15
+ expect(verifyOwnerTicket(publicPem, ticket, NOW + 1_000)).toEqual({
16
+ sandboxId: "0123456789ab",
17
+ // Lowercased at mint, the way the daemon's owner store and the platform's rows spell an address.
18
+ email: "owner@example.com",
19
+ issuedAt: Math.floor(NOW / 1000),
20
+ expiresAt: Math.floor((NOW + OWNER_TICKET_TTL_MS) / 1000),
21
+ });
22
+ expect(verifyOwnerTicket(publicPem, ticket, NOW + OWNER_TICKET_TTL_MS + 1_000)).toBeUndefined();
23
+ });
24
+
25
+ it("derives the public half the machine env carries from the platform's private key", () => {
26
+ const ticket = mintOwnerTicket(privatePem, { sandboxId: "0123456789ab", email: "o@x.dev", issuedAtMs: NOW });
27
+ expect(verifyOwnerTicket(publicKeyPemOf(privatePem), ticket, NOW)).toMatchObject({ sandboxId: "0123456789ab", email: "o@x.dev" });
28
+ });
29
+
30
+ it("refuses another key's signature, a tampered claim, and garbage", () => {
31
+ const other = generateKeyPairSync("ed25519").publicKey.export({ type: "spki", format: "pem" }) as string;
32
+ const ticket = mintOwnerTicket(privatePem, { sandboxId: "0123456789ab", email: "o@x.dev", issuedAtMs: NOW });
33
+ expect(verifyOwnerTicket(other, ticket, NOW)).toBeUndefined();
34
+ const [prefix, , signature] = ticket.split(".");
35
+ const forged = Buffer.from(JSON.stringify({ sub: "0123456789ab", email: "thief@x.dev", iat: 1, exp: 9e9 })).toString("base64url");
36
+ expect(verifyOwnerTicket(publicPem, `${prefix}.${forged}.${signature}`, NOW)).toBeUndefined();
37
+ expect(verifyOwnerTicket(publicPem, "not a ticket", NOW)).toBeUndefined();
38
+ expect(isOwnerTicket("ig1.x.y")).toBe(false);
39
+ });
40
+
41
+ /* One key signs both the reachability grant and the owner ticket, and neither may ever pass as the other:
42
+ * a grant is a sandbox's right to serve its hostnames, a ticket is a person's right to drive it. */
43
+ it("is never a reachability grant, and a grant is never a ticket", () => {
44
+ const ticket = mintOwnerTicket(privatePem, { sandboxId: "0123456789ab", email: "o@x.dev", issuedAtMs: NOW });
45
+ const grant = mintReachabilityGrant(privatePem, "0123456789ab", NOW);
46
+ expect(verifyReachabilityGrant(publicPem, ticket)).toBeUndefined();
47
+ expect(verifyOwnerTicket(publicPem, grant, NOW)).toBeUndefined();
48
+ });
49
+
50
+ it("refuses to mint for anything but a real sandbox id and an owner", () => {
51
+ expect(() => mintOwnerTicket(privatePem, { sandboxId: "nope", email: "o@x.dev", issuedAtMs: NOW })).toThrow();
52
+ expect(() => mintOwnerTicket(privatePem, { sandboxId: "0123456789ab", email: "", issuedAtMs: NOW })).toThrow();
53
+ });
54
+ });
@@ -0,0 +1,102 @@
1
+ import { createPrivateKey, createPublicKey, sign as edSign, verify as edVerify } from "node:crypto";
2
+
3
+ /* THE OWNER TICKET: the platform's signed word for who owns a HOSTED sandbox, so the browser that just signed in
4
+ * to the platform can sign in to that sandbox's daemon without being asked for Google a second time.
5
+ *
6
+ * THE RULE IT BENDS, AND WHY THAT IS ALLOWED HERE. Every sandbox authenticates its owner against Google itself
7
+ * (the daemon's auth/auth.ts): the platform never holds or forges a daemon credential, so a platform breach can
8
+ * read a sandbox's address and drive nothing. That rule stands for every lane but one. On the HOSTED lane the
9
+ * platform already creates the machine, holds its power and its disk, and injects its owner's email into its
10
+ * env before the daemon ever runs (ARCHITECTURE.md names this the stated exception). A platform that can already
11
+ * open the machine gains no new power from being able to say "this browser is the owner" to it, and the user
12
+ * gains the one thing the second Google prompt was costing: a sign-in that is one sign-in.
13
+ *
14
+ * So a hosted daemon, and only a hosted daemon, accepts a ticket the platform signs with the SAME Ed25519 key
15
+ * that signs reachability grants (ingress-contract.ts), verified offline against the public half the machine's
16
+ * env carries (ENV_PLATFORM_PUBLIC_KEY, set by the provisioner and by nothing else). The ticket names the
17
+ * sandbox (its 12-hex id, which the daemon checks against its own) and the owner's email (which the daemon
18
+ * checks against OWNER_EMAIL on first-bind, exactly as it checks a Google proof), and it expires in minutes:
19
+ * it is spent once, on the daemon's /system/session, for the same daemon-minted session a Google proof buys.
20
+ *
21
+ * A different prefix from the grant's, so neither can ever be presented as the other: a grant verifies only as
22
+ * a grant, a ticket only as a ticket, under one key. */
23
+
24
+ const TICKET_PREFIX = "ot1";
25
+
26
+ // Long enough for the exchange it exists for (one round trip to the daemon after the platform answers), short
27
+ // enough that a ticket lifted from a network log is worthless by the time anyone reads it.
28
+ export const OWNER_TICKET_TTL_MS = 5 * 60_000;
29
+
30
+ // The env var a hosted machine carries the platform's public key in (PEM, SPKI). Absent on every other lane,
31
+ // which is what keeps the ticket a hosted-only credential: a daemon with no key verifies no ticket.
32
+ export const ENV_PLATFORM_PUBLIC_KEY = "PLATFORM_PUBLIC_KEY";
33
+
34
+ const base64url = (bytes: Buffer): string => bytes.toString("base64url");
35
+ const SANDBOX_ID = /^[0-9a-f]{12}$/;
36
+
37
+ // The signed claim, times in seconds like the grant's.
38
+ export interface OwnerTicket {
39
+ readonly sandboxId: string;
40
+ readonly email: string;
41
+ readonly issuedAt: number;
42
+ readonly expiresAt: number;
43
+ }
44
+
45
+ export const isOwnerTicket = (bearer: string): boolean => bearer.startsWith(`${TICKET_PREFIX}.`);
46
+
47
+ export const mintOwnerTicket = (
48
+ privateKeyPem: string,
49
+ claim: { readonly sandboxId: string; readonly email: string; readonly issuedAtMs: number; readonly ttlMs?: number },
50
+ ): string => {
51
+ if (!SANDBOX_ID.test(claim.sandboxId)) {
52
+ throw new Error(`an owner ticket names a 12-hex sandbox id, got "${claim.sandboxId}"`);
53
+ }
54
+ if (claim.email === "") {
55
+ throw new Error("an owner ticket names an owner");
56
+ }
57
+ const iat = Math.floor(claim.issuedAtMs / 1000);
58
+ const exp = Math.floor((claim.issuedAtMs + (claim.ttlMs ?? OWNER_TICKET_TTL_MS)) / 1000);
59
+ const payload = Buffer.from(JSON.stringify({ sub: claim.sandboxId, email: claim.email.toLowerCase(), iat, exp }), "utf8");
60
+ const signature = edSign(null, payload, createPrivateKey(privateKeyPem));
61
+ return `${TICKET_PREFIX}.${base64url(payload)}.${base64url(signature)}`;
62
+ };
63
+
64
+ /* Verify a ticket against the platform's public key, at `nowMs`. Undefined for every way of not being a valid
65
+ * ticket: the wrong prefix, a bad signature, a malformed claim, or one past its expiry. The CALLER checks that
66
+ * `sandboxId` is its own and that `email` is the owner it expects: those are the daemon's facts, not this
67
+ * function's. */
68
+ export const verifyOwnerTicket = (publicKeyPem: string, token: string, nowMs: number): OwnerTicket | undefined => {
69
+ const parts = token.split(".");
70
+ if (parts.length !== 3 || parts[0] !== TICKET_PREFIX) {
71
+ return undefined;
72
+ }
73
+ try {
74
+ const payload = Buffer.from(parts[1] as string, "base64url");
75
+ const signature = Buffer.from(parts[2] as string, "base64url");
76
+ if (!edVerify(null, payload, createPublicKey(publicKeyPem), signature)) {
77
+ return undefined;
78
+ }
79
+ const parsed = JSON.parse(payload.toString("utf8")) as { sub?: unknown; email?: unknown; iat?: unknown; exp?: unknown };
80
+ if (
81
+ typeof parsed.sub !== "string" ||
82
+ !SANDBOX_ID.test(parsed.sub) ||
83
+ typeof parsed.email !== "string" ||
84
+ parsed.email === "" ||
85
+ typeof parsed.iat !== "number" ||
86
+ typeof parsed.exp !== "number"
87
+ ) {
88
+ return undefined;
89
+ }
90
+ if (parsed.exp * 1000 <= nowMs) {
91
+ return undefined;
92
+ }
93
+ return { sandboxId: parsed.sub, email: parsed.email, issuedAt: parsed.iat, expiresAt: parsed.exp };
94
+ } catch {
95
+ return undefined;
96
+ }
97
+ };
98
+
99
+ // The public half of the platform's signing key, in the PEM the daemon's env carries. Derived rather than
100
+ // configured: one key, two readers (the ingress and every hosted daemon), no second value to keep in step.
101
+ export const publicKeyPemOf = (privateKeyPem: string): string =>
102
+ createPublicKey(createPrivateKey(privateKeyPem)).export({ type: "spki", format: "pem" }) as string;