@huynle/hub 0.6.1 → 0.7.2

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 (355) hide show
  1. package/CHANGELOG.md +870 -0
  2. package/Dockerfile +33 -0
  3. package/README.md +1007 -66
  4. package/package.json +47 -2
  5. package/packages/claude-code/package.json +12 -0
  6. package/packages/claude-code/src/bridge.ts +79 -0
  7. package/packages/claude-code/src/daemon.ts +339 -0
  8. package/packages/claude-code/src/hook-input.ts +44 -0
  9. package/packages/claude-code/src/ids.ts +27 -0
  10. package/packages/claude-code/src/index.ts +18 -0
  11. package/packages/claude-code/src/launcher.ts +265 -0
  12. package/packages/claude-code/src/main.ts +127 -0
  13. package/packages/claude-code/src/mcp.ts +109 -0
  14. package/packages/claude-code/src/paths.ts +34 -0
  15. package/packages/claude-code/src/plugin.ts +99 -0
  16. package/packages/claude-code/src/references.ts +51 -0
  17. package/packages/claude-code/src/sessions.ts +600 -0
  18. package/packages/claude-code/src/tools.ts +66 -0
  19. package/packages/claude-code/src/wire.ts +71 -0
  20. package/packages/claude-code/src/wrapper.ts +156 -0
  21. package/packages/cli/dist/hub-cli.js +21647 -0
  22. package/packages/cli/package.json +11 -0
  23. package/packages/cli/src/acting.ts +70 -0
  24. package/packages/cli/src/args.ts +91 -0
  25. package/packages/cli/src/bootstrap/adapters/archive-stream.ts +276 -0
  26. package/packages/cli/src/bootstrap/adapters/claude/README.md +63 -0
  27. package/packages/cli/src/bootstrap/adapters/claude/adapter.ts +250 -0
  28. package/packages/cli/src/bootstrap/adapters/claude/index.ts +42 -0
  29. package/packages/cli/src/bootstrap/adapters/claude/node.ts +99 -0
  30. package/packages/cli/src/bootstrap/adapters/claude/openpgp.ts +221 -0
  31. package/packages/cli/src/bootstrap/adapters/claude/ports.ts +44 -0
  32. package/packages/cli/src/bootstrap/adapters/claude/source.ts +160 -0
  33. package/packages/cli/src/bootstrap/adapters/claude/verify.ts +165 -0
  34. package/packages/cli/src/bootstrap/adapters/codex/archive.ts +103 -0
  35. package/packages/cli/src/bootstrap/adapters/codex/index.ts +60 -0
  36. package/packages/cli/src/bootstrap/adapters/codex/plan.ts +129 -0
  37. package/packages/cli/src/bootstrap/adapters/codex/sigstore.ts +298 -0
  38. package/packages/cli/src/bootstrap/adapters/codex/source.ts +102 -0
  39. package/packages/cli/src/bootstrap/adapters/codex/verify.ts +298 -0
  40. package/packages/cli/src/bootstrap/adapters/inflate.ts +433 -0
  41. package/packages/cli/src/bootstrap/adapters/node-services/control.ts +392 -0
  42. package/packages/cli/src/bootstrap/adapters/node-services/index.ts +23 -0
  43. package/packages/cli/src/bootstrap/adapters/node-services/launch.ts +223 -0
  44. package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +222 -0
  45. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +183 -0
  46. package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +197 -0
  47. package/packages/cli/src/bootstrap/adapters/opencode/README.md +204 -0
  48. package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +205 -0
  49. package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +91 -0
  50. package/packages/cli/src/bootstrap/adapters/opencode/config.ts +212 -0
  51. package/packages/cli/src/bootstrap/adapters/opencode/index.ts +81 -0
  52. package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +266 -0
  53. package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +208 -0
  54. package/packages/cli/src/bootstrap/adapters/readiness.ts +19 -0
  55. package/packages/cli/src/bootstrap/ansible/index.ts +2 -0
  56. package/packages/cli/src/bootstrap/ansible/render.ts +279 -0
  57. package/packages/cli/src/bootstrap/contracts/actions.ts +470 -0
  58. package/packages/cli/src/bootstrap/contracts/artifact.ts +323 -0
  59. package/packages/cli/src/bootstrap/contracts/canonical.ts +54 -0
  60. package/packages/cli/src/bootstrap/contracts/credentials.ts +108 -0
  61. package/packages/cli/src/bootstrap/contracts/discovery.ts +215 -0
  62. package/packages/cli/src/bootstrap/contracts/envelope.ts +143 -0
  63. package/packages/cli/src/bootstrap/contracts/exec.ts +74 -0
  64. package/packages/cli/src/bootstrap/contracts/harness.ts +202 -0
  65. package/packages/cli/src/bootstrap/contracts/index.ts +28 -0
  66. package/packages/cli/src/bootstrap/contracts/journal.ts +198 -0
  67. package/packages/cli/src/bootstrap/contracts/paths.ts +91 -0
  68. package/packages/cli/src/bootstrap/contracts/plan.ts +155 -0
  69. package/packages/cli/src/bootstrap/contracts/platform.ts +62 -0
  70. package/packages/cli/src/bootstrap/contracts/preview.ts +190 -0
  71. package/packages/cli/src/bootstrap/contracts/profile.ts +105 -0
  72. package/packages/cli/src/bootstrap/contracts/secrets.ts +52 -0
  73. package/packages/cli/src/bootstrap/contracts/validate.ts +212 -0
  74. package/packages/cli/src/bootstrap/discovery/discover.ts +146 -0
  75. package/packages/cli/src/bootstrap/discovery/executables.ts +134 -0
  76. package/packages/cli/src/bootstrap/discovery/host.ts +96 -0
  77. package/packages/cli/src/bootstrap/discovery/index.ts +6 -0
  78. package/packages/cli/src/bootstrap/discovery/node-probe.ts +97 -0
  79. package/packages/cli/src/bootstrap/discovery/probe.ts +41 -0
  80. package/packages/cli/src/bootstrap/discovery/util.ts +13 -0
  81. package/packages/cli/src/bootstrap/enrollment/enroll.ts +226 -0
  82. package/packages/cli/src/bootstrap/enrollment/index.ts +7 -0
  83. package/packages/cli/src/bootstrap/enrollment/policy.ts +54 -0
  84. package/packages/cli/src/bootstrap/enrollment/result.ts +63 -0
  85. package/packages/cli/src/bootstrap/enrollment/status.ts +119 -0
  86. package/packages/cli/src/bootstrap/enrollment/store.ts +173 -0
  87. package/packages/cli/src/bootstrap/preview/index.ts +5 -0
  88. package/packages/cli/src/bootstrap/preview/links.ts +31 -0
  89. package/packages/cli/src/bootstrap/preview/plan.ts +242 -0
  90. package/packages/cli/src/bootstrap/preview/preview.ts +104 -0
  91. package/packages/cli/src/bootstrap/preview/render.ts +30 -0
  92. package/packages/cli/src/bootstrap/profiles/audit.ts +87 -0
  93. package/packages/cli/src/bootstrap/profiles/catalog.ts +49 -0
  94. package/packages/cli/src/bootstrap/profiles/desired.ts +96 -0
  95. package/packages/cli/src/bootstrap/profiles/drift.ts +286 -0
  96. package/packages/cli/src/bootstrap/profiles/history.ts +263 -0
  97. package/packages/cli/src/bootstrap/profiles/index.ts +11 -0
  98. package/packages/cli/src/bootstrap/profiles/upgrade.ts +276 -0
  99. package/packages/cli/src/bootstrap/profiles/versions.ts +50 -0
  100. package/packages/cli/src/bootstrap/release/README.md +115 -0
  101. package/packages/cli/src/bootstrap/release/assets.ts +39 -0
  102. package/packages/cli/src/bootstrap/release/cli.ts +332 -0
  103. package/packages/cli/src/bootstrap/release/harness-pins.json +94 -0
  104. package/packages/cli/src/bootstrap/release/manifest.ts +118 -0
  105. package/packages/cli/src/bootstrap/release/pins.ts +192 -0
  106. package/packages/cli/src/bootstrap/release/text-assets.d.ts +9 -0
  107. package/packages/cli/src/bootstrap/release/trust-root.json +13 -0
  108. package/packages/cli/src/bootstrap/release/trust-root.ts +39 -0
  109. package/packages/cli/src/bootstrap/release/verify.ts +88 -0
  110. package/packages/cli/src/bootstrap/transaction/builtin.ts +312 -0
  111. package/packages/cli/src/bootstrap/transaction/engine.ts +994 -0
  112. package/packages/cli/src/bootstrap/transaction/executor.ts +112 -0
  113. package/packages/cli/src/bootstrap/transaction/fs.ts +194 -0
  114. package/packages/cli/src/bootstrap/transaction/index.ts +12 -0
  115. package/packages/cli/src/bootstrap/transaction/json-merge.ts +403 -0
  116. package/packages/cli/src/bootstrap/transaction/lock.ts +132 -0
  117. package/packages/cli/src/bootstrap/transaction/resolve.ts +48 -0
  118. package/packages/cli/src/bootstrap/transaction/result.ts +60 -0
  119. package/packages/cli/src/bootstrap/transaction/state.ts +88 -0
  120. package/packages/cli/src/bootstrap/transaction/store.ts +189 -0
  121. package/packages/cli/src/bootstrap/transaction/stream.ts +254 -0
  122. package/packages/cli/src/cli.ts +814 -0
  123. package/packages/cli/src/credentials.ts +75 -0
  124. package/packages/cli/src/decisions.ts +106 -0
  125. package/packages/cli/src/format.ts +185 -0
  126. package/packages/cli/src/grants.ts +105 -0
  127. package/packages/cli/src/http.ts +86 -0
  128. package/packages/cli/src/launches.ts +157 -0
  129. package/packages/cli/src/login.ts +160 -0
  130. package/packages/cli/src/main.ts +63 -0
  131. package/packages/cli/src/mcp.ts +879 -0
  132. package/packages/cli/src/messaging.ts +108 -0
  133. package/packages/cli/src/node.ts +279 -0
  134. package/packages/cli/src/openshell.ts +166 -0
  135. package/packages/cli/src/setup/commands.ts +809 -0
  136. package/packages/cli/src/setup/contributors.ts +171 -0
  137. package/packages/cli/src/setup/dispatch.ts +74 -0
  138. package/packages/cli/src/setup/launch-driver.ts +109 -0
  139. package/packages/cli/src/setup/runtime.ts +456 -0
  140. package/packages/cli/src/setup/spoke-probe.ts +120 -0
  141. package/packages/cli/src/setup/state.ts +244 -0
  142. package/packages/cli/src/setup/user-bin.ts +39 -0
  143. package/packages/cli/src/spoke-services.ts +109 -0
  144. package/packages/cli/src/watch.ts +28 -0
  145. package/packages/codex/package.json +12 -0
  146. package/packages/codex/src/app-server.ts +44 -0
  147. package/packages/codex/src/ids.ts +28 -0
  148. package/packages/codex/src/index.ts +11 -0
  149. package/packages/codex/src/jsonrpc.ts +168 -0
  150. package/packages/codex/src/main.ts +123 -0
  151. package/packages/codex/src/paths.ts +65 -0
  152. package/packages/codex/src/sessions.ts +578 -0
  153. package/packages/codex/src/spoke.ts +252 -0
  154. package/packages/codex/src/stdio.ts +101 -0
  155. package/packages/codex/src/websocket.ts +206 -0
  156. package/packages/codex/src/wrapper.ts +104 -0
  157. package/packages/node/package.json +12 -0
  158. package/packages/node/src/config.ts +184 -0
  159. package/packages/node/src/connection.ts +283 -0
  160. package/packages/node/src/daemon.ts +281 -0
  161. package/packages/node/src/git.ts +107 -0
  162. package/packages/node/src/harnesses.ts +95 -0
  163. package/packages/node/src/host.ts +319 -0
  164. package/packages/node/src/index.ts +17 -0
  165. package/packages/node/src/main.ts +11 -0
  166. package/packages/node/src/openshell.ts +73 -0
  167. package/packages/node/src/run.ts +106 -0
  168. package/packages/preset/package.json +12 -0
  169. package/packages/preset/presets/aider.json +8 -0
  170. package/packages/preset/presets/goose.json +8 -0
  171. package/packages/preset/src/index.ts +2 -0
  172. package/packages/preset/src/main.ts +195 -0
  173. package/packages/preset/src/presets.ts +87 -0
  174. package/packages/preset/src/spoke.ts +251 -0
  175. package/packages/preset/src/tmux.ts +160 -0
  176. package/packages/protocol/package.json +12 -0
  177. package/packages/protocol/src/harness-events.ts +207 -0
  178. package/packages/protocol/src/index.ts +1215 -0
  179. package/packages/protocol/src/launch.ts +172 -0
  180. package/packages/protocol/src/node.ts +409 -0
  181. package/packages/protocol/src/preset.ts +144 -0
  182. package/packages/protocol/src/webhook.ts +62 -0
  183. package/packages/protocol/src/wire.ts +54 -0
  184. package/packages/runtime-openshell/image/Dockerfile +36 -0
  185. package/packages/runtime-openshell/image/README.md +78 -0
  186. package/packages/runtime-openshell/image/build.ts +203 -0
  187. package/packages/runtime-openshell/image/entry.sh +163 -0
  188. package/packages/runtime-openshell/image/image.ts +119 -0
  189. package/packages/runtime-openshell/image/start.sh +24 -0
  190. package/packages/runtime-openshell/package.json +12 -0
  191. package/packages/runtime-openshell/src/cli.ts +584 -0
  192. package/packages/runtime-openshell/src/index.ts +6 -0
  193. package/packages/runtime-openshell/src/policy.ts +155 -0
  194. package/packages/runtime-openshell/src/profiles.ts +181 -0
  195. package/packages/runtime-openshell/src/runtime.ts +475 -0
  196. package/packages/runtime-sdk/package.json +12 -0
  197. package/packages/runtime-sdk/src/index.ts +87 -0
  198. package/packages/sdk/package.json +12 -0
  199. package/packages/sdk/src/address.ts +25 -0
  200. package/packages/sdk/src/client.ts +553 -0
  201. package/packages/sdk/src/envelope.ts +108 -0
  202. package/packages/sdk/src/errors.ts +107 -0
  203. package/packages/sdk/src/index.ts +10 -0
  204. package/packages/sdk/src/sse.ts +70 -0
  205. package/packages/sdk/src/types.ts +517 -0
  206. package/packages/sdk/src/webhook.ts +102 -0
  207. package/packages/spoke-sdk/package.json +12 -0
  208. package/packages/spoke-sdk/src/activity.ts +3 -0
  209. package/packages/spoke-sdk/src/connection.ts +295 -0
  210. package/packages/spoke-sdk/src/delivery.ts +119 -0
  211. package/packages/spoke-sdk/src/enroll.ts +101 -0
  212. package/packages/spoke-sdk/src/harness-hello.ts +79 -0
  213. package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
  214. package/packages/spoke-sdk/src/index.ts +12 -0
  215. package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
  216. package/packages/spoke-sdk/src/prompt-source.ts +8 -0
  217. package/packages/spoke-sdk/src/session-ids.ts +49 -0
  218. package/packages/spoke-sdk/src/session-tokens.ts +78 -0
  219. package/scripts/hub-claude-code.service +34 -0
  220. package/scripts/hub-codex-spoke.service +34 -0
  221. package/scripts/hub-node.service +35 -0
  222. package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
  223. package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
  224. package/scripts/io.github.huynle.hub-node.plist +52 -0
  225. package/src/central/actions.ts +233 -0
  226. package/src/central/agent/acting-routes.ts +133 -0
  227. package/src/central/agent/acting-switch.ts +93 -0
  228. package/src/central/agent/acting.ts +599 -0
  229. package/src/central/agent/actor.ts +193 -0
  230. package/src/central/agent/app-tokens.ts +152 -0
  231. package/src/central/agent/archive-inactive.ts +66 -0
  232. package/src/central/agent/audit.ts +185 -0
  233. package/src/central/agent/decisions.ts +530 -0
  234. package/src/central/agent/delivery.ts +678 -0
  235. package/src/central/agent/directory-routes.ts +328 -0
  236. package/src/central/agent/directory.ts +473 -0
  237. package/src/central/agent/envelope.ts +51 -0
  238. package/src/central/agent/errors.ts +74 -0
  239. package/src/central/agent/escalation.ts +219 -0
  240. package/src/central/agent/event-cursor.ts +49 -0
  241. package/src/central/agent/event-writers.ts +181 -0
  242. package/src/central/agent/events-routes.ts +148 -0
  243. package/src/central/agent/events.ts +277 -0
  244. package/src/central/agent/grant-requests.ts +221 -0
  245. package/src/central/agent/grant-routes.ts +70 -0
  246. package/src/central/agent/grants.ts +235 -0
  247. package/src/central/agent/launch-engine.ts +1025 -0
  248. package/src/central/agent/launch-routes.ts +252 -0
  249. package/src/central/agent/launch-spec.ts +289 -0
  250. package/src/central/agent/launch-store.ts +264 -0
  251. package/src/central/agent/launch-tokens.ts +206 -0
  252. package/src/central/agent/message-limits.ts +123 -0
  253. package/src/central/agent/message-routes.ts +385 -0
  254. package/src/central/agent/messages.ts +442 -0
  255. package/src/central/agent/names.ts +86 -0
  256. package/src/central/agent/placement.ts +419 -0
  257. package/src/central/agent/policy.ts +378 -0
  258. package/src/central/agent/read-routes.ts +385 -0
  259. package/src/central/agent/routes.ts +281 -0
  260. package/src/central/agent/session-tokens.ts +227 -0
  261. package/src/central/agent/versioned.ts +26 -0
  262. package/src/central/agent/watch.ts +112 -0
  263. package/src/central/agent/webhook-routes.ts +86 -0
  264. package/src/central/agent/webhooks.ts +493 -0
  265. package/src/central/agent-data.ts +177 -0
  266. package/src/central/auth.ts +350 -121
  267. package/src/central/backend/file-state.ts +240 -0
  268. package/src/central/backend/index.ts +116 -0
  269. package/src/central/backend/memory-bus.ts +93 -0
  270. package/src/central/backend/memory-catalog.ts +90 -0
  271. package/src/central/backend/memory-counters.ts +102 -0
  272. package/src/central/backend/memory-events.ts +45 -0
  273. package/src/central/backend/memory-kv.ts +38 -0
  274. package/src/central/backend/memory-state.ts +77 -0
  275. package/src/central/backend/postgres-migrate.ts +100 -0
  276. package/src/central/backend/postgres-state.ts +197 -0
  277. package/src/central/backend/types.ts +246 -0
  278. package/src/central/backend/valkey-bus.ts +425 -0
  279. package/src/central/backend/valkey-catalog.ts +190 -0
  280. package/src/central/backend/valkey-client.ts +36 -0
  281. package/src/central/backend/valkey-counters.ts +38 -0
  282. package/src/central/backend/valkey-events.ts +168 -0
  283. package/src/central/backend/valkey-kv.ts +54 -0
  284. package/src/central/backend/valkey-locks.ts +92 -0
  285. package/src/central/cluster/directory.ts +141 -0
  286. package/src/central/cluster/feed.ts +84 -0
  287. package/src/central/cluster/leader.ts +232 -0
  288. package/src/central/cluster/live.ts +151 -0
  289. package/src/central/cluster/recommend.ts +59 -0
  290. package/src/central/cluster/rpc.ts +150 -0
  291. package/src/central/cluster/view.ts +232 -0
  292. package/src/central/config.ts +197 -3
  293. package/src/central/done.ts +96 -0
  294. package/src/central/events-log.ts +100 -0
  295. package/src/central/http.ts +1149 -170
  296. package/src/central/import.ts +318 -0
  297. package/src/central/main.ts +211 -12
  298. package/src/central/mcp-http.ts +116 -0
  299. package/src/central/nodes.ts +314 -0
  300. package/src/central/oidc.ts +34 -14
  301. package/src/central/push-target.ts +118 -18
  302. package/src/central/push.ts +256 -82
  303. package/src/central/registry.ts +902 -92
  304. package/src/central/say-stream.ts +128 -0
  305. package/src/central/session-token-push.ts +233 -0
  306. package/src/central/speech/openai.ts +21 -14
  307. package/src/central/speech/openrouter.ts +27 -10
  308. package/src/central/speech/provider.ts +54 -0
  309. package/src/central/speech/service.ts +22 -7
  310. package/src/central/spoke-ws.ts +522 -44
  311. package/src/central/store-shared.ts +518 -0
  312. package/src/central/store.ts +625 -189
  313. package/src/central/token-keys.ts +165 -0
  314. package/src/config.ts +118 -7
  315. package/src/index.ts +195 -43
  316. package/src/protocol.ts +3 -650
  317. package/src/rpc.ts +27 -2
  318. package/src/spoke/client.ts +3 -272
  319. package/src/spoke/conversation.ts +16 -12
  320. package/src/spoke/deliver.ts +75 -0
  321. package/src/spoke/enroll.ts +2 -79
  322. package/src/spoke/env-inject.ts +160 -0
  323. package/src/spoke/hub-rpc.ts +115 -0
  324. package/src/spoke/hub-tool.ts +409 -0
  325. package/src/spoke/launch-source.ts +22 -0
  326. package/src/spoke/launchers.ts +174 -0
  327. package/src/spoke/locations.ts +3 -2
  328. package/src/spoke/node-roots.ts +61 -0
  329. package/src/spoke/prompt-refs.ts +116 -0
  330. package/src/spoke/session-tokens.ts +2 -0
  331. package/src/spoke/spoke.ts +465 -28
  332. package/src/spoke/taskboard.ts +243 -0
  333. package/src/spoke/timeline.ts +184 -6
  334. package/src/spoke/tracker.ts +79 -4
  335. package/src/status.ts +203 -10
  336. package/src/tui-commands.ts +13 -6
  337. package/src/tui-messaging.ts +93 -0
  338. package/src/tui.ts +15 -0
  339. package/web/dist/apple-touch-icon.png +0 -0
  340. package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
  341. package/web/dist/assets/index-DWnMhXk9.js +20 -0
  342. package/web/dist/assets/index-DmUXEnw9.css +1 -0
  343. package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
  344. package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
  345. package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
  346. package/web/dist/favicon.ico +0 -0
  347. package/web/dist/icon-192.png +0 -0
  348. package/web/dist/icon-512.png +0 -0
  349. package/web/dist/icon-maskable-512.png +0 -0
  350. package/web/dist/icon.svg +5 -5
  351. package/web/dist/index.html +17 -4
  352. package/web/dist/manifest.webmanifest +2 -2
  353. package/web/dist/sw.js +79 -15
  354. package/web/dist/assets/index-DClBvfLL.css +0 -1
  355. package/web/dist/assets/index-Djo_yF0a.js +0 -12
