quoxcli 0.0.0-stage → 0.3.1

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 (516) hide show
  1. package/LICENSE +35 -0
  2. package/README.md +144 -2
  3. package/bin/quox.js +265 -0
  4. package/bin/quoxon.js +8 -0
  5. package/lib/api/endpoints/admin.js +151 -0
  6. package/lib/api/endpoints/aee.js +38 -0
  7. package/lib/api/endpoints/agent-attach.js +126 -0
  8. package/lib/api/endpoints/agent-schedules.js +26 -0
  9. package/lib/api/endpoints/agents.js +63 -0
  10. package/lib/api/endpoints/assistants.js +62 -0
  11. package/lib/api/endpoints/audit.js +14 -0
  12. package/lib/api/endpoints/auth.js +50 -0
  13. package/lib/api/endpoints/awareness.js +34 -0
  14. package/lib/api/endpoints/away.js +15 -0
  15. package/lib/api/endpoints/backup-schedules.js +20 -0
  16. package/lib/api/endpoints/backups.js +86 -0
  17. package/lib/api/endpoints/brain.js +140 -0
  18. package/lib/api/endpoints/budgets.js +62 -0
  19. package/lib/api/endpoints/calendarq.js +71 -0
  20. package/lib/api/endpoints/catalog.js +14 -0
  21. package/lib/api/endpoints/chat.js +61 -0
  22. package/lib/api/endpoints/consent.js +26 -0
  23. package/lib/api/endpoints/contactsq.js +68 -0
  24. package/lib/api/endpoints/conversations.js +46 -0
  25. package/lib/api/endpoints/custom-roles.js +92 -0
  26. package/lib/api/endpoints/divisions.js +56 -0
  27. package/lib/api/endpoints/domains.js +38 -0
  28. package/lib/api/endpoints/engagement.js +231 -0
  29. package/lib/api/endpoints/engine.js +11 -0
  30. package/lib/api/endpoints/entity.js +50 -0
  31. package/lib/api/endpoints/executions.js +45 -0
  32. package/lib/api/endpoints/fetchAllowlist.js +27 -0
  33. package/lib/api/endpoints/file.js +20 -0
  34. package/lib/api/endpoints/files.js +40 -0
  35. package/lib/api/endpoints/github.js +21 -0
  36. package/lib/api/endpoints/governance.js +43 -0
  37. package/lib/api/endpoints/hat.js +66 -0
  38. package/lib/api/endpoints/inbox.js +52 -0
  39. package/lib/api/endpoints/inboxq.js +120 -0
  40. package/lib/api/endpoints/index.js +242 -0
  41. package/lib/api/endpoints/invites.js +19 -0
  42. package/lib/api/endpoints/llm-policy.js +43 -0
  43. package/lib/api/endpoints/lucid.js +89 -0
  44. package/lib/api/endpoints/members.js +32 -0
  45. package/lib/api/endpoints/memory.js +94 -0
  46. package/lib/api/endpoints/mfa.js +32 -0
  47. package/lib/api/endpoints/mind.js +13 -0
  48. package/lib/api/endpoints/mon.js +184 -0
  49. package/lib/api/endpoints/notesq.js +43 -0
  50. package/lib/api/endpoints/objectives.js +68 -0
  51. package/lib/api/endpoints/orgs.js +38 -0
  52. package/lib/api/endpoints/plugins.js +152 -0
  53. package/lib/api/endpoints/powers.js +75 -0
  54. package/lib/api/endpoints/presence.js +33 -0
  55. package/lib/api/endpoints/prompts.js +69 -0
  56. package/lib/api/endpoints/qlarity.js +122 -0
  57. package/lib/api/endpoints/quoxbox.js +55 -0
  58. package/lib/api/endpoints/quoxchat.js +54 -0
  59. package/lib/api/endpoints/quoxflow.js +32 -0
  60. package/lib/api/endpoints/quoxplan.js +121 -0
  61. package/lib/api/endpoints/quoxseo.js +114 -0
  62. package/lib/api/endpoints/reach.js +47 -0
  63. package/lib/api/endpoints/router.js +17 -0
  64. package/lib/api/endpoints/runs.js +38 -0
  65. package/lib/api/endpoints/safety.js +53 -0
  66. package/lib/api/endpoints/scim.js +46 -0
  67. package/lib/api/endpoints/scopes.js +14 -0
  68. package/lib/api/endpoints/security.js +69 -0
  69. package/lib/api/endpoints/setup.js +90 -0
  70. package/lib/api/endpoints/skills.js +200 -0
  71. package/lib/api/endpoints/sso.js +67 -0
  72. package/lib/api/endpoints/stepup.js +20 -0
  73. package/lib/api/endpoints/tags.js +32 -0
  74. package/lib/api/endpoints/teams.js +56 -0
  75. package/lib/api/endpoints/telephony.js +46 -0
  76. package/lib/api/endpoints/tg.js +56 -0
  77. package/lib/api/endpoints/tools.js +50 -0
  78. package/lib/api/endpoints/traces.js +157 -0
  79. package/lib/api/endpoints/triggers.js +96 -0
  80. package/lib/api/endpoints/user-library.js +41 -0
  81. package/lib/api/endpoints/users.js +20 -0
  82. package/lib/api/endpoints/v1.js +585 -0
  83. package/lib/api/endpoints/vault.js +192 -0
  84. package/lib/api/endpoints/voice.js +22 -0
  85. package/lib/api/endpoints/volt.js +84 -0
  86. package/lib/api/endpoints/ward.js +47 -0
  87. package/lib/api/endpoints/webhooks.js +46 -0
  88. package/lib/api/endpoints/workflows.js +169 -0
  89. package/lib/api/endpoints/x.js +32 -0
  90. package/lib/api/endpoints.js +15 -0
  91. package/lib/commands/actions.js +61 -0
  92. package/lib/commands/admin.js +375 -0
  93. package/lib/commands/aee.js +99 -0
  94. package/lib/commands/agent.js +337 -0
  95. package/lib/commands/agentAttach.js +1528 -0
  96. package/lib/commands/approvals.js +119 -0
  97. package/lib/commands/aside.js +24 -0
  98. package/lib/commands/ask.js +144 -0
  99. package/lib/commands/assistant.js +169 -0
  100. package/lib/commands/auth.js +464 -0
  101. package/lib/commands/awareness.js +88 -0
  102. package/lib/commands/backup.js +377 -0
  103. package/lib/commands/brain.js +1085 -0
  104. package/lib/commands/budget.js +241 -0
  105. package/lib/commands/build.js +170 -0
  106. package/lib/commands/calendar.js +216 -0
  107. package/lib/commands/capabilities.js +34 -0
  108. package/lib/commands/chat.js +295 -0
  109. package/lib/commands/claude.js +380 -0
  110. package/lib/commands/config-cmd.js +106 -0
  111. package/lib/commands/consent.js +84 -0
  112. package/lib/commands/contacts.js +1016 -0
  113. package/lib/commands/contactsSelftest.js +132 -0
  114. package/lib/commands/conversation.js +105 -0
  115. package/lib/commands/dashboard.js +43 -0
  116. package/lib/commands/department.js +96 -0
  117. package/lib/commands/dev.js +620 -0
  118. package/lib/commands/devResident.js +326 -0
  119. package/lib/commands/devSupervisor.js +1062 -0
  120. package/lib/commands/discord.js +132 -0
  121. package/lib/commands/doctor.js +181 -0
  122. package/lib/commands/domain.js +114 -0
  123. package/lib/commands/done.js +347 -0
  124. package/lib/commands/engagement.js +597 -0
  125. package/lib/commands/entity.js +119 -0
  126. package/lib/commands/facebook.js +212 -0
  127. package/lib/commands/fetchAllowlist.js +71 -0
  128. package/lib/commands/file.js +165 -0
  129. package/lib/commands/fleet.js +1189 -0
  130. package/lib/commands/governance.js +647 -0
  131. package/lib/commands/gripe.js +355 -0
  132. package/lib/commands/hat.js +374 -0
  133. package/lib/commands/helpers.js +96 -0
  134. package/lib/commands/hyperion.js +58 -0
  135. package/lib/commands/inbox.js +173 -0
  136. package/lib/commands/instagram.js +264 -0
  137. package/lib/commands/integration.js +100 -0
  138. package/lib/commands/inventory.js +115 -0
  139. package/lib/commands/invites.js +45 -0
  140. package/lib/commands/licence.js +139 -0
  141. package/lib/commands/linkedin.js +178 -0
  142. package/lib/commands/llm-policy.js +117 -0
  143. package/lib/commands/llm.js +45 -0
  144. package/lib/commands/lucid.js +597 -0
  145. package/lib/commands/mail.js +531 -0
  146. package/lib/commands/memory.js +361 -0
  147. package/lib/commands/mind.js +71 -0
  148. package/lib/commands/mon.js +581 -0
  149. package/lib/commands/monitoring.js +86 -0
  150. package/lib/commands/motion.js +22 -0
  151. package/lib/commands/notes.js +1246 -0
  152. package/lib/commands/notesBatch.js +160 -0
  153. package/lib/commands/notesBridges.js +56 -0
  154. package/lib/commands/notesLinks.js +60 -0
  155. package/lib/commands/notification.js +57 -0
  156. package/lib/commands/nucleus.js +123 -0
  157. package/lib/commands/objective.js +240 -0
  158. package/lib/commands/offload.js +89 -0
  159. package/lib/commands/onboard.js +329 -0
  160. package/lib/commands/open.js +56 -0
  161. package/lib/commands/org.js +276 -0
  162. package/lib/commands/peek.js +28 -0
  163. package/lib/commands/plan.js +944 -0
  164. package/lib/commands/plugin-doctor.js +1091 -0
  165. package/lib/commands/plugin.js +649 -0
  166. package/lib/commands/policy.js +78 -0
  167. package/lib/commands/powers.js +296 -0
  168. package/lib/commands/presence.js +89 -0
  169. package/lib/commands/prompt.js +350 -0
  170. package/lib/commands/provider.js +159 -0
  171. package/lib/commands/qlarity.js +706 -0
  172. package/lib/commands/quoxbox.js +270 -0
  173. package/lib/commands/quoxchat.js +275 -0
  174. package/lib/commands/quoxseo.js +378 -0
  175. package/lib/commands/reach.js +159 -0
  176. package/lib/commands/role.js +286 -0
  177. package/lib/commands/router.js +77 -0
  178. package/lib/commands/rows.js +358 -0
  179. package/lib/commands/run.js +187 -0
  180. package/lib/commands/safety.js +107 -0
  181. package/lib/commands/scim.js +88 -0
  182. package/lib/commands/scope.js +58 -0
  183. package/lib/commands/security.js +157 -0
  184. package/lib/commands/server.js +292 -0
  185. package/lib/commands/service.js +89 -0
  186. package/lib/commands/settings.js +99 -0
  187. package/lib/commands/setup.js +569 -0
  188. package/lib/commands/skillManifest.js +113 -0
  189. package/lib/commands/skillRightsFormat.js +38 -0
  190. package/lib/commands/skills.js +687 -0
  191. package/lib/commands/slot.js +1451 -0
  192. package/lib/commands/sso.js +229 -0
  193. package/lib/commands/status.js +137 -0
  194. package/lib/commands/tag.js +91 -0
  195. package/lib/commands/team.js +142 -0
  196. package/lib/commands/tg.js +240 -0
  197. package/lib/commands/tiktok.js +218 -0
  198. package/lib/commands/tips.js +23 -0
  199. package/lib/commands/tool.js +304 -0
  200. package/lib/commands/trace.js +530 -0
  201. package/lib/commands/trigger.js +223 -0
  202. package/lib/commands/update.js +46 -0
  203. package/lib/commands/vault.js +1447 -0
  204. package/lib/commands/vm.js +237 -0
  205. package/lib/commands/voice.js +213 -0
  206. package/lib/commands/volt.js +570 -0
  207. package/lib/commands/walkthrough.js +32 -0
  208. package/lib/commands/watch.js +167 -0
  209. package/lib/commands/webhook.js +137 -0
  210. package/lib/commands/welcome.js +14 -0
  211. package/lib/commands/workflow.js +660 -0
  212. package/lib/commands/x.js +448 -0
  213. package/lib/core/actingAs.js +136 -0
  214. package/lib/core/activeSummary.js +186 -0
  215. package/lib/core/aee.js +53 -0
  216. package/lib/core/agentAlias.js +163 -0
  217. package/lib/core/approvalContext.js +61 -0
  218. package/lib/core/aside.js +54 -0
  219. package/lib/core/askDecision.js +60 -0
  220. package/lib/core/auth.js +501 -0
  221. package/lib/core/banner.js +143 -0
  222. package/lib/core/brainPrime.js +90 -0
  223. package/lib/core/callerSource.js +129 -0
  224. package/lib/core/claudeLs.js +94 -0
  225. package/lib/core/cliBuild.js +62 -0
  226. package/lib/core/client.js +693 -0
  227. package/lib/core/commandRegistrars.js +121 -0
  228. package/lib/core/completions.js +163 -0
  229. package/lib/core/config.js +669 -0
  230. package/lib/core/confirm.js +37 -0
  231. package/lib/core/customHats.js +168 -0
  232. package/lib/core/deeplinks.js +76 -0
  233. package/lib/core/doneEngineFetch.js +227 -0
  234. package/lib/core/errors.js +441 -0
  235. package/lib/core/global-flags.js +190 -0
  236. package/lib/core/governedSession.js +402 -0
  237. package/lib/core/hyperion.js +102 -0
  238. package/lib/core/inboxResolution.js +65 -0
  239. package/lib/core/lazyCommands.js +122 -0
  240. package/lib/core/liveSessionsReport.js +112 -0
  241. package/lib/core/offload.js +504 -0
  242. package/lib/core/org-override.js +52 -0
  243. package/lib/core/orgResolve.js +70 -0
  244. package/lib/core/output.js +239 -0
  245. package/lib/core/peek.js +126 -0
  246. package/lib/core/prodEnv.js +7 -0
  247. package/lib/core/profileUsers.js +51 -0
  248. package/lib/core/promptCancel.js +18 -0
  249. package/lib/core/quoxglyph.js +158 -0
  250. package/lib/core/repoPath.js +11 -0
  251. package/lib/core/residentAlarm.js +282 -0
  252. package/lib/core/residentAuth.js +243 -0
  253. package/lib/core/residentPump.js +229 -0
  254. package/lib/core/residentSeed.js +106 -0
  255. package/lib/core/rootOptions.js +39 -0
  256. package/lib/core/router.js +168 -0
  257. package/lib/core/selfUpdate.js +188 -0
  258. package/lib/core/sessionHeartbeat.js +192 -0
  259. package/lib/core/sessionsOverview.js +224 -0
  260. package/lib/core/slotKeeper.js +527 -0
  261. package/lib/core/slotWake.js +120 -0
  262. package/lib/core/supervisorEngine.js +933 -0
  263. package/lib/core/systemdInstaller.js +256 -0
  264. package/lib/core/version.js +87 -0
  265. package/lib/core/vm.js +361 -0
  266. package/lib/core/vmTui.js +86 -0
  267. package/lib/core/walkthrough.js +35 -0
  268. package/lib/hyperion/deck.js +29 -0
  269. package/lib/lucid/LucidCard.jsx +68 -0
  270. package/lib/lucid/LucidIntro.jsx +92 -0
  271. package/lib/lucid/blind.js +71 -0
  272. package/lib/lucid/cards.js +86 -0
  273. package/lib/lucid/deck.js +32 -0
  274. package/lib/lucid/fmt.js +139 -0
  275. package/lib/lucid/markdown.js +81 -0
  276. package/lib/lucid/motion.js +56 -0
  277. package/lib/lucid/sitrep.js +173 -0
  278. package/lib/plugins/loader.js +93 -0
  279. package/lib/quoxon/activity.js +116 -0
  280. package/lib/quoxon/aocl.js +117 -0
  281. package/lib/quoxon/api.js +952 -0
  282. package/lib/quoxon/app.js +4032 -0
  283. package/lib/quoxon/approvalFacts.js +94 -0
  284. package/lib/quoxon/approvalsView.js +325 -0
  285. package/lib/quoxon/asideView.js +40 -0
  286. package/lib/quoxon/ask.js +121 -0
  287. package/lib/quoxon/away.js +52 -0
  288. package/lib/quoxon/bang.js +224 -0
  289. package/lib/quoxon/bulkRisk.js +17 -0
  290. package/lib/quoxon/capabilities.js +82 -0
  291. package/lib/quoxon/cells.js +53 -0
  292. package/lib/quoxon/commandShort.js +112 -0
  293. package/lib/quoxon/commands.js +479 -0
  294. package/lib/quoxon/connect.js +654 -0
  295. package/lib/quoxon/connectCard.js +45 -0
  296. package/lib/quoxon/consent.js +86 -0
  297. package/lib/quoxon/context.js +68 -0
  298. package/lib/quoxon/copy.js +34 -0
  299. package/lib/quoxon/countdownLine.js +11 -0
  300. package/lib/quoxon/demo.autoplay.js +29 -0
  301. package/lib/quoxon/demo.fixtures.js +24 -0
  302. package/lib/quoxon/demo.js +76 -0
  303. package/lib/quoxon/devObserver.js +20 -0
  304. package/lib/quoxon/dividerAocl.js +191 -0
  305. package/lib/quoxon/dividerRule.js +57 -0
  306. package/lib/quoxon/dragonModes.js +150 -0
  307. package/lib/quoxon/dragonView.js +41 -0
  308. package/lib/quoxon/earlyInput.js +40 -0
  309. package/lib/quoxon/echo.js +15 -0
  310. package/lib/quoxon/egg.js +36 -0
  311. package/lib/quoxon/events.js +677 -0
  312. package/lib/quoxon/feedback.js +50 -0
  313. package/lib/quoxon/gallery.js +237 -0
  314. package/lib/quoxon/handoffOffer.js +78 -0
  315. package/lib/quoxon/hatSwitch.js +37 -0
  316. package/lib/quoxon/headless.js +161 -0
  317. package/lib/quoxon/hostUrls.js +41 -0
  318. package/lib/quoxon/indicator.js +197 -0
  319. package/lib/quoxon/intent.js +52 -0
  320. package/lib/quoxon/keys.js +164 -0
  321. package/lib/quoxon/launcher.js +103 -0
  322. package/lib/quoxon/main.js +176 -0
  323. package/lib/quoxon/markdown.js +341 -0
  324. package/lib/quoxon/markdownSettle.js +19 -0
  325. package/lib/quoxon/matrixDomains.json +46 -0
  326. package/lib/quoxon/mentions.js +90 -0
  327. package/lib/quoxon/mind.js +103 -0
  328. package/lib/quoxon/mode.js +46 -0
  329. package/lib/quoxon/model.js +311 -0
  330. package/lib/quoxon/modelPrefs.js +345 -0
  331. package/lib/quoxon/motion/clock.js +70 -0
  332. package/lib/quoxon/motion/color.js +35 -0
  333. package/lib/quoxon/motion/frame.js +14 -0
  334. package/lib/quoxon/motion/level.js +91 -0
  335. package/lib/quoxon/motion/reveal.js +54 -0
  336. package/lib/quoxon/motion/ring.js +79 -0
  337. package/lib/quoxon/mouse.js +211 -0
  338. package/lib/quoxon/noai.js +57 -0
  339. package/lib/quoxon/objects/agent.js +61 -0
  340. package/lib/quoxon/objects/away.js +72 -0
  341. package/lib/quoxon/objects/crystal.js +100 -0
  342. package/lib/quoxon/objects/fetch.js +182 -0
  343. package/lib/quoxon/objects/fleet.js +149 -0
  344. package/lib/quoxon/objects/health.js +186 -0
  345. package/lib/quoxon/objects/index.js +106 -0
  346. package/lib/quoxon/objects/ladder.js +71 -0
  347. package/lib/quoxon/objects/memory.js +44 -0
  348. package/lib/quoxon/objects/mirror.js +137 -0
  349. package/lib/quoxon/objects/model.js +118 -0
  350. package/lib/quoxon/objects/peek.js +17 -0
  351. package/lib/quoxon/objects/proof.js +110 -0
  352. package/lib/quoxon/objects/prove.js +130 -0
  353. package/lib/quoxon/objects/replay.js +125 -0
  354. package/lib/quoxon/objects/sitrep.js +58 -0
  355. package/lib/quoxon/objects/skill.js +105 -0
  356. package/lib/quoxon/objects/view.js +87 -0
  357. package/lib/quoxon/objects/workflow.js +119 -0
  358. package/lib/quoxon/observers.js +42 -0
  359. package/lib/quoxon/onboarding/copy.js +263 -0
  360. package/lib/quoxon/onboarding/credential.js +108 -0
  361. package/lib/quoxon/onboarding/detect.js +22 -0
  362. package/lib/quoxon/onboarding/dockerOffer.js +45 -0
  363. package/lib/quoxon/onboarding/failureCause.js +33 -0
  364. package/lib/quoxon/onboarding/finish.js +134 -0
  365. package/lib/quoxon/onboarding/index.js +94 -0
  366. package/lib/quoxon/onboarding/installRunner.js +284 -0
  367. package/lib/quoxon/onboarding/intent.js +53 -0
  368. package/lib/quoxon/onboarding/journey.js +73 -0
  369. package/lib/quoxon/onboarding/preflight.js +156 -0
  370. package/lib/quoxon/onboarding/probe.js +65 -0
  371. package/lib/quoxon/onboarding/runner.js +58 -0
  372. package/lib/quoxon/onboarding/view.js +682 -0
  373. package/lib/quoxon/orgLabel.js +10 -0
  374. package/lib/quoxon/orgSwitch.js +33 -0
  375. package/lib/quoxon/ownBroadcast.js +22 -0
  376. package/lib/quoxon/palette.js +43 -0
  377. package/lib/quoxon/print.js +98 -0
  378. package/lib/quoxon/profileSwitch.js +53 -0
  379. package/lib/quoxon/promptHistory.js +41 -0
  380. package/lib/quoxon/providers.js +48 -0
  381. package/lib/quoxon/qart.js +348 -0
  382. package/lib/quoxon/qdragon.js +410 -0
  383. package/lib/quoxon/qdragonFly.js +61 -0
  384. package/lib/quoxon/qdragonScenes.js +282 -0
  385. package/lib/quoxon/qeyes.js +213 -0
  386. package/lib/quoxon/qglyph.js +143 -0
  387. package/lib/quoxon/qhello.js +58 -0
  388. package/lib/quoxon/qmotion.js +132 -0
  389. package/lib/quoxon/render.js +1167 -0
  390. package/lib/quoxon/resend.js +72 -0
  391. package/lib/quoxon/roster.js +307 -0
  392. package/lib/quoxon/route.js +39 -0
  393. package/lib/quoxon/rows.js +711 -0
  394. package/lib/quoxon/rowsStore.js +80 -0
  395. package/lib/quoxon/rules.js +130 -0
  396. package/lib/quoxon/screenContext.js +150 -0
  397. package/lib/quoxon/screenFrames.js +254 -0
  398. package/lib/quoxon/session.js +238 -0
  399. package/lib/quoxon/sessionDead.js +17 -0
  400. package/lib/quoxon/signin.js +125 -0
  401. package/lib/quoxon/skills.js +30 -0
  402. package/lib/quoxon/slash/agents.js +112 -0
  403. package/lib/quoxon/slash/argSources.js +65 -0
  404. package/lib/quoxon/slash/args.js +65 -0
  405. package/lib/quoxon/slash/automate.js +89 -0
  406. package/lib/quoxon/slash/broadcast.js +140 -0
  407. package/lib/quoxon/slash/broadcastRun.js +108 -0
  408. package/lib/quoxon/slash/card.js +30 -0
  409. package/lib/quoxon/slash/challenge.js +114 -0
  410. package/lib/quoxon/slash/cliCommand.js +53 -0
  411. package/lib/quoxon/slash/cliLeaves.json +948 -0
  412. package/lib/quoxon/slash/compact.js +90 -0
  413. package/lib/quoxon/slash/confirm.js +30 -0
  414. package/lib/quoxon/slash/crystallize.js +69 -0
  415. package/lib/quoxon/slash/dashboard.js +136 -0
  416. package/lib/quoxon/slash/draft.js +54 -0
  417. package/lib/quoxon/slash/hatWalk.js +206 -0
  418. package/lib/quoxon/slash/inverses.js +77 -0
  419. package/lib/quoxon/slash/killer.js +24 -0
  420. package/lib/quoxon/slash/killerApi.js +16 -0
  421. package/lib/quoxon/slash/ledger.js +88 -0
  422. package/lib/quoxon/slash/listView.js +30 -0
  423. package/lib/quoxon/slash/modelCmd.js +261 -0
  424. package/lib/quoxon/slash/palette.js +118 -0
  425. package/lib/quoxon/slash/paletteController.js +169 -0
  426. package/lib/quoxon/slash/panels.js +116 -0
  427. package/lib/quoxon/slash/picker.js +56 -0
  428. package/lib/quoxon/slash/restart.js +202 -0
  429. package/lib/quoxon/slash/resume.js +59 -0
  430. package/lib/quoxon/slash/rowCmd.js +157 -0
  431. package/lib/quoxon/slash/rowWalk.js +175 -0
  432. package/lib/quoxon/slash/settings.js +60 -0
  433. package/lib/quoxon/slash/spawn.js +177 -0
  434. package/lib/quoxon/slash/stepUp.js +274 -0
  435. package/lib/quoxon/slash/stepUpCard.js +60 -0
  436. package/lib/quoxon/slash/stepUpCmds.js +78 -0
  437. package/lib/quoxon/slash/undoApi.js +38 -0
  438. package/lib/quoxon/slash/undoCmd.js +49 -0
  439. package/lib/quoxon/slash/undoWish.js +15 -0
  440. package/lib/quoxon/slash/watch.js +164 -0
  441. package/lib/quoxon/slash/wish.js +23 -0
  442. package/lib/quoxon/slash/wishCmd.js +32 -0
  443. package/lib/quoxon/slotChat.js +300 -0
  444. package/lib/quoxon/slotFormat.js +195 -0
  445. package/lib/quoxon/slotRun.js +131 -0
  446. package/lib/quoxon/sound/index.js +186 -0
  447. package/lib/quoxon/sound/mapping.js +120 -0
  448. package/lib/quoxon/sound/output.js +52 -0
  449. package/lib/quoxon/sound/synth.js +122 -0
  450. package/lib/quoxon/stray.js +22 -0
  451. package/lib/quoxon/suggestCommand.js +59 -0
  452. package/lib/quoxon/term.js +141 -0
  453. package/lib/quoxon/textBuffer.js +416 -0
  454. package/lib/quoxon/theme.js +286 -0
  455. package/lib/quoxon/tips.js +433 -0
  456. package/lib/quoxon/toolError.js +41 -0
  457. package/lib/quoxon/transcript.js +188 -0
  458. package/lib/quoxon/web/qface.js +89 -0
  459. package/lib/quoxon/welcome.js +52 -0
  460. package/lib/quoxon/whatsnew.js +136 -0
  461. package/lib/quoxon/whatsnew.jsonl +89 -0
  462. package/lib/quoxon/windowOrg.js +113 -0
  463. package/lib/server/diag.js +129 -0
  464. package/lib/server/doctor.js +71 -0
  465. package/lib/server/install.js +149 -0
  466. package/lib/server/pluginEnable.js +161 -0
  467. package/lib/server/skew.js +56 -0
  468. package/lib/server/snapshot.js +131 -0
  469. package/lib/server/status.js +128 -0
  470. package/lib/server/sys.js +65 -0
  471. package/lib/server/uninstall.js +63 -0
  472. package/lib/server/update.js +234 -0
  473. package/lib/toolOutputShape.js +315 -0
  474. package/lib/tui/app.jsx +289 -0
  475. package/lib/tui/components/Breadcrumb.jsx +42 -0
  476. package/lib/tui/components/ChatInput.jsx +68 -0
  477. package/lib/tui/components/Confirm.jsx +32 -0
  478. package/lib/tui/components/DataTable.jsx +270 -0
  479. package/lib/tui/components/DetailPanel.jsx +241 -0
  480. package/lib/tui/components/HelpOverlay.jsx +67 -0
  481. package/lib/tui/components/Layout.jsx +129 -0
  482. package/lib/tui/components/Palette.jsx +141 -0
  483. package/lib/tui/components/SearchInput.jsx +125 -0
  484. package/lib/tui/components/Sparkline.jsx +55 -0
  485. package/lib/tui/components/StatusBar.jsx +133 -0
  486. package/lib/tui/components/Toast.jsx +52 -0
  487. package/lib/tui/hooks/useApi.js +44 -0
  488. package/lib/tui/hooks/useHealth.js +90 -0
  489. package/lib/tui/hooks/usePolledApi.js +86 -0
  490. package/lib/tui/hooks/useTerminalSize.js +46 -0
  491. package/lib/tui/index.js +56 -0
  492. package/lib/tui/intro/Intro.jsx +332 -0
  493. package/lib/tui/jsx-loader.js +32 -0
  494. package/lib/tui/keys.js +87 -0
  495. package/lib/tui/registry.js +49 -0
  496. package/lib/tui/theme.js +103 -0
  497. package/lib/tui/views/AdminView.jsx +398 -0
  498. package/lib/tui/views/AgentView.jsx +204 -0
  499. package/lib/tui/views/BackupView.jsx +225 -0
  500. package/lib/tui/views/BrainView.jsx +223 -0
  501. package/lib/tui/views/ChatView.jsx +468 -0
  502. package/lib/tui/views/FilesView.jsx +218 -0
  503. package/lib/tui/views/FleetView.jsx +234 -0
  504. package/lib/tui/views/GovernanceView.jsx +453 -0
  505. package/lib/tui/views/HealthView.jsx +216 -0
  506. package/lib/tui/views/HyperionDeck.jsx +79 -0
  507. package/lib/tui/views/InboxView.jsx +228 -0
  508. package/lib/tui/views/LucidDeck.jsx +719 -0
  509. package/lib/tui/views/MainMenu.jsx +123 -0
  510. package/lib/tui/views/MemoryView.jsx +377 -0
  511. package/lib/tui/views/ObjectivesView.jsx +230 -0
  512. package/lib/tui/views/OrgView.jsx +229 -0
  513. package/lib/tui/views/ToolView.jsx +221 -0
  514. package/lib/tui/views/VaultView.jsx +422 -0
  515. package/lib/tui/views/WorkflowView.jsx +329 -0
  516. package/package.json +75 -4