@@ -0,0 +1,584 @@
1
+ // The node's only way to drive NVIDIA OpenShell (decision lk6gn3u7 §1): the `openshell` CLI, never a shell. Each call
2
+ // is spawn(binary, argv) with the global flags first (--gateway when configured, --workspace hub, --color never),
3
+ // `-o json` where the CLI has it, a time limit (the whole process group is killed) and cancellation. The first call
4
+ // checks `openshell --version` (0.1.x only: the CLI and its JSON change between minor versions). Errors are
5
+ // OpenShellErrors with a code and a short reason taken from the CLI's stderr, with the call's env values, the
6
+ // configured secrets and URL credentials removed: fit for the hub and a launch's reason.
7
+ //
8
+ // Names and labels are checked here, before anything runs, so nothing the hub sends can become a flag.
9
+ // node:child_process (not Bun.spawn) so the node also runs bundled for node.
10
+ import { spawn } from "node:child_process"
11
+ import path from "node:path"
12
+
13
+ /** The OpenShell workspace the hub's sandboxes live in. */
14
+ export const OPENSHELL_WORKSPACE = "hub"
15
+ /** The CLI versions this client speaks (`openshell --version`). */
16
+ export const SUPPORTED_VERSIONS = "0.1.x"
17
+ export const DEFAULT_TIMEOUT_MS = 60_000
18
+ /** Creating waits for the sandbox to come up (~20 s cold on the vm driver). */
19
+ export const CREATE_TIMEOUT_MS = 300_000
20
+ /** `sandbox exec` reads at most this much stdin. */
21
+ export const MAX_EXEC_STDIN = 4 * 1024 * 1024
22
+ /** The process limit beyond an exec's or a policy wait's own --timeout. */
23
+ const GRACE_MS = 30_000
24
+ const MAX_OUTPUT = 16 * 1024 * 1024
25
+ const REASON_MAX = 300
26
+ /** Inherited settings that would change what a call does; the client's flags decide instead. */
27
+ const DROPPED_ENV = new Set(["OPENSHELL_SANDBOX_POLICY", "OPENSHELL_WORKSPACE", "OPENSHELL_GATEWAY", "OPENSHELL_GATEWAY_ENDPOINT", "OPENSHELL_GATEWAY_INSECURE", "OPENSHELL_COLOR"])
28
+
29
+ const NAME = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/
30
+ const LABEL_KEY = /^([a-z0-9]([a-z0-9.-]{0,251}[a-z0-9])?\/)?[A-Za-z0-9]([A-Za-z0-9._-]{0,61}[A-Za-z0-9])?$/
31
+ const LABEL_VALUE = /^([A-Za-z0-9]([A-Za-z0-9._-]{0,61}[A-Za-z0-9])?)?$/
32
+ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
33
+ const RESOURCE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$/
34
+
35
+ type Env = Readonly<Record<string, string | undefined>>
36
+
37
+ export type OpenShellErrorCode =
38
+ | "not_installed"
39
+ | "unsupported_version"
40
+ | "invalid"
41
+ | "not_found"
42
+ | "already_exists"
43
+ | "timeout"
44
+ | "cancelled"
45
+ | "bad_output"
46
+ | "failed"
47
+
48
+ export class OpenShellError extends Error {
49
+ override readonly name = "OpenShellError"
50
+ constructor(
51
+ message: string,
52
+ readonly code: OpenShellErrorCode,
53
+ readonly exitCode?: number,
54
+ ) {
55
+ super(message)
56
+ }
57
+ }
58
+
59
+ export type SandboxPhase = "Provisioning" | "Ready" | "Error" | "Deleting" | "Stopping" | "Stopped" | "Starting" | "Completed" | "Unspecified" | "Unknown"
60
+
61
+ export interface SandboxCondition {
62
+ readonly type: string
63
+ readonly status: string
64
+ readonly reason: string
65
+ readonly message: string
66
+ }
67
+
68
+ /** A sandbox as `sandbox get/list/create -o json` reports it. */
69
+ export interface Sandbox {
70
+ readonly id: string
71
+ readonly name: string
72
+ readonly workspace: string
73
+ readonly phase: SandboxPhase
74
+ readonly labels: Readonly<Record<string, string>>
75
+ /** The main command's exit code once it ended, else null. */
76
+ readonly exitCode: number | null
77
+ readonly createdAt: string
78
+ readonly conditions: readonly SandboxCondition[]
79
+ }
80
+
81
+ export interface GatewayStatus {
82
+ readonly gateway: string
83
+ readonly server: string
84
+ readonly connected: boolean
85
+ readonly authenticated: boolean
86
+ readonly version: string
87
+ }
88
+
89
+ /** What `gateway info -o json` tells the node: the gateway's compute drivers (e.g. "vm"), its sandbox backends. */
90
+ export interface GatewayInfo {
91
+ readonly computeDrivers: readonly string[]
92
+ }
93
+
94
+ export interface CreateSandboxOptions {
95
+ readonly name: string
96
+ /** A container image reference or rootfs tar (`--from`); or a template; or neither (the gateway's default image). */
97
+ readonly from?: string
98
+ readonly template?: string
99
+ /** Policy YAML file (`--policy`). */
100
+ readonly policyFile?: string
101
+ /** Credential providers the node operator created (`--provider`). */
102
+ readonly providers?: readonly string[]
103
+ readonly labels?: Readonly<Record<string, string>>
104
+ /** Non-secret environment (`--env`); secrets go through providers. */
105
+ readonly env?: Readonly<Record<string, string>>
106
+ /** The main command, started detached. */
107
+ readonly command?: readonly string[]
108
+ }
109
+
110
+ export interface ExecOptions {
111
+ readonly workdir?: string
112
+ /** OpenShell's own limit (seconds); the process gets this plus a grace period. */
113
+ readonly timeoutSec?: number
114
+ readonly env?: Readonly<Record<string, string>>
115
+ readonly stdin?: string | Uint8Array
116
+ }
117
+
118
+ export interface ExecResult {
119
+ /** The remote command's exit code (openshell exits with it; a CLI failure also shows here, usually as 1). */
120
+ readonly exitCode: number
121
+ readonly stdout: string
122
+ readonly stderr: string
123
+ }
124
+
125
+ export interface PolicySetOptions {
126
+ /** Wait for the sandbox to load the policy. */
127
+ readonly wait?: boolean
128
+ readonly timeoutSec?: number
129
+ }
130
+
131
+ /** Per-call cancellation. */
132
+ export interface CallOptions {
133
+ readonly signal?: AbortSignal
134
+ }
135
+
136
+ /** What the openshell runtime needs from OpenShell; OpenShellCli implements it over the CLI. */
137
+ export interface OpenShellClient {
138
+ version(options?: CallOptions): Promise<string>
139
+ status(options?: CallOptions): Promise<GatewayStatus>
140
+ /** Optional: clients without it report no backends. */
141
+ gatewayInfo?(options?: CallOptions): Promise<GatewayInfo>
142
+ create(sandbox: CreateSandboxOptions, options?: CallOptions): Promise<Sandbox>
143
+ /** undefined when there is no such sandbox. */
144
+ get(name: string, options?: CallOptions): Promise<Sandbox | undefined>
145
+ list(selector?: Readonly<Record<string, string>>, options?: CallOptions): Promise<Sandbox[]>
146
+ exec(name: string, command: readonly string[], exec?: ExecOptions & CallOptions): Promise<ExecResult>
147
+ upload(name: string, localPath: string, sandboxPath?: string, options?: CallOptions): Promise<void>
148
+ download(name: string, sandboxPath: string, localPath: string, options?: CallOptions): Promise<void>
149
+ stop(name: string, options?: CallOptions): Promise<void>
150
+ /** Asks for deletion (OpenShell deletes asynchronously: poll get until undefined); a missing sandbox is fine. */
151
+ delete(name: string, options?: CallOptions): Promise<void>
152
+ policySet(name: string, policyFile: string, policy?: PolicySetOptions & CallOptions): Promise<void>
153
+ /** Optional, read-only: the gateway's workspace names (`hub node openshell check`). */
154
+ workspaces?(options?: CallOptions): Promise<string[]>
155
+ /** Optional, read-only: the provider names in the client's workspace (`hub node openshell check`). */
156
+ providers?(options?: CallOptions): Promise<string[]>
157
+ }
158
+
159
+ export interface OpenShellCliOptions {
160
+ /** The executable (default "openshell" on PATH). */
161
+ readonly binary?: string
162
+ /** A gateway name (`--gateway`); default: the CLI's active gateway. */
163
+ readonly gateway?: string
164
+ /** Default OPENSHELL_WORKSPACE ("hub"). */
165
+ readonly workspace?: string
166
+ /** Limit per call (default 60 s); create gets CREATE_TIMEOUT_MS unless createTimeoutMs is set. */
167
+ readonly timeoutMs?: number
168
+ readonly createTimeoutMs?: number
169
+ /** `sandbox list --page-size` (default 100). */
170
+ readonly pageSize?: number
171
+ /** The environment to start from (default process.env); OpenShell overrides are dropped from it. */
172
+ readonly env?: Env
173
+ /** Strings never to show in an error (tokens the caller knows about). */
174
+ readonly secrets?: readonly string[]
175
+ }
176
+
177
+ interface RunOptions {
178
+ readonly timeoutMs?: number
179
+ readonly signal?: AbortSignal
180
+ readonly stdin?: string | Uint8Array
181
+ /** Extra strings to remove from errors (the call's env values). */
182
+ readonly hide?: readonly string[]
183
+ /** What the error names ("sandbox create"). */
184
+ readonly what: string
185
+ }
186
+
187
+ interface RunResult {
188
+ readonly exitCode: number
189
+ readonly stdout: string
190
+ readonly stderr: string
191
+ }
192
+
193
+ // ---- checks ----
194
+
195
+ function invalid(message: string): OpenShellError {
196
+ return new OpenShellError(message, "invalid")
197
+ }
198
+
199
+ function checkName(name: string): void {
200
+ if (!NAME.test(name)) throw invalid(`invalid sandbox name ${JSON.stringify(name)}: lowercase letters, digits and '-', at most 63`)
201
+ }
202
+
203
+ function checkLabels(labels: Readonly<Record<string, string>>): void {
204
+ for (const [k, v] of Object.entries(labels)) {
205
+ if (!LABEL_KEY.test(k)) throw invalid(`invalid label key ${JSON.stringify(k)}`)
206
+ if (!LABEL_VALUE.test(v)) throw invalid(`invalid value for label ${k}: letters, digits, '.', '_' and '-', at most 63`)
207
+ }
208
+ }
209
+
210
+ function checkResource(what: string, value: string): void {
211
+ if (!RESOURCE.test(value)) throw invalid(`invalid ${what} ${JSON.stringify(value)}`)
212
+ }
213
+
214
+ /** A path argument that cannot be read as a flag. */
215
+ function checkPath(what: string, value: string): void {
216
+ if (value === "" || value.startsWith("-") || value.includes("\0")) throw invalid(`invalid ${what} ${JSON.stringify(value)}`)
217
+ }
218
+
219
+ function envArgs(flag: string, env: Readonly<Record<string, string>> | undefined): string[] {
220
+ const args: string[] = []
221
+ for (const [k, v] of Object.entries(env ?? {})) {
222
+ if (!ENV_NAME.test(k)) throw invalid(`invalid environment variable name ${JSON.stringify(k)}`)
223
+ if (v.includes("\0")) throw invalid(`invalid value for ${k}`)
224
+ args.push(flag, `${k}=${v}`)
225
+ }
226
+ return args
227
+ }
228
+
229
+ // ---- output ----
230
+
231
+ const ANSI = /\u001b\[[0-9;?]*[A-Za-z]/g
232
+
233
+ function escapeRegExp(s: string): string {
234
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
235
+ }
236
+
237
+ /** Removes the given strings, URL credentials and bearer tokens. */
238
+ export function redact(text: string, hide: readonly string[] = []): string {
239
+ let out = text
240
+ for (const s of [...hide].filter((s) => s.length >= 3).sort((a, b) => b.length - a.length)) out = out.replace(new RegExp(escapeRegExp(s), "g"), "***")
241
+ return out.replace(/([a-z][a-z0-9+.-]*:\/\/)[^\s/@]+@/gi, "$1***@").replace(/(bearer\s+)[^\s"']+/gi, "$1***")
242
+ }
243
+
244
+ /** The gRPC message from a miette/tonic error, else the stderr's text, on one line. */
245
+ function reasonOf(stderr: string): string {
246
+ const plain = stderr.replace(ANSI, "")
247
+ const grpc = /message: "((?:[^"\\]|\\.)*)"/.exec(plain)
248
+ const text = grpc ? grpc[1]! : plain
249
+ return text
250
+ .split("\n")
251
+ .map((line) => line.replace(/^\s*(Error:)?\s*[×x]?\s*/, "").trim())
252
+ .filter(Boolean)
253
+ .join(" ")
254
+ }
255
+
256
+ function cut(text: string): string {
257
+ return text.length > REASON_MAX ? `${text.slice(0, REASON_MAX)}…` : text
258
+ }
259
+
260
+ function parseJSON<T>(what: string, stdout: string): T {
261
+ try {
262
+ return JSON.parse(stdout) as T
263
+ } catch {
264
+ throw new OpenShellError(`openshell ${what}: output is not JSON`, "bad_output")
265
+ }
266
+ }
267
+
268
+ const str = (v: unknown): string => (typeof v === "string" ? v : "")
269
+
270
+ function toSandbox(raw: unknown, what: string): Sandbox {
271
+ if (!raw || typeof raw !== "object") throw new OpenShellError(`openshell ${what}: no sandbox in the output`, "bad_output")
272
+ const o = raw as Record<string, unknown>
273
+ if (typeof o.name !== "string" || typeof o.phase !== "string") throw new OpenShellError(`openshell ${what}: no sandbox in the output`, "bad_output")
274
+ const labels: Record<string, string> = {}
275
+ if (o.labels && typeof o.labels === "object") for (const [k, v] of Object.entries(o.labels)) if (typeof v === "string") labels[k] = v
276
+ const conditions = Array.isArray(o.conditions)
277
+ ? o.conditions.map((c: Record<string, unknown>) => ({ type: str(c?.type), status: str(c?.status), reason: str(c?.reason), message: str(c?.message) }))
278
+ : []
279
+ return {
280
+ id: str(o.id),
281
+ name: o.name,
282
+ workspace: str(o.workspace),
283
+ phase: o.phase as SandboxPhase,
284
+ labels,
285
+ exitCode: typeof o.exit_code === "number" ? o.exit_code : null,
286
+ createdAt: str(o.created_at),
287
+ conditions,
288
+ }
289
+ }
290
+
291
+ // ---- the client ----
292
+
293
+ export class OpenShellCli implements OpenShellClient {
294
+ readonly workspace: string
295
+ private readonly binary: string
296
+ private readonly timeoutMs: number
297
+ private readonly createTimeoutMs: number
298
+ private readonly pageSize: number
299
+ private readonly env: Record<string, string>
300
+ private readonly secrets: readonly string[]
301
+ private readonly globals: readonly string[]
302
+ private checked?: Promise<string>
303
+
304
+ constructor(options: OpenShellCliOptions = {}) {
305
+ this.binary = options.binary ?? "openshell"
306
+ this.workspace = options.workspace ?? OPENSHELL_WORKSPACE
307
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
308
+ this.createTimeoutMs = options.createTimeoutMs ?? Math.max(CREATE_TIMEOUT_MS, this.timeoutMs)
309
+ this.pageSize = options.pageSize ?? 100
310
+ this.secrets = options.secrets ?? []
311
+ if (options.gateway !== undefined) checkResource("gateway name", options.gateway)
312
+ checkResource("workspace", this.workspace)
313
+ this.globals = [...(options.gateway !== undefined ? ["--gateway", options.gateway] : []), "--workspace", this.workspace, "--color", "never"]
314
+ const env: Record<string, string> = {}
315
+ for (const [k, v] of Object.entries(options.env ?? process.env)) if (v !== undefined && !DROPPED_ENV.has(k)) env[k] = v
316
+ env.NO_COLOR = "1"
317
+ this.env = env
318
+ }
319
+
320
+ /** The CLI's version; rejects with unsupported_version outside 0.1.x. Checked once (a failure is retried). */
321
+ version(options: CallOptions = {}): Promise<string> {
322
+ this.checked ??= this.checkVersion(options).catch((error: unknown) => {
323
+ this.checked = undefined
324
+ throw error
325
+ })
326
+ return this.checked
327
+ }
328
+
329
+ async status(options: CallOptions = {}): Promise<GatewayStatus> {
330
+ const { stdout } = await this.call(["status", "-o", "json"], { what: "status", ...options })
331
+ const o = parseJSON<Record<string, unknown>>("status", stdout)
332
+ const auth = (o.authentication ?? {}) as Record<string, unknown>
333
+ return { gateway: str(o.gateway), server: str(o.server), connected: o.status === "connected", authenticated: auth.status === "authenticated", version: str(o.version) }
334
+ }
335
+
336
+ async gatewayInfo(options: CallOptions = {}): Promise<GatewayInfo> {
337
+ const { stdout } = await this.call(["gateway", "info", "-o", "json"], { what: "gateway info", ...options })
338
+ const o = parseJSON<{ compute_drivers?: unknown }>("gateway info", stdout)
339
+ if (!Array.isArray(o.compute_drivers)) throw new OpenShellError("openshell gateway info: no compute drivers in the output", "bad_output")
340
+ const computeDrivers = o.compute_drivers.map((d) => str((d as { name?: unknown } | null)?.name)).filter(Boolean)
341
+ return { computeDrivers }
342
+ }
343
+
344
+ workspaces(options: CallOptions = {}): Promise<string[]> {
345
+ return this.names(["workspace", "list"], "workspaces", options)
346
+ }
347
+
348
+ providers(options: CallOptions = {}): Promise<string[]> {
349
+ return this.names(["provider", "list"], "providers", options)
350
+ }
351
+
352
+ async create(sandbox: CreateSandboxOptions, options: CallOptions = {}): Promise<Sandbox> {
353
+ checkName(sandbox.name)
354
+ const args = ["sandbox", "create", "--name", sandbox.name]
355
+ if (sandbox.from !== undefined && sandbox.template !== undefined) throw invalid("give an image (from) or a template, not both")
356
+ if (sandbox.from !== undefined) {
357
+ checkPath("image", sandbox.from)
358
+ args.push("--from", sandbox.from)
359
+ }
360
+ if (sandbox.template !== undefined) {
361
+ checkResource("template", sandbox.template)
362
+ args.push("--template", sandbox.template)
363
+ }
364
+ if (sandbox.policyFile !== undefined) {
365
+ checkPath("policy file", sandbox.policyFile)
366
+ args.push("--policy", sandbox.policyFile)
367
+ }
368
+ for (const p of sandbox.providers ?? []) {
369
+ checkResource("provider", p)
370
+ args.push("--provider", p)
371
+ }
372
+ checkLabels(sandbox.labels ?? {})
373
+ for (const [k, v] of Object.entries(sandbox.labels ?? {})) args.push("--label", `${k}=${v}`)
374
+ args.push(...envArgs("--env", sandbox.env), "--no-auto-providers", "--no-tty", "--detach", "-o", "json")
375
+ if (sandbox.command?.length) args.push("--", ...sandbox.command)
376
+ const { stdout } = await this.call(args, { what: "sandbox create", timeoutMs: this.createTimeoutMs, hide: Object.values(sandbox.env ?? {}), ...options })
377
+ return toSandbox(parseJSON("sandbox create", stdout), "sandbox create")
378
+ }
379
+
380
+ async get(name: string, options: CallOptions = {}): Promise<Sandbox | undefined> {
381
+ checkName(name)
382
+ try {
383
+ const { stdout } = await this.call(["sandbox", "get", name, "-o", "json"], { what: "sandbox get", ...options })
384
+ return toSandbox(parseJSON("sandbox get", stdout), "sandbox get")
385
+ } catch (error) {
386
+ if (error instanceof OpenShellError && error.code === "not_found") return undefined
387
+ throw error
388
+ }
389
+ }
390
+
391
+ async list(selector: Readonly<Record<string, string>> = {}, options: CallOptions = {}): Promise<Sandbox[]> {
392
+ checkLabels(selector)
393
+ const select = Object.entries(selector).map(([k, v]) => `${k}=${v}`).join(",")
394
+ const sandboxes: Sandbox[] = []
395
+ let token = ""
396
+ for (let page = 0; page < 10_000; page++) {
397
+ const args = ["sandbox", "list", "--page-size", String(this.pageSize)]
398
+ if (token) args.push("--page-token", token)
399
+ if (select) args.push("--selector", select)
400
+ args.push("-o", "json")
401
+ const { stdout } = await this.call(args, { what: "sandbox list", ...options })
402
+ const o = parseJSON<{ sandboxes?: unknown; next_page_token?: unknown }>("sandbox list", stdout)
403
+ if (!Array.isArray(o.sandboxes)) throw new OpenShellError("openshell sandbox list: no sandboxes in the output", "bad_output")
404
+ for (const s of o.sandboxes) sandboxes.push(toSandbox(s, "sandbox list"))
405
+ token = str(o.next_page_token)
406
+ if (!token) return sandboxes
407
+ }
408
+ throw new OpenShellError("openshell sandbox list: too many pages", "bad_output")
409
+ }
410
+
411
+ async exec(name: string, command: readonly string[], exec: ExecOptions & CallOptions = {}): Promise<ExecResult> {
412
+ checkName(name)
413
+ if (command.length === 0) throw invalid("exec needs a command")
414
+ const stdinSize = typeof exec.stdin === "string" ? Buffer.byteLength(exec.stdin) : (exec.stdin?.byteLength ?? 0)
415
+ if (stdinSize > MAX_EXEC_STDIN) throw invalid(`exec stdin is over ${MAX_EXEC_STDIN} bytes`)
416
+ const args = ["sandbox", "exec", "--name", name, "--no-tty", "--no-login-shell"]
417
+ if (exec.workdir !== undefined) {
418
+ checkPath("workdir", exec.workdir)
419
+ args.push("--workdir", exec.workdir)
420
+ }
421
+ const timeoutSec = exec.timeoutSec
422
+ if (timeoutSec !== undefined) {
423
+ if (!Number.isInteger(timeoutSec) || timeoutSec < 0) throw invalid("exec timeoutSec must be a whole number of seconds")
424
+ args.push("--timeout", String(timeoutSec))
425
+ }
426
+ args.push(...envArgs("--env", exec.env), "--", ...command)
427
+ const run: RunOptions = {
428
+ what: "sandbox exec",
429
+ timeoutMs: timeoutSec ? timeoutSec * 1000 + GRACE_MS : this.timeoutMs,
430
+ hide: Object.values(exec.env ?? {}),
431
+ ...(exec.signal ? { signal: exec.signal } : {}),
432
+ ...(exec.stdin !== undefined ? { stdin: exec.stdin } : {}),
433
+ }
434
+ await this.version(exec)
435
+ return this.run(args, run)
436
+ }
437
+
438
+ async upload(name: string, localPath: string, sandboxPath?: string, options: CallOptions = {}): Promise<void> {
439
+ checkName(name)
440
+ const local = path.resolve(localPath)
441
+ const args = ["sandbox", "upload", "--no-git-ignore", name, local]
442
+ if (sandboxPath !== undefined) {
443
+ checkPath("sandbox path", sandboxPath)
444
+ args.push(sandboxPath)
445
+ }
446
+ await this.call(args, { what: "sandbox upload", ...options })
447
+ }
448
+
449
+ async download(name: string, sandboxPath: string, localPath: string, options: CallOptions = {}): Promise<void> {
450
+ checkName(name)
451
+ checkPath("sandbox path", sandboxPath)
452
+ await this.call(["sandbox", "download", name, sandboxPath, path.resolve(localPath)], { what: "sandbox download", ...options })
453
+ }
454
+
455
+ async stop(name: string, options: CallOptions = {}): Promise<void> {
456
+ checkName(name)
457
+ await this.call(["sandbox", "stop", name], { what: "sandbox stop", ...options })
458
+ }
459
+
460
+ async delete(name: string, options: CallOptions = {}): Promise<void> {
461
+ checkName(name)
462
+ await this.call(["sandbox", "delete", name], { what: "sandbox delete", ...options })
463
+ }
464
+
465
+ async policySet(name: string, policyFile: string, policy: PolicySetOptions & CallOptions = {}): Promise<void> {
466
+ checkName(name)
467
+ checkPath("policy file", policyFile)
468
+ const args = ["policy", "set", "--policy", policyFile]
469
+ let timeoutMs = this.timeoutMs
470
+ if (policy.wait) {
471
+ args.push("--wait")
472
+ if (policy.timeoutSec !== undefined) {
473
+ if (!Number.isInteger(policy.timeoutSec) || policy.timeoutSec <= 0) throw invalid("policy timeoutSec must be a positive whole number")
474
+ args.push("--timeout", String(policy.timeoutSec))
475
+ }
476
+ timeoutMs = Math.max(timeoutMs, (policy.timeoutSec ?? 60) * 1000 + GRACE_MS)
477
+ }
478
+ args.push(name)
479
+ await this.call(args, { what: "policy set", timeoutMs, ...(policy.signal ? { signal: policy.signal } : {}) })
480
+ }
481
+
482
+ /** The `name`s of a paged `<command> -o json` listing whose items sit under `field`. */
483
+ private async names(command: readonly string[], field: string, options: CallOptions): Promise<string[]> {
484
+ const what = command.join(" ")
485
+ const names: string[] = []
486
+ let token = ""
487
+ for (let page = 0; page < 10_000; page++) {
488
+ const args = [...command, "--page-size", String(this.pageSize)]
489
+ if (token) args.push("--page-token", token)
490
+ args.push("-o", "json")
491
+ const { stdout } = await this.call(args, { what, ...options })
492
+ const o = parseJSON<Record<string, unknown> | null>(what, stdout)
493
+ const items = o && typeof o === "object" ? o[field] : undefined
494
+ if (!Array.isArray(items)) throw new OpenShellError(`openshell ${what}: no ${field} in the output`, "bad_output")
495
+ for (const item of items) {
496
+ const name = str((item as { name?: unknown } | null)?.name)
497
+ if (name) names.push(name)
498
+ }
499
+ token = str(o!.next_page_token)
500
+ if (!token) return names
501
+ }
502
+ throw new OpenShellError(`openshell ${what}: too many pages`, "bad_output")
503
+ }
504
+
505
+ // ---- running ----
506
+
507
+ private async checkVersion(options: CallOptions): Promise<string> {
508
+ const { stdout } = await this.call(["--version"], { what: "--version", ...options }, false)
509
+ const match = /(\d+)\.(\d+)\.(\d+)\S*/.exec(stdout)
510
+ if (!match) throw new OpenShellError("openshell --version: no version in the output", "bad_output")
511
+ if (match[1] !== "0" || match[2] !== "1") throw new OpenShellError(`openshell ${match[0]} is not supported (needs ${SUPPORTED_VERSIONS})`, "unsupported_version")
512
+ return match[0]
513
+ }
514
+
515
+ /** Checks the version, runs, and turns a non-zero exit into an OpenShellError. */
516
+ private async call(args: readonly string[], options: RunOptions, gate = true): Promise<RunResult> {
517
+ if (gate) await this.version(options.signal ? { signal: options.signal } : {})
518
+ const result = await this.run(args, options)
519
+ if (result.exitCode !== 0) {
520
+ const reason = reasonOf(result.stderr)
521
+ const code: OpenShellErrorCode = /\bNotFound\b|\bnot found\b/i.test(reason + result.stderr) ? "not_found" : /\bAlreadyExists\b|already exists/i.test(reason + result.stderr) ? "already_exists" : "failed"
522
+ const message = cut(redact(reason, [...this.secrets, ...(options.hide ?? [])])) || "no error output"
523
+ throw new OpenShellError(`openshell ${options.what} failed (exit ${result.exitCode}): ${message}`, code, result.exitCode)
524
+ }
525
+ return result
526
+ }
527
+
528
+ private run(args: readonly string[], options: RunOptions): Promise<RunResult> {
529
+ const what = `openshell ${options.what}`
530
+ return new Promise((resolve, reject) => {
531
+ if (options.signal?.aborted) return reject(new OpenShellError(`${what} cancelled`, "cancelled"))
532
+ let settled = false
533
+ const finish = (error: OpenShellError | undefined, result?: RunResult) => {
534
+ if (settled) return
535
+ settled = true
536
+ clearTimeout(timer)
537
+ options.signal?.removeEventListener("abort", onAbort)
538
+ if (error) reject(error)
539
+ else resolve(result!)
540
+ }
541
+ // Its own process group, so a timeout or cancel also kills what openshell started.
542
+ const child = spawn(this.binary, [...this.globals, ...args], { env: this.env, stdio: ["pipe", "pipe", "pipe"], detached: true, windowsHide: true })
543
+ const kill = () => {
544
+ try {
545
+ if (child.pid) process.kill(-child.pid, "SIGKILL")
546
+ } catch {
547
+ child.kill("SIGKILL")
548
+ }
549
+ }
550
+ const out: Buffer[] = []
551
+ const err: Buffer[] = []
552
+ let size = 0
553
+ const collect = (into: Buffer[]) => (chunk: Buffer) => {
554
+ size += chunk.length
555
+ if (size > MAX_OUTPUT) {
556
+ kill()
557
+ return finish(new OpenShellError(`${what}: output over ${MAX_OUTPUT} bytes`, "bad_output"))
558
+ }
559
+ into.push(chunk)
560
+ }
561
+ child.stdout.on("data", collect(out))
562
+ child.stderr.on("data", collect(err))
563
+ child.stdin.on("error", () => {}) // the CLI may exit before reading stdin
564
+ child.stdin.end(options.stdin === undefined ? undefined : Buffer.from(options.stdin))
565
+ const timeoutMs = options.timeoutMs ?? this.timeoutMs
566
+ const timer = setTimeout(() => {
567
+ kill()
568
+ finish(new OpenShellError(`${what} timed out after ${Math.round(timeoutMs / 1000)} s`, "timeout"))
569
+ }, timeoutMs)
570
+ const onAbort = () => {
571
+ kill()
572
+ finish(new OpenShellError(`${what} cancelled`, "cancelled"))
573
+ }
574
+ options.signal?.addEventListener("abort", onAbort, { once: true })
575
+ child.on("error", (error: NodeJS.ErrnoException) => {
576
+ if (error.code === "ENOENT") finish(new OpenShellError(`openshell is not installed (${this.binary} not found)`, "not_installed"))
577
+ else finish(new OpenShellError(`${what} could not start: ${error.code ?? "error"}`, "failed"))
578
+ })
579
+ child.on("close", (code, signal) => {
580
+ finish(undefined, { exitCode: code ?? (signal ? 128 : 1), stdout: Buffer.concat(out).toString("utf8"), stderr: Buffer.concat(err).toString("utf8") })
581
+ })
582
+ })
583
+ }
584
+ }
@@ -0,0 +1,6 @@
1
+ // The `openshell` runtime of `hub node` (design §6.4): launches in NVIDIA OpenShell sandboxes. The CLI client
2
+ // (cli.ts), hub profiles (profiles.ts), the policy compiler (policy.ts) and the Runtime itself (runtime.ts).
3
+ export * from "./cli.ts"
4
+ export * from "./profiles.ts"
5
+ export * from "./policy.ts"
6
+ export * from "./runtime.ts"