@@ -0,0 +1,1189 @@
1
+ /**
2
+ * Fleet commands: status, summary, agents, hosts, tools, watch
3
+ */
4
+
5
+ import { missingReasonWarning } from '../core/callerSource.js';
6
+ import chalk from 'chalk';
7
+ import tls from 'node:tls';
8
+ import { X509Certificate, createHash } from 'node:crypto';
9
+ import { formatOutput, formatDetail, printSuccess, printWarning, printInfo } from '../core/output.js';
10
+ import { events, printEvent, line as glyphLine } from '../core/quoxglyph.js';
11
+ import { parseJSON } from './helpers.js';
12
+ import { resolveEndpoint } from '../api/endpoints.js';
13
+
14
+ /**
15
+ * Map a raw /api/v1/gpu/nodes row to the flat shape `fleet gpu`'s table
16
+ * expects. Keeps the GPU-specific field munging (device count + first GPU
17
+ * name, MB -> GB, digest shortening, relative last-seen) local to this
18
+ * command instead of teaching the shared table renderer about GPU rows.
19
+ */
20
+ function formatGpuRow(node) {
21
+ const summary = node.gpu_summary || {};
22
+ const deviceCount = summary.device_count ?? 0;
23
+ const names = Array.isArray(summary.names) ? summary.names : [];
24
+ const gpus = deviceCount > 0
25
+ ? `${deviceCount}x ${names[0] || 'GPU'}`
26
+ : '—';
27
+
28
+ const vramMb = summary.vram_total_mb;
29
+ const vram = typeof vramMb === 'number' ? `${(vramMb / 1024).toFixed(1)} GB` : '—';
30
+
31
+ const backends = Array.isArray(node.backends) ? node.backends.join(', ') : (node.backends || '—');
32
+
33
+ const ward = node.ward_digest ? `${String(node.ward_digest).slice(0, 12)}…` : '—';
34
+
35
+ const lastSeen = relativeTime(node.last_seen_at);
36
+
37
+ return {
38
+ host_id: node.host_id,
39
+ gpus,
40
+ vram,
41
+ backends,
42
+ status: node.status,
43
+ ward,
44
+ last_seen: lastSeen,
45
+ };
46
+ }
47
+
48
+ /**
49
+ * Render a Bastion host's status.last_seen for `fleet hosts` (quox#460).
50
+ * Go's zero time.Time serialises as "0001-01-01T00:00:00Z" — a host that
51
+ * has never checked in gets that literal string, not null/undefined, so a
52
+ * plain falsy check would miss it and print a confusing century-old date.
53
+ */
54
+ function formatBastionLastSeen(iso) {
55
+ if (!iso) return 'never';
56
+ const str = String(iso);
57
+ if (str.startsWith('0001-01-01')) return 'never';
58
+ return str;
59
+ }
60
+
61
+ /** Format an ISO timestamp as a short relative-time string ("5m ago"). */
62
+ function relativeTime(iso) {
63
+ if (!iso) return '—';
64
+ const then = new Date(iso).getTime();
65
+ if (Number.isNaN(then)) return '—';
66
+ const diffSec = Math.max(0, Math.floor((Date.now() - then) / 1000));
67
+ if (diffSec < 60) return `${diffSec}s ago`;
68
+ const diffMin = Math.floor(diffSec / 60);
69
+ if (diffMin < 60) return `${diffMin}m ago`;
70
+ const diffHr = Math.floor(diffMin / 60);
71
+ if (diffHr < 24) return `${diffHr}h ago`;
72
+ const diffDay = Math.floor(diffHr / 24);
73
+ return `${diffDay}d ago`;
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // fleet #425 part B — codex-provision / rehome-agent
78
+ //
79
+ // Both verbs ride the SAME governed exec path as `quox fleet exec` (POST
80
+ // /api/v1/tools/execute with tool_name=ssh_exec), never raw ssh, so policy
81
+ // evaluation and HITL approval on the risky/sudo steps apply naturally.
82
+ //
83
+ // THE 60s PROBLEM: QuoxBastion's /api/v1/exec has a hardcoded 60s cap
84
+ // (exec.go, known + unfixed) and the collector's own callBastion aborts at
85
+ // 30s. `npm install -g` and a QuoxAgent install (~25MB binary + setup) both
86
+ // blow through that, and a transport timeout SIGHUPs the child mid-run. So
87
+ // every long step is LAUNCHED detached with `systemd-run --collect` and then
88
+ // POLLED with short execs against a sentinel file. This is the lesson
89
+ // recorded in the quoxpert-deploy skill ("run long installs detached ...
90
+ // a transport timeout SIGHUPs the child mid-build").
91
+ // ---------------------------------------------------------------------------
92
+
93
+ /** Default Codex model written to ~/.codex/config.toml. */
94
+ export const DEFAULT_CODEX_MODEL = 'gpt-5.4';
95
+
96
+ /** Tools whose input schema has a `host` field (collector reads input.host, not body.host_id). */
97
+ const TOOLS_READING_INPUT_HOST = new Set(['ssh_exec']);
98
+
99
+ /** Locates the vendored (bwrap-capable) codex binary on the host. */
100
+ export const CODEX_VENDOR_FIND_COMMAND =
101
+ "find $(dirname $(readlink -f $(which codex)))/../.. -name codex -path '*vendor*'";
102
+
103
+ /** The one step a human must run themselves. Never scripted — see below. */
104
+ export const CODEX_LOGIN_COMMAND = 'codex login --device-auth';
105
+
106
+ /** GET <collector>/install must answer with a shell script, not a proxy page. */
107
+ export const INSTALL_SCRIPT_MAGIC = '#!/bin/bash';
108
+
109
+ /**
110
+ * The REAL flag names from quoxagent/deploy/install.sh's Usage block. Kept as
111
+ * a named map (not inlined) so fleet-provision.test.js can drift-guard it
112
+ * against the script itself — guessing these silently installs an agent that
113
+ * enrols nowhere.
114
+ */
115
+ export const INSTALL_SH_FLAGS = {
116
+ hostId: '--host-id',
117
+ collector: '--collector',
118
+ org: '--org-id',
119
+ };
120
+
121
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
122
+
123
+ /** POSIX single-quote a string for safe embedding in a shell command. */
124
+ export function shellSingleQuote(value) {
125
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
126
+ }
127
+
128
+ /**
129
+ * Build the exact AppArmor profile body for the codex user-namespace rule.
130
+ * `vendorPath` is the path found by CODEX_VENDOR_FIND_COMMAND.
131
+ */
132
+ export function buildAppArmorProfile(vendorPath) {
133
+ const path = String(vendorPath || '').trim();
134
+ if (!path) {
135
+ throw new Error(
136
+ 'Cannot build the AppArmor profile: no vendor codex path was detected on the host. '
137
+ + `Run \`${CODEX_VENDOR_FIND_COMMAND}\` there and check codex is installed.`
138
+ );
139
+ }
140
+ return [
141
+ 'abi <abi/4.0>,',
142
+ 'include <tunables/global>',
143
+ `profile codex-userns ${path} flags=(unconfined) {`,
144
+ ' userns,',
145
+ '}',
146
+ '',
147
+ ].join('\n');
148
+ }
149
+
150
+ /**
151
+ * Idempotently set the top-level `model` key in a config.toml body without
152
+ * clobbering any other key. A missing key is inserted BEFORE the first
153
+ * [table] header — appending at the end would silently land the key inside
154
+ * whatever table happens to be last, which is a different setting entirely.
155
+ */
156
+ export function upsertModelLine(existing, model) {
157
+ const line = `model = "${model}"`;
158
+ const text = existing == null ? '' : String(existing);
159
+ if (!text.trim()) return `${line}\n`;
160
+
161
+ const lines = text.split('\n');
162
+ let firstTable = lines.findIndex((l) => /^[ \t]*\[/.test(l));
163
+ if (firstTable === -1) firstTable = lines.length;
164
+
165
+ const idx = lines.findIndex((l, i) => i < firstTable && /^[ \t]*model[ \t]*=/.test(l));
166
+ if (idx !== -1) lines[idx] = line;
167
+ else lines.splice(firstTable, 0, line);
168
+
169
+ const out = lines.join('\n');
170
+ return out.endsWith('\n') ? out : `${out}\n`;
171
+ }
172
+
173
+ /** Write a full config.toml body back to the host in one exec. */
174
+ export function buildConfigWriteCommand(content) {
175
+ const body = String(content).endsWith('\n') ? String(content) : `${content}\n`;
176
+ return `mkdir -p ~/.codex && cat > ~/.codex/config.toml <<'QUOX_CODEX_EOF'\n${body}QUOX_CODEX_EOF`;
177
+ }
178
+
179
+ /** Write the AppArmor profile and reload it (sudo — the governed path gates it). */
180
+ export function buildAppArmorInstallCommand(profileBody, target = '/etc/apparmor.d/codex-userns') {
181
+ const body = String(profileBody).endsWith('\n') ? String(profileBody) : `${profileBody}\n`;
182
+ return `sudo tee ${target} > /dev/null <<'QUOX_AA_EOF'\n${body}QUOX_AA_EOF\n`
183
+ + `sudo apparmor_parser -r ${target}`;
184
+ }
185
+
186
+ /** Sentinel + log paths for a detached unit. */
187
+ export function detachedPaths(unit) {
188
+ return { sentinel: `/tmp/${unit}.exit`, log: `/tmp/${unit}.log` };
189
+ }
190
+
191
+ /**
192
+ * Launch `command` as a transient systemd unit so it survives the exec
193
+ * transport closing, recording its exit code in a sentinel file.
194
+ */
195
+ export function buildDetachedRun({ unit, command, sentinel, log }) {
196
+ const inner = `{ ${command}; } > ${log} 2>&1; echo $? > ${sentinel}`;
197
+ return `rm -f ${sentinel} ${log}; sudo systemd-run --collect --unit ${unit} `
198
+ + `/bin/bash -lc ${shellSingleQuote(inner)}`;
199
+ }
200
+
201
+ /** Short exec used to poll a detached unit for completion. */
202
+ export function buildSentinelPollCommand(sentinel) {
203
+ return `if [ -f ${sentinel} ]; then echo "DONE=$(cat ${sentinel})"; else echo PENDING; fi`;
204
+ }
205
+
206
+ /** Parse the sentinel poll output. */
207
+ export function parseSentinel(output) {
208
+ const m = /DONE=(-?\d+)/.exec(String(output == null ? '' : output));
209
+ if (!m) return { done: false, exitCode: null };
210
+ return { done: true, exitCode: Number(m[1]) };
211
+ }
212
+
213
+ /** The full install pipe, using install.sh's REAL flags. */
214
+ export function buildRehomeInstallCommand({ collector, hostId, orgId }) {
215
+ const base = String(collector).replace(/\/+$/, '');
216
+ // quox#1223: fail loud, no `curl | bash` (see buildEnrolCommand). TLS verification stays on.
217
+ return `( f=$(mktemp) && trap 'rm -f "$f"' EXIT && curl -fsSL ${base}/install -o "$f" && sudo bash "$f"`
218
+ + ` ${INSTALL_SH_FLAGS.hostId} ${shellSingleQuote(hostId)}`
219
+ + ` ${INSTALL_SH_FLAGS.collector} ${shellSingleQuote(base)}`
220
+ + ` ${INSTALL_SH_FLAGS.org} ${shellSingleQuote(orgId)} )`;
221
+ }
222
+
223
+ // ── fleet enrol (Z2H-AGENTINST) ─────────────────────────────────────────────
224
+
225
+ export const ENROL_OS = ['linux', 'darwin'];
226
+ export const ENROL_ARCH = ['amd64', 'arm64'];
227
+
228
+ /** Exact wording when GET /install answers 503 (no installer shipped). */
229
+ export const ENROL_NO_INSTALLER_MESSAGE =
230
+ 'this install has no agent installer yet (quoxagent binaries not shipped); enrolment is not available';
231
+
232
+ /** Argument validation for `fleet enrol`. Returns a list of errors. */
233
+ export function validateEnrolArgs(hostId, options = {}) {
234
+ const usage = 'Usage: quox fleet enrol <host> [--os linux --arch amd64|arm64] [--run]';
235
+ const errors = [];
236
+ if (!hostId || !String(hostId).trim()) errors.push(`Missing <host>. ${usage}`);
237
+ else if (/[\s'"`$\;|&<>]/.test(String(hostId))) {
238
+ errors.push(`<host> "${hostId}" has characters that are not valid in a host id (spaces, quotes, shell symbols). ${usage}`);
239
+ }
240
+ if (options.os !== undefined && !ENROL_OS.includes(String(options.os))) {
241
+ errors.push(`--os must be one of ${ENROL_OS.join(', ')} (got "${options.os}"). ${usage}`);
242
+ }
243
+ if (options.arch !== undefined && !ENROL_ARCH.includes(String(options.arch))) {
244
+ errors.push(`--arch must be one of ${ENROL_ARCH.join(', ')} (got "${options.arch}"). ${usage}`);
245
+ }
246
+ return errors;
247
+ }
248
+
249
+ /**
250
+ * Pull the collector URL the serving collector templated into install.sh's
251
+ * `COLLECTOR_URL="${COLLECTOR_URL:-<url>}"` default arm. Empty string when the
252
+ * collector has no event URL configured (the script then refuses to run
253
+ * without --collector).
254
+ */
255
+ export function parseInstallerCollectorUrl(script) {
256
+ const m = /^COLLECTOR_URL="\$\{COLLECTOR_URL:-([^}"]*)\}"$/m.exec(String(script || ''));
257
+ return m ? m[1].trim() : '';
258
+ }
259
+
260
+ /**
261
+ * Probe GET <base>/install. Returns { collectorUrl, bytes } or throws an Error
262
+ * whose message is plain English, never a raw status dump. `fetchImpl` is
263
+ * injectable for tests.
264
+ */
265
+ export async function probeEnrolInstaller(base, fetchImpl = fetch) {
266
+ const root = String(base).replace(/\/+$/, '');
267
+ const url = `${root}/install`;
268
+ let resp;
269
+ try {
270
+ resp = await fetchImpl(url, { signal: AbortSignal.timeout(10000) });
271
+ } catch (err) {
272
+ throw new Error(
273
+ `Could not reach ${url} (${err.message}). Check the Quox instance is running and reachable from this machine.`
274
+ );
275
+ }
276
+ if (resp.status === 503) throw new Error(`Enrolment is not available: ${ENROL_NO_INSTALLER_MESSAGE}.`);
277
+ if (!resp.ok) {
278
+ throw new Error(`Enrolment is not available: GET ${url} answered HTTP ${resp.status}, not an installer.`);
279
+ }
280
+ const script = await resp.text();
281
+ if (!script.startsWith(INSTALL_SCRIPT_MAGIC)) {
282
+ throw new Error(
283
+ `Enrolment is not available: ${url} answered but not with the QuoxAgent installer `
284
+ + '(a proxy error page or a login redirect is in the way). Point the profile at the Quox instance itself.'
285
+ );
286
+ }
287
+ return { collectorUrl: parseInstallerCollectorUrl(script), bytes: script.length };
288
+ }
289
+
290
+ /**
291
+ * The exact command to run ON the host. `base` is the URL the installer is
292
+ * fetched from (the collector's own templated LAN URL when it has one, else
293
+ * the instance URL); `collector` is the URL the agent will report to.
294
+ *
295
+ * Fail loud (quox#1223): the script is downloaded to a temp file with
296
+ * `curl -f` and only then run, inside a subshell, so a curl failure (TLS
297
+ * error, HTTP error) stops the command with curl's non-zero exit and nothing
298
+ * is piped into bash. TLS verification stays ON unless the operator passes
299
+ * `insecureTls` (the explicit --insecure-tls opt-in).
300
+ */
301
+ export function buildEnrolCommand({ base, collector, hostId, insecureTls = false, tlsPin = null, binarySha256 = null }) {
302
+ const root = String(base).replace(/\/+$/, '');
303
+ const coll = String(collector || root).replace(/\/+$/, '');
304
+ // REL-423: binary integrity out of band. install.sh fetches the agent binary from the collector URL (plain http
305
+ // on a default install) and fails closed when BINARY_SHA256 does not match; the CLI read this hash over its own
306
+ // verified connection, so a MITM on the binary download cannot swap it.
307
+ const sha = /^[0-9a-f]{64}$/.test(String(binarySha256 || '')) ? `env BINARY_SHA256=${binarySha256} ` : '';
308
+ const flags = ` ${INSTALL_SH_FLAGS.hostId} ${shellSingleQuote(hostId)} ${INSTALL_SH_FLAGS.collector} ${shellSingleQuote(coll)} )`;
309
+ if (tlsPin && !insecureTls) {
310
+ // REL-423: a self-signed edge with TLS verification ON and no -k. The install's own certificate rides in the
311
+ // command as the only trusted CA (--cacert: chain + hostname checked) and its public key is pinned too
312
+ // (--pinnedpubkey: a different key aborts with curl exit 90). Both were read over a verified handshake.
313
+ const { pem, pin } = pemAndPinParts(tlsPin);
314
+ return `( f=$(mktemp) && c=$(mktemp) && trap 'rm -f "$f" "$c"' EXIT && printf -- '${pem}' > "$c"`
315
+ + ` && curl -fsSL --cacert "$c" --pinnedpubkey 'sha256//${pin}' ${root}/install -o "$f" && sudo ${sha}bash "$f"` + flags;
316
+ }
317
+ const curl = insecureTls ? 'curl -fsSLk' : 'curl -fsSL';
318
+ if (sha) return `( f=$(mktemp) && trap 'rm -f "$f"' EXIT && ${curl} ${root}/install -o "$f" && sudo ${sha}bash "$f"` + flags;
319
+ return `( f=$(mktemp) && trap 'rm -f "$f"' EXIT && ${curl} ${root}/install -o "$f" && sudo bash "$f"`
320
+ + ` ${INSTALL_SH_FLAGS.hostId} ${shellSingleQuote(hostId)}`
321
+ + ` ${INSTALL_SH_FLAGS.collector} ${shellSingleQuote(coll)} )`;
322
+ }
323
+
324
+ /** Validate a { pem, pin } pair and return the shell-safe parts (base64 only, so nothing can break out of quotes). */
325
+ function pemAndPinParts({ pem, pin }) {
326
+ const body = String(pem || '').replace(/-----(BEGIN|END) CERTIFICATE-----/g, '').replace(/\s+/g, '');
327
+ if (!/^[A-Za-z0-9+/]+=*$/.test(body) || !/^[A-Za-z0-9+/]{43}=$/.test(String(pin || ''))) throw new Error('invalid TLS pin');
328
+ const lines = body.match(/.{1,64}/g).join('\\n');
329
+ return { pem: `-----BEGIN CERTIFICATE-----\\n${lines}\\n-----END CERTIFICATE-----\\n`, pin };
330
+ }
331
+
332
+ const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
333
+
334
+ /**
335
+ * REL-423: the URL another host fetches the installer from. The instance's own TLS edge (it proxies /install and
336
+ * /download/quoxagent-* to the collector), with a loopback profile host (the host CLI's `local` profile is
337
+ * https://localhost:3000) swapped for the LAN host the collector advertises (COLLECTOR_LAN_URL, set by setup.sh).
338
+ * Returns null when a loopback base has no LAN host to swap in.
339
+ */
340
+ export function edgeInstallerBase(base, collectorUrl) {
341
+ const u = new URL(String(base));
342
+ if (LOOPBACK_HOSTNAMES.has(u.hostname)) {
343
+ let lan = '';
344
+ try { lan = collectorUrl ? new URL(collectorUrl).hostname : ''; } catch { lan = ''; }
345
+ if (!lan || LOOPBACK_HOSTNAMES.has(lan)) return null;
346
+ u.hostname = lan;
347
+ }
348
+ return `${u.protocol}//${u.host}`;
349
+ }
350
+
351
+ /**
352
+ * REL-423: read the edge's certificate over a VERIFIED handshake (Node's CAs plus NODE_EXTRA_CA_CERTS, hostname
353
+ * checked) and return { pem, pin } (pin = base64 SHA-256 of the SPKI, curl's --pinnedpubkey form). Throws with a
354
+ * plain reason when the certificate cannot be verified, so no pin is ever taken from an unverified peer.
355
+ */
356
+ export function readEdgeTlsPin(url, { connect = tls.connect, timeoutMs = 10000 } = {}) {
357
+ const u = new URL(String(url));
358
+ const host = u.hostname.replace(/^\[|\]$/g, '');
359
+ const port = Number(u.port || 443);
360
+ const isIp = /^[0-9.]+$/.test(host) || host.includes(':');
361
+ return new Promise((resolve, reject) => {
362
+ const sock = connect({ host, port, servername: isIp ? undefined : host, rejectUnauthorized: true });
363
+ const done = (fn, v) => { try { sock.destroy(); } catch { /* closed */ } fn(v); };
364
+ sock.setTimeout?.(timeoutMs, () => done(reject, new Error(`timed out reading the TLS certificate of ${u.host}`)));
365
+ sock.once('error', (err) => done(reject, new Error(`cannot verify the TLS certificate of ${u.host} (${err.code || err.message})`)));
366
+ sock.once('secureConnect', () => {
367
+ try {
368
+ const raw = sock.getPeerCertificate(true)?.raw;
369
+ if (!raw) throw new Error(`no certificate from ${u.host}`);
370
+ const x = new X509Certificate(raw);
371
+ const spki = x.publicKey.export({ type: 'spki', format: 'der' });
372
+ done(resolve, { pem: x.toString(), pin: createHash('sha256').update(spki).digest('base64') });
373
+ } catch (err) { done(reject, err); }
374
+ });
375
+ });
376
+ }
377
+
378
+ /** Argument validation for `fleet codex-provision`. Returns a list of errors. */
379
+ export function validateCodexProvisionArgs(hostId, options = {}) {
380
+ const usage = 'Usage: quox fleet codex-provision <hostId> [--model <model>]';
381
+ const errors = [];
382
+ if (!hostId || !String(hostId).trim()) errors.push(`Missing <hostId>. ${usage}`);
383
+ if (options.model !== undefined && !String(options.model).trim()) {
384
+ errors.push(`--model needs a model name, e.g. --model ${DEFAULT_CODEX_MODEL}. ${usage}`);
385
+ }
386
+ return errors;
387
+ }
388
+
389
+ /** Argument validation for `fleet rehome-agent`. Returns a list of errors. */
390
+ export function validateRehomeArgs(hostId, options = {}) {
391
+ const usage = 'Usage: quox fleet rehome-agent <hostId> --collector <url> --org <orgId>';
392
+ const errors = [];
393
+ if (!hostId || !String(hostId).trim()) errors.push(`Missing <hostId>. ${usage}`);
394
+ const collector = options.collector == null ? '' : String(options.collector).trim();
395
+ const org = options.org == null ? '' : String(options.org).trim();
396
+ if (!collector) errors.push(`Missing --collector <url>. ${usage}`);
397
+ else if (!/^https?:\/\//i.test(collector)) {
398
+ errors.push(
399
+ '--collector must be an absolute http(s) URL, e.g. --collector http://192.0.2.126:9848'
400
+ );
401
+ }
402
+ if (!org) errors.push(`Missing --org <orgId>. ${usage}`);
403
+ return errors;
404
+ }
405
+
406
+ /**
407
+ * Normalise a governed-exec response into { ok, pending, text, error }.
408
+ * `pending: true` is the collector's real approval-gate shape (agentTools.js:
409
+ * { success:false, pending:true, approvalId, instructions }) — a gated step is
410
+ * NOT a failure, it is waiting on a human.
411
+ */
412
+ export function describeGovernedResult(data) {
413
+ const raw = (data && typeof data === 'object' && data.result !== undefined) ? data.result : data;
414
+ if (raw && typeof raw === 'object') {
415
+ if (raw.pending === true) {
416
+ return {
417
+ ok: false,
418
+ pending: true,
419
+ approvalId: raw.approvalId || null,
420
+ text: raw.instructions || raw.message || 'Awaiting human approval.',
421
+ error: null,
422
+ };
423
+ }
424
+ const text = [raw.stdout, raw.stderr].filter(Boolean).join('\n').trim()
425
+ || (typeof raw.output === 'string' ? raw.output.trim() : '');
426
+ const ok = raw.success === false
427
+ ? false
428
+ : (typeof raw.exit_code === 'number' ? raw.exit_code === 0 : true);
429
+ return { ok, pending: false, text, error: raw.error || null };
430
+ }
431
+ return { ok: true, pending: false, text: raw == null ? '' : String(raw), error: null };
432
+ }
433
+
434
+ /** Fail loudly with the pending-approval detail when a governed step is gated. */
435
+ function approvalError(step, described) {
436
+ return new Error(
437
+ `${step} is waiting on human approval${described.approvalId ? ` (approval ${described.approvalId})` : ''}. `
438
+ + `${described.text}\nApprove it, then re-run this command — every step is idempotent.`
439
+ );
440
+ }
441
+
442
+ export function registerFleetCommands(program, ctx) {
443
+ const { client, auth } = ctx;
444
+
445
+ const fleet = program
446
+ .command('fleet')
447
+ .description('Fleet monitoring and management');
448
+
449
+ fleet
450
+ .command('status')
451
+ .alias('health') // #620: 'quox fleet health' was a dead end; it's the verb people reach for.
452
+ .description('Show fleet status (connection counts plus per-host state)')
453
+ .action(async () => {
454
+ auth.requireAuth();
455
+ // fleet #458: /api/v1/fleet/status is a summary-shaped alias
456
+ // ({ status, total_agents, connected, stale, dead, timestamp }) — it
457
+ // has never returned an `agents`/`hosts` array. Reading data.agents ||
458
+ // data.hosts always fell through to [], so the table rendered zero
459
+ // rows and printed only a dim "No results." line: functionally
460
+ // silent. Render the real status fields plus the per-host table
461
+ // (same shape as `fleet agents`) so the verb actually says something.
462
+ const statusEp = resolveEndpoint('v1.fleet.status.list');
463
+ const status = await client.get(statusEp.path, { service: statusEp.service });
464
+ const agentsEp = resolveEndpoint('v1.agents.list');
465
+ const agentsData = await client.get(agentsEp.path, { service: agentsEp.service });
466
+ const hosts = (agentsData.agents || agentsData || []).map(a => ({
467
+ ...a,
468
+ status: typeof a.status === 'object' ? (a.status?.health ?? a.status?.state ?? '-') : a.status,
469
+ last_seen: typeof a.status?.last_seen_seconds === 'number'
470
+ ? `${a.status.last_seen_seconds}s ago`
471
+ : (a.last_seen ?? '-'),
472
+ }));
473
+
474
+ const format = program.opts().output;
475
+
476
+ if (format === 'json') {
477
+ console.log(JSON.stringify({ ...status, hosts }, null, 2));
478
+ return;
479
+ }
480
+
481
+ if (format === 'quiet') {
482
+ formatOutput({
483
+ columns: ['host_id', 'status', 'version', 'last_seen'],
484
+ rows: hosts,
485
+ }, { format, emptyMessage: 'No hosts reporting.' });
486
+ return;
487
+ }
488
+
489
+ formatDetail(status, {
490
+ format,
491
+ title: 'Fleet Status',
492
+ fields: ['status', 'total_agents', 'connected', 'stale', 'dead', 'timestamp'],
493
+ labels: {
494
+ status: 'Status', total_agents: 'Total Agents', connected: 'Connected',
495
+ stale: 'Stale', dead: 'Dead', timestamp: 'As Of',
496
+ },
497
+ });
498
+ console.log('');
499
+ formatOutput({
500
+ columns: ['host_id', 'status', 'version', 'last_seen'],
501
+ headers: ['Host', 'Status', 'Version', 'Last Seen'],
502
+ rows: hosts,
503
+ }, { format, emptyMessage: 'No hosts reporting.' });
504
+ });
505
+
506
+ fleet
507
+ .command('summary')
508
+ .description('Show fleet summary')
509
+ .action(async () => {
510
+ auth.requireAuth();
511
+ const data = await client.get(resolveEndpoint('v1.fleet.summary.list').path, { service: resolveEndpoint('v1.fleet.summary.list').service });
512
+ // fleet #387: the route returns a NESTED shape —
513
+ // { total_agents, connection: {connected, stale, dead},
514
+ // health: {healthy, warning, critical}, resources: {...} } —
515
+ // but this read five flat keys that don't exist at the top level, so
516
+ // every field rendered as a dash. Flattened here rather than renaming
517
+ // the labels, so the display keeps its meaning:
518
+ // Degraded <- health.warning, Down <- connection.dead.
519
+ // `warning` and `critical` are distinct health states, so `critical`
520
+ // is surfaced too instead of being silently folded into Down.
521
+ const flat = {
522
+ total: data.total_agents,
523
+ connected: data.connection?.connected,
524
+ healthy: data.health?.healthy,
525
+ degraded: data.health?.warning,
526
+ critical: data.health?.critical,
527
+ stale: data.connection?.stale,
528
+ down: data.connection?.dead,
529
+ };
530
+ formatDetail({ ...data, ...flat }, {
531
+ format: program.opts().output,
532
+ title: 'Fleet Summary',
533
+ fields: ['total', 'connected', 'healthy', 'degraded', 'critical', 'stale', 'down'],
534
+ labels: {
535
+ total: 'Total', connected: 'Connected', healthy: 'Healthy',
536
+ degraded: 'Degraded', critical: 'Critical', stale: 'Stale', down: 'Down',
537
+ },
538
+ });
539
+ });
540
+
541
+ fleet
542
+ .command('agents')
543
+ .description('List fleet agents')
544
+ .action(async () => {
545
+ auth.requireAuth();
546
+ const data = await client.get(resolveEndpoint('v1.agents.list').path, { service: resolveEndpoint('v1.agents.list').service });
547
+ formatOutput({
548
+ // fleet #392: status is a nested object and last_seen only exists
549
+ // inside it as last_seen_seconds; both rendered as dashes flat.
550
+ columns: ['host_id', 'status', 'version', 'last_seen'],
551
+ headers: ['Host', 'Status', 'Version', 'Last Seen'],
552
+ rows: (data.agents || data || []).map(a => ({
553
+ ...a,
554
+ status: typeof a.status === 'object' ? (a.status?.health ?? a.status?.state ?? '-') : a.status,
555
+ last_seen: typeof a.status?.last_seen_seconds === 'number'
556
+ ? `${a.status.last_seen_seconds}s ago`
557
+ : (a.last_seen ?? '-'),
558
+ })),
559
+ }, { format: program.opts().output });
560
+ });
561
+
562
+ fleet
563
+ .command('hosts')
564
+ .description('List hosts known to the Bastion registry (quox#460) — the full inventory, not just heartbeating agents')
565
+ .action(async () => {
566
+ auth.requireAuth();
567
+ const ep = resolveEndpoint('v1.fleet.hosts.list');
568
+ // quox#1224: on an install with no Bastion the collector answers from the agent registry (200) or, with
569
+ // no agents either, a 503 whose body says so in a sentence. Either way the operator reads that sentence,
570
+ // never a bare status code.
571
+ let data;
572
+ try {
573
+ data = await client.get(ep.path, { service: ep.service });
574
+ } catch (err) {
575
+ if (err && err.status === 503 && err.body && err.body.bastion === false) {
576
+ data = { hosts: [], bastion: false, message: err.body.message || err.body.error };
577
+ } else {
578
+ throw err;
579
+ }
580
+ }
581
+ if (data && data.bastion === false && data.message && program.opts().output !== 'json') printInfo(data.message);
582
+ const raw = data.hosts || data || [];
583
+ const rows = (raw || []).map(h => ({
584
+ id: h.id,
585
+ ip: h.ip,
586
+ group: h.group,
587
+ state: h.status?.state ?? '-',
588
+ health: h.status?.health ?? '-',
589
+ last_seen: formatBastionLastSeen(h.status?.last_seen),
590
+ }));
591
+ formatOutput({
592
+ columns: ['id', 'ip', 'group', 'state', 'health', 'last_seen'],
593
+ headers: ['Host', 'IP', 'Group', 'State', 'Health', 'Last Seen'],
594
+ rows,
595
+ }, { format: program.opts().output, emptyMessage: data && data.bastion === false ? 'No agents enrolled yet.' : 'No hosts in the Bastion registry.' });
596
+ });
597
+
598
+ fleet
599
+ .command('gpu')
600
+ .description('List the durable GPU fleet')
601
+ .action(async () => {
602
+ auth.requireAuth();
603
+ const data = await client.get(resolveEndpoint('v1.gpu.nodes.list').path, { service: resolveEndpoint('v1.gpu.nodes.list').service });
604
+ const nodes = data.nodes || data.rows || data;
605
+ const rows = (nodes || []).map(formatGpuRow);
606
+ formatOutput({
607
+ columns: ['host_id', 'gpus', 'vram', 'backends', 'status', 'ward', 'last_seen'],
608
+ headers: ['Host', 'GPUs', 'VRAM', 'Backends', 'Status', 'Ward', 'Last Seen'],
609
+ rows,
610
+ }, { format: program.opts().output, emptyMessage: 'No GPU nodes enrolled yet.' });
611
+ });
612
+
613
+ fleet
614
+ .command('tools')
615
+ .description('List available fleet tools')
616
+ .action(async () => {
617
+ auth.requireAuth();
618
+ const data = await client.get(resolveEndpoint('v1.tools.list').path, { service: resolveEndpoint('v1.tools.list').service });
619
+ formatOutput({
620
+ columns: ['name', 'description', 'category'],
621
+ headers: ['Name', 'Description', 'Category'],
622
+ rows: data.tools || data,
623
+ }, { format: program.opts().output });
624
+ });
625
+
626
+ fleet
627
+ .command('exec')
628
+ .description('Execute a fleet tool')
629
+ .requiredOption('--tool <name>', 'tool name')
630
+ .option('--input <json>', 'input parameters as JSON')
631
+ .option('--host <hostId>', 'target host')
632
+ .option('--org <id>', 'org ID (overrides active org; required for QUOX_SERVICE_KEY automation callers unless QUOX_ORG_ID is set)')
633
+ .option('--reason <text>', 'why you are running this; shown on the approval card if the call is held for a human')
634
+ .option('--session-label <name>', 'name this session on approval cards (default: $QUOX_SESSION)')
635
+ .action(async (options) => {
636
+ auth.requireAuth();
637
+ // Route reads req.body.tool_name (collector server.js /api/v1/tools/execute)
638
+ // — `tool` was silently 400ing every fleet exec. Same body-key bug
639
+ // family as the fixed run.js/chat.js keys.
640
+ const body = { tool_name: options.tool };
641
+ if (options.input) body.input = parseJSON(options.input, '--input');
642
+ if (options.host) body.host_id = options.host;
643
+ // quox#789: tools whose input schema reads input.host (ssh_exec) never
644
+ // see body.host_id, so the approval was stored with targets [null] and
645
+ // failed after the human approved it. Merge --host into input.host.
646
+ if (options.host && TOOLS_READING_INPUT_HOST.has(options.tool)) {
647
+ const inp = body.input && typeof body.input === 'object' && !Array.isArray(body.input) ? body.input : {};
648
+ if (inp.host && String(inp.host) !== String(options.host)) {
649
+ throw new Error(`--host ${options.host} conflicts with input.host ${inp.host}. Give one or make them match.`);
650
+ }
651
+ body.input = { ...inp, host: inp.host || options.host };
652
+ }
653
+ // HITL-CONTEXT: the reason + session label ride to the approval card.
654
+ if (options.reason) body.reason = String(options.reason);
655
+ { const w = missingReasonWarning(options.tool, options.reason); if (w) console.error(w); }
656
+ if (options.sessionLabel) body.session_label = String(options.sessionLabel);
657
+
658
+ // Collector's /api/v1/tools/execute requires org_id in the body for
659
+ // service-key authenticated callers (isServiceKeyAuth = !req.user,
660
+ // services/collector/server.js) — it has no session to derive org from,
661
+ // so it 400s with "org_id is required for service-key authenticated
662
+ // MCP calls" if the body omits it. A human-logged-in caller has
663
+ // req.user and the server derives org from the session instead, which
664
+ // is why this never surfaced for interactive use.
665
+ //
666
+ // Precedence for the org this CLI sends: explicit --org flag > the
667
+ // QUOX_ORG_ID env var (the automation-mode org, named to match
668
+ // QUOX_SERVICE_KEY) > the logged-in profile's stored org.
669
+ const orgId = auth.getOrgId(options.org || process.env.QUOX_ORG_ID);
670
+ if (!orgId && process.env.QUOX_SERVICE_KEY) {
671
+ throw new Error(
672
+ 'QUOX_SERVICE_KEY is set but no org could be resolved. '
673
+ + 'Set QUOX_ORG_ID=<org> for automation callers, or pass --org <id>.'
674
+ );
675
+ }
676
+ if (orgId) body.org_id = orgId;
677
+
678
+ const data = await client.post(resolveEndpoint('v1.tools.execute').path, { service: resolveEndpoint('v1.tools.execute').service, body, timeout: 120000 });
679
+ if (program.opts().output === 'json') {
680
+ console.log(JSON.stringify(data, null, 2));
681
+ } else {
682
+ if (data.result) console.log(data.result);
683
+ else console.log(JSON.stringify(data, null, 2));
684
+ }
685
+ // quox#791: a held call prints how to read the output once approved.
686
+ const heldId = data && data.pending && (data.approvalId || (data._approval && data._approval.id));
687
+ if (heldId) console.error(`after approval run: quox approvals result ${heldId}`);
688
+ // quox#999: a failed tool call must exit non-zero, or a script/CI step reads success on
689
+ // failure. A HELD (pending) call is not a failure — it awaits a human — so it stays 0.
690
+ const failed = (data && data.success === false) || (data && data.result && data.result.success === false);
691
+ if (failed && !(data && data.pending)) process.exitCode = 1;
692
+ });
693
+
694
+ fleet
695
+ .command('host-update <hostId>')
696
+ .description('Update a host in the Bastion registry (link/clear its bootstrap credential)')
697
+ .option('--bootstrap-credential-id <credId>', 'link a QuoxVault bootstrap (purpose:bootstrap) credential to this host')
698
+ .option('--clear-bootstrap-credential', 'clear the host\'s bootstrap credential link')
699
+ .action(async (hostId, options) => {
700
+ auth.requireAuth();
701
+
702
+ if (options.bootstrapCredentialId && options.clearBootstrapCredential) {
703
+ throw new Error('Pass either --bootstrap-credential-id or --clear-bootstrap-credential, not both.');
704
+ }
705
+ if (!options.bootstrapCredentialId && !options.clearBootstrapCredential) {
706
+ throw new Error('Nothing to update. Pass --bootstrap-credential-id <id> or --clear-bootstrap-credential.');
707
+ }
708
+
709
+ // The Bastion update handler merges: only the ssh fields present in the
710
+ // body are changed, so a partial {ssh:{bootstrap_credential_id}} leaves
711
+ // user/port/key_id intact. An empty string clears the link.
712
+ const bootstrapId = options.clearBootstrapCredential ? '' : options.bootstrapCredentialId;
713
+ const ep = resolveEndpoint('v1.fleet.host.update', { id: hostId });
714
+ const data = await client.put(ep.path, {
715
+ service: ep.service,
716
+ body: { ssh: { bootstrap_credential_id: bootstrapId } },
717
+ });
718
+
719
+ const linked = data?.ssh?.bootstrap_credential_id;
720
+ if (program.opts().output === 'json') {
721
+ console.log(JSON.stringify(data, null, 2));
722
+ } else {
723
+ printEvent(events.updated(
724
+ 'bastion',
725
+ 'host',
726
+ hostId,
727
+ options.clearBootstrapCredential ? 'bootstrap credential cleared' : `bootstrap credential linked: ${linked || bootstrapId}`
728
+ ));
729
+ }
730
+ });
731
+
732
+ // ── fleet #425 part B ────────────────────────────────────────────────────
733
+
734
+ /**
735
+ * One governed exec against a host. Same body construction, org precedence
736
+ * and error text as `fleet exec --tool ssh_exec` above — deliberately, so a
737
+ * gated step behaves identically whichever verb the operator reached for.
738
+ */
739
+ async function governedExec(hostId, command, { orgOverride, timeout = 120000 } = {}) {
740
+ const body = { tool_name: 'ssh_exec', input: { host: hostId, command } };
741
+ const orgId = auth.getOrgId(orgOverride || process.env.QUOX_ORG_ID);
742
+ if (!orgId && process.env.QUOX_SERVICE_KEY) {
743
+ throw new Error(
744
+ 'QUOX_SERVICE_KEY is set but no org could be resolved. '
745
+ + 'Set QUOX_ORG_ID=<org> for automation callers, or pass --exec-org <id>.'
746
+ );
747
+ }
748
+ if (orgId) body.org_id = orgId;
749
+ const ep = resolveEndpoint('v1.tools.execute');
750
+ return client.post(ep.path, { service: ep.service, body, timeout });
751
+ }
752
+
753
+ /**
754
+ * Launch a long step detached and poll its sentinel. Returns
755
+ * { state: 'done'|'failed'|'timeout', exitCode, text }.
756
+ */
757
+ async function runDetachedStep(hostId, { unit, command, orgOverride, attempts = 30, intervalMs = 10000 }) {
758
+ const { sentinel, log } = detachedPaths(unit);
759
+
760
+ const launch = describeGovernedResult(
761
+ await governedExec(hostId, buildDetachedRun({ unit, command, sentinel, log }), { orgOverride })
762
+ );
763
+ if (launch.pending) throw approvalError(`Launching ${unit}`, launch);
764
+ if (!launch.ok) {
765
+ return { state: 'failed', exitCode: null, text: launch.error || launch.text, log };
766
+ }
767
+
768
+ for (let i = 0; i < attempts; i++) {
769
+ await sleep(intervalMs);
770
+ const poll = describeGovernedResult(
771
+ await governedExec(hostId, buildSentinelPollCommand(sentinel), { orgOverride, timeout: 60000 })
772
+ );
773
+ if (poll.pending) throw approvalError(`Polling ${unit}`, poll);
774
+ const parsed = parseSentinel(poll.text);
775
+ if (!parsed.done) continue;
776
+ if (parsed.exitCode === 0) return { state: 'done', exitCode: 0, text: '', log };
777
+ const tail = describeGovernedResult(
778
+ await governedExec(hostId, `tail -40 ${log}`, { orgOverride, timeout: 60000 })
779
+ );
780
+ return { state: 'failed', exitCode: parsed.exitCode, text: tail.text, log };
781
+ }
782
+ return { state: 'timeout', exitCode: null, text: '', log };
783
+ }
784
+
785
+ fleet
786
+ .command('codex-provision <hostId>')
787
+ .description('Provision Codex on a fleet host (npm install, AppArmor userns profile, model config) up to the manual device-auth login')
788
+ .option('--model <model>', `model written to ~/.codex/config.toml (default: ${DEFAULT_CODEX_MODEL})`)
789
+ .option('--exec-org <id>', 'org ID for the governed exec calls (overrides active org; required for QUOX_SERVICE_KEY automation callers unless QUOX_ORG_ID is set)')
790
+ .action(async (hostId, options) => {
791
+ auth.requireAuth();
792
+
793
+ const errors = validateCodexProvisionArgs(hostId, options);
794
+ if (errors.length) throw new Error(errors.join('\n'));
795
+
796
+ const model = (options.model || DEFAULT_CODEX_MODEL).trim();
797
+ const orgOverride = options.execOrg;
798
+ const isJson = program.opts().output === 'json';
799
+ const steps = [];
800
+ const say = (fn, msg) => { if (!isJson) fn(msg); };
801
+
802
+ // 1. npm install -g @openai/codex — detached, it can outlive the 60s cap.
803
+ say(printInfo, `[1/4] Installing @openai/codex on ${hostId} (detached, polled)...`);
804
+ const npm = await runDetachedStep(hostId, {
805
+ unit: 'codex-provision-npm',
806
+ command: 'npm install -g @openai/codex',
807
+ orgOverride,
808
+ });
809
+ steps.push({ step: 'npm_install', ...npm });
810
+ if (npm.state === 'timeout') {
811
+ throw new Error(
812
+ `npm install did not finish within the poll window on ${hostId}. `
813
+ + `It may still be running: check \`systemctl status codex-provision-npm\` and ${npm.log} on the host, then re-run.`
814
+ );
815
+ }
816
+ if (npm.state === 'failed') {
817
+ throw new Error(`npm install -g @openai/codex failed on ${hostId} (exit ${npm.exitCode}):\n${npm.text}`);
818
+ }
819
+ say(printSuccess, ' codex installed.');
820
+
821
+ // 2. Detect the vendored bwrap-capable binary (fast, single exec).
822
+ say(printInfo, '[2/4] Detecting the vendor codex path...');
823
+ const find = describeGovernedResult(
824
+ await governedExec(hostId, CODEX_VENDOR_FIND_COMMAND, { orgOverride })
825
+ );
826
+ if (find.pending) throw approvalError('Vendor path detection', find);
827
+ const vendorPath = String(find.text || '').split('\n').map((l) => l.trim()).filter(Boolean)[0];
828
+ if (!vendorPath) {
829
+ throw new Error(
830
+ `No vendored codex binary found on ${hostId}. Ran: ${CODEX_VENDOR_FIND_COMMAND}\n`
831
+ + `${find.error || find.text || '(no output)'}\n`
832
+ + 'Check codex is on PATH for the exec user and that this npm release still ships a vendor/ tree.'
833
+ );
834
+ }
835
+ steps.push({ step: 'vendor_path', vendor_path: vendorPath });
836
+ say(printSuccess, ` ${vendorPath}`);
837
+
838
+ // 3. AppArmor userns profile (sudo — the governed path gates it).
839
+ say(printInfo, '[3/4] Writing /etc/apparmor.d/codex-userns and reloading...');
840
+ const profile = buildAppArmorProfile(vendorPath);
841
+ const aa = describeGovernedResult(
842
+ await governedExec(hostId, buildAppArmorInstallCommand(profile), { orgOverride })
843
+ );
844
+ if (aa.pending) throw approvalError('The AppArmor profile write', aa);
845
+ if (!aa.ok) {
846
+ throw new Error(`Failed to install the AppArmor profile on ${hostId}: ${aa.error || aa.text}`);
847
+ }
848
+ steps.push({ step: 'apparmor', profile_path: '/etc/apparmor.d/codex-userns' });
849
+ say(printSuccess, ' profile loaded.');
850
+
851
+ // 4. ~/.codex/config.toml — read, upsert the model line locally, write
852
+ // back only if it changed. Other keys are preserved verbatim.
853
+ say(printInfo, `[4/4] Setting model = "${model}" in ~/.codex/config.toml...`);
854
+ const read = describeGovernedResult(
855
+ await governedExec(hostId, 'cat ~/.codex/config.toml 2>/dev/null || true', { orgOverride })
856
+ );
857
+ if (read.pending) throw approvalError('Reading ~/.codex/config.toml', read);
858
+ const current = read.ok ? (read.text || '') : '';
859
+ const next = upsertModelLine(current, model);
860
+ const currentNormalised = (current === '' || current.endsWith('\n')) ? current : `${current}\n`;
861
+ let configChanged = false;
862
+ if (next !== currentNormalised) {
863
+ const write = describeGovernedResult(
864
+ await governedExec(hostId, buildConfigWriteCommand(next), { orgOverride })
865
+ );
866
+ if (write.pending) throw approvalError('Writing ~/.codex/config.toml', write);
867
+ if (!write.ok) throw new Error(`Failed to write ~/.codex/config.toml on ${hostId}: ${write.error || write.text}`);
868
+ configChanged = true;
869
+ }
870
+ steps.push({ step: 'config_toml', model, changed: configChanged });
871
+ say(printSuccess, configChanged ? ` model set to ${model}.` : ` already model = "${model}", left alone.`);
872
+
873
+ if (isJson) {
874
+ console.log(JSON.stringify({
875
+ host_id: hostId,
876
+ model,
877
+ vendor_path: vendorPath,
878
+ steps,
879
+ manual_step: { command: CODEX_LOGIN_COMMAND, host: hostId, reason: 'device-auth is interactive and must not be scripted' },
880
+ }, null, 2));
881
+ return;
882
+ }
883
+
884
+ console.log();
885
+ console.log(chalk.bold('One manual step remains — it cannot be scripted.'));
886
+ console.log();
887
+ console.log(` On ${chalk.cyan(hostId)}, run: ${chalk.bold(CODEX_LOGIN_COMMAND)}`);
888
+ console.log();
889
+ console.log(chalk.dim(' It prints a URL and a one-time code. Open the URL, enter the code,'));
890
+ console.log(chalk.dim(' and finish the sign-in in a browser. The code expires after 15 minutes,'));
891
+ console.log(chalk.dim(' so start it when you are ready to complete it.'));
892
+ console.log(chalk.dim(' Quox never brokers that login: the device-auth code is a human credential.'));
893
+ console.log();
894
+ });
895
+
896
+ fleet
897
+ .command('rehome-agent <hostId>')
898
+ .description('Re-enrol a host\'s QuoxAgent against a different collector by clean reinstall from that collector')
899
+ .requiredOption('--collector <url>', 'the NEW collector base URL, e.g. http://192.0.2.126:9848')
900
+ // NOT requiredOption: --org shadows the root program's global --org
901
+ // <slug> (bin/quox.js). Commander's mandatory-option check runs BEFORE
902
+ // the mergeShadowedGlobals preAction hook, so a requiredOption here
903
+ // always fired "required option '--org' not specified" even when the
904
+ // user supplied it (quox#457). validateRehomeArgs below already reports
905
+ // a missing --org with the same usage string.
906
+ .option('--org <orgId>', 'org the agent should enrol into on the new collector')
907
+ .option('--exec-org <id>', 'org ID for the governed exec calls against the CURRENT collector (defaults to the active org)')
908
+ .option('--wait <seconds>', 'how long to wait for the host to appear on the new collector', '120')
909
+ .action(async (hostId, options) => {
910
+ auth.requireAuth();
911
+
912
+ const errors = validateRehomeArgs(hostId, options);
913
+ if (errors.length) throw new Error(errors.join('\n'));
914
+
915
+ const collector = String(options.collector).trim().replace(/\/+$/, '');
916
+ const orgId = String(options.org).trim();
917
+ const orgOverride = options.execOrg;
918
+ const isJson = program.opts().output === 'json';
919
+ const say = (fn, msg) => { if (!isJson) fn(msg); };
920
+
921
+ // 1. Preflight the NEW collector actually serves the installer. Same
922
+ // actionable-error shape as the collector's own GET /install 503.
923
+ const installUrl = `${collector}/install`;
924
+ say(printInfo, `[1/3] Preflight: GET ${installUrl}`);
925
+ let resp;
926
+ try {
927
+ resp = await fetch(installUrl, { signal: AbortSignal.timeout(10000) });
928
+ } catch (err) {
929
+ throw new Error(
930
+ `Preflight failed: could not reach ${installUrl} (${err.message}). `
931
+ + 'Check the new collector is running and reachable from this machine, then retry.'
932
+ );
933
+ }
934
+ if (!resp.ok) {
935
+ const body = (await resp.text().catch(() => '')).slice(0, 300);
936
+ throw new Error(
937
+ `Preflight failed: GET ${installUrl} returned HTTP ${resp.status}.\n${body}\n`
938
+ + 'The new collector must serve quoxagent/deploy/install.sh: set QuoxAgent_ROOT on that '
939
+ + 'collector and make sure the quoxagent checkout is mounted into its container.'
940
+ );
941
+ }
942
+ const script = await resp.text();
943
+ if (!script.startsWith(INSTALL_SCRIPT_MAGIC)) {
944
+ throw new Error(
945
+ `Preflight failed: GET ${installUrl} returned 200 but the body does not start with `
946
+ + `"${INSTALL_SCRIPT_MAGIC}" (got "${script.slice(0, 60).replace(/\n/g, '\\n')}").\n`
947
+ + 'Something other than the QuoxAgent installer is answering there (a proxy error page '
948
+ + 'or a login redirect). Point --collector at the collector itself, not a gateway.'
949
+ );
950
+ }
951
+ say(printSuccess, ` ${script.length} bytes of installer, starts with ${INSTALL_SCRIPT_MAGIC}.`);
952
+
953
+ // 2. Clean reinstall from the new collector — detached: it downloads a
954
+ // ~25MB binary plus setup, well over the exec cap.
955
+ say(printInfo, `[2/3] Reinstalling QuoxAgent on ${hostId} from ${collector} (detached, polled)...`);
956
+ const install = await runDetachedStep(hostId, {
957
+ unit: 'quoxagent-rehome',
958
+ command: buildRehomeInstallCommand({ collector, hostId, orgId }),
959
+ orgOverride,
960
+ });
961
+ if (install.state === 'timeout') {
962
+ throw new Error(
963
+ `The install did not finish within the poll window on ${hostId}. `
964
+ + `It may still be running: check \`systemctl status quoxagent-rehome\` and ${install.log} on the host, then re-run.`
965
+ );
966
+ }
967
+ if (install.state === 'failed') {
968
+ throw new Error(`QuoxAgent install failed on ${hostId} (exit ${install.exitCode}):\n${install.text}`);
969
+ }
970
+ say(printSuccess, ' install completed.');
971
+
972
+ // 3. Bounded wait for the host to heartbeat into the NEW collector.
973
+ // Same endpoint `fleet agents` reads, pointed at the new base URL.
974
+ const waitSeconds = Math.max(0, Number.parseInt(options.wait, 10) || 0);
975
+ const agentsPath = resolveEndpoint('v1.agents.list').path;
976
+ say(printInfo, `[3/3] Waiting up to ${waitSeconds}s for ${hostId} on ${collector}${agentsPath}...`);
977
+ const deadline = Date.now() + waitSeconds * 1000;
978
+ let enrolled = false;
979
+ let lastDetail = 'no successful listing yet';
980
+ do {
981
+ try {
982
+ const listResp = await fetch(`${collector}${agentsPath}`, { signal: AbortSignal.timeout(10000) });
983
+ if (!listResp.ok) {
984
+ // An error body is not data: a 401 is "cannot see", not "absent".
985
+ lastDetail = `HTTP ${listResp.status} from ${collector}${agentsPath}`;
986
+ } else {
987
+ const data = await listResp.json();
988
+ const agents = data.agents || data || [];
989
+ enrolled = (Array.isArray(agents) ? agents : []).some((a) => a.host_id === hostId);
990
+ lastDetail = enrolled ? 'heartbeating' : `listing returned ${Array.isArray(agents) ? agents.length : 0} agents, none matching ${hostId}`;
991
+ }
992
+ } catch (err) {
993
+ lastDetail = err.message;
994
+ }
995
+ if (enrolled || Date.now() >= deadline) break;
996
+ await sleep(5000);
997
+ } while (Date.now() < deadline);
998
+
999
+ if (isJson) {
1000
+ console.log(JSON.stringify({
1001
+ host_id: hostId,
1002
+ collector,
1003
+ org_id: orgId,
1004
+ install: { state: install.state, exit_code: install.exitCode },
1005
+ enrolled,
1006
+ detail: lastDetail,
1007
+ reminder: 'dev.register/dev.sessions capability profiles are signed per-collector; dev-enabled hosts must re-register with the new collector.',
1008
+ }, null, 2));
1009
+ return;
1010
+ }
1011
+
1012
+ if (enrolled) printEvent(glyphLine('bastion', 'agent rehomed', { result: `${hostId} -> ${collector}`, status: 'ok' }));
1013
+ else printWarning(`${hostId} has NOT been seen on ${collector} yet (${lastDetail}). The install succeeded; verify with \`quox fleet agents\` against the new collector.`);
1014
+
1015
+ console.log();
1016
+ console.log(chalk.bold('Reminder: dev capability profiles are per-collector.'));
1017
+ console.log(chalk.dim(' dev.register / dev.sessions profiles are SIGNED by the collector that issued them,'));
1018
+ console.log(chalk.dim(` so a dev-enabled host must re-register with ${collector} before dev sessions work again.`));
1019
+ console.log();
1020
+ });
1021
+
1022
+ fleet
1023
+ .command('enrol <host>')
1024
+ .alias('enroll')
1025
+ .description('Print (default; or with --run, execute over the governed path) the exact command that enrols a host into this Quox instance. Needs the instance to ship the quoxagent installer.')
1026
+ .option('--os <os>', `target OS: ${ENROL_OS.join('|')} (default: linux)`)
1027
+ .option('--arch <arch>', `target CPU: ${ENROL_ARCH.join('|')} (default: amd64)`)
1028
+ // NOTE: no --print flag. Printing is the default, and `--print` anywhere in argv is claimed by the
1029
+ // root headless-chat router (lib/quoxon/route.js), which would swallow this whole command.
1030
+ .option('--run', 'run it on <host> through the governed exec path (needs <host> already reachable by the Bastion; may wait on approval)')
1031
+ .option('--insecure-tls', 'opt in to skipping TLS verification when fetching the installer (self-signed certs). Off by default; prints a warning')
1032
+ .option('--exec-org <id>', 'org ID for the governed exec calls (with --run)')
1033
+ .option('--wait <seconds>', 'with --run: how long to wait for the host to show up', '120')
1034
+ .action(async (hostId, options) => {
1035
+ auth.requireAuth();
1036
+ const errors = validateEnrolArgs(hostId, options);
1037
+ if (errors.length) throw new Error(errors.join('\n'));
1038
+
1039
+ const host = String(hostId).trim();
1040
+ const os = options.os || 'linux';
1041
+ const arch = options.arch || 'amd64';
1042
+ const isJson = program.opts().output === 'json';
1043
+ const say = (fn, msg) => { if (!isJson) fn(msg); };
1044
+ const base = String(ctx.config.getBaseUrl()).replace(/\/+$/, '');
1045
+
1046
+ // 1. Is there an installer at all? Never hand out a command that cannot work.
1047
+ const probe = await probeEnrolInstaller(base);
1048
+
1049
+ // 2. A binary must be staged for the platform; its hash (read over this verified connection) is pinned
1050
+ // into the command as BINARY_SHA256 (REL-423), so the host checks the binary it downloads.
1051
+ const binUrl = `${base}/download/quoxagent-${os}-${arch}.sha256`;
1052
+ let bin;
1053
+ try { bin = await fetch(binUrl, { signal: AbortSignal.timeout(10000) }); }
1054
+ catch (err) { throw new Error(`Could not reach ${binUrl} (${err.message}).`); }
1055
+ if (!bin.ok) {
1056
+ throw new Error(
1057
+ `Enrolment is not available for ${os}/${arch}: this install has no quoxagent binary staged for it (HTTP ${bin.status}).`
1058
+ );
1059
+ }
1060
+ const binarySha256 = (/^([0-9a-f]{64})\b/.exec(await bin.text()) || [])[1] || null;
1061
+ if (!binarySha256) throw new Error(`Enrolment is not available for ${os}/${arch}: ${binUrl} did not return a SHA-256.`);
1062
+
1063
+ const collector = probe.collectorUrl || base;
1064
+ const insecureTls = Boolean(options.insecureTls);
1065
+ // REL-423 (supersedes the quox#1223 plain-http collector fetch): an https instance hands out its own TLS edge
1066
+ // (/install is proxied to the collector there), with TLS verification ON for its self-signed certificate:
1067
+ // the certificate and its key pin are read now over a verified handshake and travel in the command.
1068
+ // --insecure-tls (explicit opt-in) and a plain-http instance keep the collector URL, as before.
1069
+ let installerBase = probe.collectorUrl || base;
1070
+ let tlsPin = null;
1071
+ if (/^https:/i.test(base) && !insecureTls) {
1072
+ installerBase = edgeInstallerBase(base, probe.collectorUrl);
1073
+ if (!installerBase) {
1074
+ throw new Error(
1075
+ `Enrolment needs this server's LAN address: the profile points at ${base} and the collector advertises no `
1076
+ + 'LAN URL. Set COLLECTOR_LAN_URL=http://<this-server-LAN-address>:9848 in the install\'s .env '
1077
+ + '(./scripts/setup.sh does this) and restart the collector, or use a profile with the LAN address.'
1078
+ );
1079
+ }
1080
+ try { tlsPin = await readEdgeTlsPin(installerBase); }
1081
+ catch (err) {
1082
+ throw new Error(
1083
+ `Cannot hand out a verified enrol command: ${err.message}. If the certificate does not name `
1084
+ + `${new URL(installerBase).hostname}, reissue it (rm ssl/quox.crt ssl/quox.key && ./scripts/setup.sh); `
1085
+ + 'or re-run with --insecure-tls to accept the risk explicitly.'
1086
+ );
1087
+ }
1088
+ }
1089
+ const command = buildEnrolCommand({ base: installerBase, collector, hostId: host, insecureTls, tlsPin, binarySha256 });
1090
+
1091
+ if (!options.run) {
1092
+ if (isJson) {
1093
+ console.log(JSON.stringify({ host_id: host, os, arch, installer_url: `${installerBase}/install`, collector, insecure_tls: insecureTls, tls_pin: tlsPin ? `sha256//${tlsPin.pin}` : null, binary_sha256: binarySha256, command }, null, 2));
1094
+ return;
1095
+ }
1096
+ if (!probe.collectorUrl) {
1097
+ printWarning(`The installer has no collector URL configured (COLLECTOR_LAN_URL / COLLECTOR_PUBLIC_URL on the collector); the command passes ${collector} explicitly.`);
1098
+ }
1099
+ if (insecureTls) {
1100
+ printWarning('--insecure-tls: the command skips TLS certificate verification. Anyone on the path can swap the installer, which runs as root. Use it only on a network you trust.');
1101
+ } else if (/^http:/i.test(installerBase)) {
1102
+ printWarning(`The installer is fetched over plain http (${installerBase}); the agent binary is still checked against BINARY_SHA256.`);
1103
+ }
1104
+ console.log();
1105
+ console.log(`Run this on ${chalk.cyan(host)} (${os}/${arch}), as a user who can sudo:`);
1106
+ console.log();
1107
+ console.log(` ${chalk.bold(command)}`);
1108
+ console.log();
1109
+ console.log(chalk.dim(' Then `quox fleet agents` and `quox fleet hosts` list it once it heartbeats.'));
1110
+ console.log(chalk.dim(' The host must be able to reach ' + collector + ' (the instance\'s collector).'));
1111
+ console.log();
1112
+ return;
1113
+ }
1114
+
1115
+ // --run: governed ssh_exec to <host>, detached and polled.
1116
+ say(printInfo, `Installing on ${host} from ${base} (detached, polled)...`);
1117
+ const install = await runDetachedStep(host, {
1118
+ unit: 'quoxagent-enrol',
1119
+ command,
1120
+ orgOverride: options.execOrg,
1121
+ });
1122
+ if (install.state === 'timeout') {
1123
+ throw new Error(`The install did not finish in time on ${host}. Check \`systemctl status quoxagent-enrol\` and ${install.log} on the host, then re-run.`);
1124
+ }
1125
+ if (install.state === 'failed') {
1126
+ throw new Error(`QuoxAgent install failed on ${host} (exit ${install.exitCode}):\n${install.text}`);
1127
+ }
1128
+ say(printSuccess, ' install completed.');
1129
+
1130
+ const waitSeconds = Math.max(0, Number.parseInt(options.wait, 10) || 0);
1131
+ const deadline = Date.now() + waitSeconds * 1000;
1132
+ let seen = false;
1133
+ let detail = 'not listed yet';
1134
+ do {
1135
+ try {
1136
+ const ep = resolveEndpoint('v1.agents.list');
1137
+ const data = await client.get(ep.path, { service: ep.service });
1138
+ const agents = data.agents || data || [];
1139
+ seen = (Array.isArray(agents) ? agents : []).some((a) => a.host_id === host);
1140
+ detail = seen ? 'heartbeating' : `agent list has ${Array.isArray(agents) ? agents.length : 0} entries, none matching ${host}`;
1141
+ } catch (err) { detail = err.message; }
1142
+ if (seen || Date.now() >= deadline) break;
1143
+ await sleep(5000);
1144
+ } while (Date.now() < deadline);
1145
+
1146
+ if (isJson) {
1147
+ console.log(JSON.stringify({ host_id: host, command, install: { state: install.state, exit_code: install.exitCode }, enrolled: seen, detail }, null, 2));
1148
+ return;
1149
+ }
1150
+ if (seen) printEvent(glyphLine('bastion', 'host enrolled', { result: `${host} -> ${collector}`, status: 'ok' }));
1151
+ else printWarning(`${host} is not listed yet (${detail}). The install succeeded; check \`quox fleet agents\` shortly.`);
1152
+ });
1153
+
1154
+ fleet
1155
+ .command('watch')
1156
+ .description('Watch fleet status in real-time')
1157
+ .action(async () => {
1158
+ auth.requireAuth();
1159
+ console.log(chalk.dim('Watching fleet status... (Ctrl+C to stop)\n'));
1160
+
1161
+ const poll = async () => {
1162
+ try {
1163
+ const data = await client.get(resolveEndpoint('v1.fleet.status.list').path, { service: resolveEndpoint('v1.fleet.status.list').service });
1164
+ const agents = data.agents || data.hosts || [];
1165
+ console.clear();
1166
+ console.log(chalk.bold('Fleet Status') + chalk.dim(` (${new Date().toLocaleTimeString()})`));
1167
+ console.log();
1168
+ formatOutput({
1169
+ columns: ['host_id', 'ip', 'status', 'cpu_percent', 'memory_percent', 'uptime'],
1170
+ headers: ['Host', 'IP', 'Status', 'CPU%', 'Mem%', 'Uptime'],
1171
+ rows: agents,
1172
+ }, { format: 'table' });
1173
+ } catch (err) {
1174
+ console.error(chalk.red(`Error: ${err.message}`));
1175
+ }
1176
+ };
1177
+
1178
+ await poll();
1179
+ const interval = setInterval(poll, 5000);
1180
+ process.on('SIGINT', () => {
1181
+ clearInterval(interval);
1182
+ console.log(chalk.dim('\nStopped watching.'));
1183
+ process.exit(0);
1184
+ });
1185
+
1186
+ // Keep alive
1187
+ await new Promise(() => {});
1188
+ });
1189
+ }