quoxcli 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (348) hide show
  1. package/LICENSE +35 -0
  2. package/README.md +142 -2
  3. package/bin/quox.js +292 -0
  4. package/bin/quoxon.js +8 -0
  5. package/lib/api/endpoints/admin.js +125 -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/agents.js +56 -0
  9. package/lib/api/endpoints/assistants.js +50 -0
  10. package/lib/api/endpoints/audit.js +14 -0
  11. package/lib/api/endpoints/auth.js +50 -0
  12. package/lib/api/endpoints/awareness.js +34 -0
  13. package/lib/api/endpoints/backup-schedules.js +20 -0
  14. package/lib/api/endpoints/backups.js +86 -0
  15. package/lib/api/endpoints/brain.js +140 -0
  16. package/lib/api/endpoints/budgets.js +62 -0
  17. package/lib/api/endpoints/calendarq.js +71 -0
  18. package/lib/api/endpoints/catalog.js +14 -0
  19. package/lib/api/endpoints/chat.js +54 -0
  20. package/lib/api/endpoints/consent.js +26 -0
  21. package/lib/api/endpoints/contactsq.js +66 -0
  22. package/lib/api/endpoints/conversations.js +32 -0
  23. package/lib/api/endpoints/custom-roles.js +92 -0
  24. package/lib/api/endpoints/divisions.js +56 -0
  25. package/lib/api/endpoints/domains.js +38 -0
  26. package/lib/api/endpoints/engagement.js +231 -0
  27. package/lib/api/endpoints/engine.js +11 -0
  28. package/lib/api/endpoints/entity.js +50 -0
  29. package/lib/api/endpoints/executions.js +32 -0
  30. package/lib/api/endpoints/file.js +20 -0
  31. package/lib/api/endpoints/files.js +26 -0
  32. package/lib/api/endpoints/github.js +21 -0
  33. package/lib/api/endpoints/governance.js +43 -0
  34. package/lib/api/endpoints/hat.js +36 -0
  35. package/lib/api/endpoints/inbox.js +32 -0
  36. package/lib/api/endpoints/inboxq.js +120 -0
  37. package/lib/api/endpoints/index.js +228 -0
  38. package/lib/api/endpoints/invites.js +19 -0
  39. package/lib/api/endpoints/llm-policy.js +43 -0
  40. package/lib/api/endpoints/lucid.js +89 -0
  41. package/lib/api/endpoints/members.js +32 -0
  42. package/lib/api/endpoints/memory.js +75 -0
  43. package/lib/api/endpoints/mfa.js +32 -0
  44. package/lib/api/endpoints/mon.js +178 -0
  45. package/lib/api/endpoints/notesq.js +43 -0
  46. package/lib/api/endpoints/objectives.js +62 -0
  47. package/lib/api/endpoints/orgs.js +38 -0
  48. package/lib/api/endpoints/plugins.js +134 -0
  49. package/lib/api/endpoints/powers.js +75 -0
  50. package/lib/api/endpoints/prompts.js +69 -0
  51. package/lib/api/endpoints/qlarity.js +122 -0
  52. package/lib/api/endpoints/quoxbox.js +55 -0
  53. package/lib/api/endpoints/quoxchat.js +54 -0
  54. package/lib/api/endpoints/quoxflow.js +32 -0
  55. package/lib/api/endpoints/quoxplan.js +114 -0
  56. package/lib/api/endpoints/quoxseo.js +108 -0
  57. package/lib/api/endpoints/reach.js +47 -0
  58. package/lib/api/endpoints/runs.js +38 -0
  59. package/lib/api/endpoints/safety.js +46 -0
  60. package/lib/api/endpoints/scim.js +46 -0
  61. package/lib/api/endpoints/scopes.js +14 -0
  62. package/lib/api/endpoints/security.js +69 -0
  63. package/lib/api/endpoints/setup.js +90 -0
  64. package/lib/api/endpoints/skills.js +180 -0
  65. package/lib/api/endpoints/sso.js +67 -0
  66. package/lib/api/endpoints/tags.js +32 -0
  67. package/lib/api/endpoints/teams.js +56 -0
  68. package/lib/api/endpoints/telephony.js +46 -0
  69. package/lib/api/endpoints/tg.js +56 -0
  70. package/lib/api/endpoints/tools.js +50 -0
  71. package/lib/api/endpoints/traces.js +157 -0
  72. package/lib/api/endpoints/triggers.js +70 -0
  73. package/lib/api/endpoints/user-library.js +41 -0
  74. package/lib/api/endpoints/users.js +20 -0
  75. package/lib/api/endpoints/v1.js +499 -0
  76. package/lib/api/endpoints/vault.js +192 -0
  77. package/lib/api/endpoints/voice.js +22 -0
  78. package/lib/api/endpoints/volt.js +84 -0
  79. package/lib/api/endpoints/ward.js +40 -0
  80. package/lib/api/endpoints/webhooks.js +46 -0
  81. package/lib/api/endpoints/workflows.js +162 -0
  82. package/lib/api/endpoints/x.js +24 -0
  83. package/lib/api/endpoints.js +15 -0
  84. package/lib/commands/admin.js +359 -0
  85. package/lib/commands/aee.js +99 -0
  86. package/lib/commands/agent.js +201 -0
  87. package/lib/commands/agentAttach.js +1440 -0
  88. package/lib/commands/approvals.js +47 -0
  89. package/lib/commands/assistant.js +114 -0
  90. package/lib/commands/auth.js +458 -0
  91. package/lib/commands/awareness.js +88 -0
  92. package/lib/commands/backup.js +373 -0
  93. package/lib/commands/brain.js +994 -0
  94. package/lib/commands/budget.js +241 -0
  95. package/lib/commands/build.js +156 -0
  96. package/lib/commands/calendar.js +216 -0
  97. package/lib/commands/capabilities.js +34 -0
  98. package/lib/commands/chat.js +240 -0
  99. package/lib/commands/claude.js +354 -0
  100. package/lib/commands/config-cmd.js +106 -0
  101. package/lib/commands/consent.js +84 -0
  102. package/lib/commands/contacts.js +967 -0
  103. package/lib/commands/contactsSelftest.js +132 -0
  104. package/lib/commands/conversation.js +74 -0
  105. package/lib/commands/department.js +75 -0
  106. package/lib/commands/dev.js +620 -0
  107. package/lib/commands/devResident.js +314 -0
  108. package/lib/commands/devSupervisor.js +1050 -0
  109. package/lib/commands/discord.js +96 -0
  110. package/lib/commands/doctor.js +181 -0
  111. package/lib/commands/domain.js +114 -0
  112. package/lib/commands/done.js +277 -0
  113. package/lib/commands/engagement.js +586 -0
  114. package/lib/commands/entity.js +119 -0
  115. package/lib/commands/facebook.js +212 -0
  116. package/lib/commands/file.js +128 -0
  117. package/lib/commands/fleet.js +882 -0
  118. package/lib/commands/governance.js +638 -0
  119. package/lib/commands/gripe.js +324 -0
  120. package/lib/commands/hat.js +205 -0
  121. package/lib/commands/helpers.js +79 -0
  122. package/lib/commands/inbox.js +122 -0
  123. package/lib/commands/instagram.js +264 -0
  124. package/lib/commands/integration.js +100 -0
  125. package/lib/commands/inventory.js +115 -0
  126. package/lib/commands/invites.js +45 -0
  127. package/lib/commands/licence.js +280 -0
  128. package/lib/commands/linkedin.js +178 -0
  129. package/lib/commands/llm-policy.js +117 -0
  130. package/lib/commands/llm.js +45 -0
  131. package/lib/commands/lucid.js +591 -0
  132. package/lib/commands/mail.js +531 -0
  133. package/lib/commands/memory.js +284 -0
  134. package/lib/commands/mon.js +514 -0
  135. package/lib/commands/monitoring.js +86 -0
  136. package/lib/commands/motion.js +22 -0
  137. package/lib/commands/notes.js +1246 -0
  138. package/lib/commands/notesBatch.js +160 -0
  139. package/lib/commands/notesBridges.js +56 -0
  140. package/lib/commands/notesLinks.js +60 -0
  141. package/lib/commands/notification.js +57 -0
  142. package/lib/commands/nucleus.js +92 -0
  143. package/lib/commands/objective.js +229 -0
  144. package/lib/commands/onboard.js +328 -0
  145. package/lib/commands/open.js +56 -0
  146. package/lib/commands/org.js +266 -0
  147. package/lib/commands/plan.js +906 -0
  148. package/lib/commands/plugin-doctor.js +1091 -0
  149. package/lib/commands/plugin.js +539 -0
  150. package/lib/commands/policy.js +78 -0
  151. package/lib/commands/powers.js +296 -0
  152. package/lib/commands/prompt.js +350 -0
  153. package/lib/commands/qlarity.js +690 -0
  154. package/lib/commands/quoxbox.js +270 -0
  155. package/lib/commands/quoxchat.js +266 -0
  156. package/lib/commands/quoxseo.js +340 -0
  157. package/lib/commands/reach.js +158 -0
  158. package/lib/commands/role.js +286 -0
  159. package/lib/commands/run.js +132 -0
  160. package/lib/commands/safety.js +107 -0
  161. package/lib/commands/scim.js +88 -0
  162. package/lib/commands/scope.js +58 -0
  163. package/lib/commands/security.js +157 -0
  164. package/lib/commands/service.js +89 -0
  165. package/lib/commands/settings.js +99 -0
  166. package/lib/commands/setup.js +543 -0
  167. package/lib/commands/skillManifest.js +113 -0
  168. package/lib/commands/skillRightsFormat.js +38 -0
  169. package/lib/commands/skills.js +644 -0
  170. package/lib/commands/slot.js +1198 -0
  171. package/lib/commands/sso.js +229 -0
  172. package/lib/commands/status.js +137 -0
  173. package/lib/commands/tag.js +91 -0
  174. package/lib/commands/team.js +140 -0
  175. package/lib/commands/tg.js +240 -0
  176. package/lib/commands/tiktok.js +218 -0
  177. package/lib/commands/tool.js +216 -0
  178. package/lib/commands/trace.js +525 -0
  179. package/lib/commands/trigger.js +128 -0
  180. package/lib/commands/vault.js +1424 -0
  181. package/lib/commands/voice.js +182 -0
  182. package/lib/commands/volt.js +528 -0
  183. package/lib/commands/watch.js +115 -0
  184. package/lib/commands/webhook.js +137 -0
  185. package/lib/commands/workflow.js +503 -0
  186. package/lib/commands/x.js +439 -0
  187. package/lib/core/actingAs.js +136 -0
  188. package/lib/core/aee.js +53 -0
  189. package/lib/core/agentAlias.js +163 -0
  190. package/lib/core/approvalContext.js +61 -0
  191. package/lib/core/auth.js +350 -0
  192. package/lib/core/banner.js +143 -0
  193. package/lib/core/brainPrime.js +90 -0
  194. package/lib/core/callerSource.js +129 -0
  195. package/lib/core/claudeLs.js +94 -0
  196. package/lib/core/client.js +584 -0
  197. package/lib/core/completions.js +163 -0
  198. package/lib/core/config.js +551 -0
  199. package/lib/core/confirm.js +37 -0
  200. package/lib/core/deeplinks.js +76 -0
  201. package/lib/core/doneEngineFetch.js +219 -0
  202. package/lib/core/errors.js +132 -0
  203. package/lib/core/global-flags.js +90 -0
  204. package/lib/core/governedSession.js +402 -0
  205. package/lib/core/lazyCommands.js +119 -0
  206. package/lib/core/liveSessionsReport.js +112 -0
  207. package/lib/core/org-override.js +52 -0
  208. package/lib/core/output.js +239 -0
  209. package/lib/core/prodEnv.js +7 -0
  210. package/lib/core/quoxglyph.js +158 -0
  211. package/lib/core/residentAlarm.js +282 -0
  212. package/lib/core/residentAuth.js +243 -0
  213. package/lib/core/residentPump.js +229 -0
  214. package/lib/core/residentSeed.js +104 -0
  215. package/lib/core/sessionHeartbeat.js +192 -0
  216. package/lib/core/sessionsOverview.js +160 -0
  217. package/lib/core/slotKeeper.js +282 -0
  218. package/lib/core/supervisorEngine.js +864 -0
  219. package/lib/core/systemdInstaller.js +256 -0
  220. package/lib/core/version.js +87 -0
  221. package/lib/core/websiteSession.js +148 -0
  222. package/lib/lucid/LucidIntro.jsx +105 -0
  223. package/lib/lucid/blind.js +71 -0
  224. package/lib/lucid/deck.js +32 -0
  225. package/lib/lucid/fmt.js +139 -0
  226. package/lib/lucid/sitrep.js +173 -0
  227. package/lib/plugins/loader.js +93 -0
  228. package/lib/quoxon/activity.js +104 -0
  229. package/lib/quoxon/api.js +473 -0
  230. package/lib/quoxon/app.js +1262 -0
  231. package/lib/quoxon/capabilities.js +75 -0
  232. package/lib/quoxon/commands.js +154 -0
  233. package/lib/quoxon/connect.js +467 -0
  234. package/lib/quoxon/connectCard.js +28 -0
  235. package/lib/quoxon/context.js +68 -0
  236. package/lib/quoxon/copy.js +33 -0
  237. package/lib/quoxon/demo.fixtures.js +24 -0
  238. package/lib/quoxon/demo.js +35 -0
  239. package/lib/quoxon/egg.js +36 -0
  240. package/lib/quoxon/events.js +458 -0
  241. package/lib/quoxon/gallery.js +223 -0
  242. package/lib/quoxon/indicator.js +193 -0
  243. package/lib/quoxon/intent.js +41 -0
  244. package/lib/quoxon/keys.js +121 -0
  245. package/lib/quoxon/main.js +244 -0
  246. package/lib/quoxon/markdown.js +317 -0
  247. package/lib/quoxon/model.js +205 -0
  248. package/lib/quoxon/motion/clock.js +69 -0
  249. package/lib/quoxon/motion/color.js +35 -0
  250. package/lib/quoxon/motion/frame.js +14 -0
  251. package/lib/quoxon/motion/reveal.js +54 -0
  252. package/lib/quoxon/motion/ring.js +79 -0
  253. package/lib/quoxon/objects/agent.js +61 -0
  254. package/lib/quoxon/objects/crystal.js +100 -0
  255. package/lib/quoxon/objects/fetch.js +170 -0
  256. package/lib/quoxon/objects/index.js +85 -0
  257. package/lib/quoxon/objects/ladder.js +71 -0
  258. package/lib/quoxon/objects/memory.js +44 -0
  259. package/lib/quoxon/objects/model.js +118 -0
  260. package/lib/quoxon/objects/proof.js +110 -0
  261. package/lib/quoxon/objects/skill.js +105 -0
  262. package/lib/quoxon/objects/view.js +76 -0
  263. package/lib/quoxon/objects/workflow.js +119 -0
  264. package/lib/quoxon/observers.js +42 -0
  265. package/lib/quoxon/onboarding/copy.js +235 -0
  266. package/lib/quoxon/onboarding/credential.js +95 -0
  267. package/lib/quoxon/onboarding/detect.js +22 -0
  268. package/lib/quoxon/onboarding/finish.js +119 -0
  269. package/lib/quoxon/onboarding/index.js +67 -0
  270. package/lib/quoxon/onboarding/installRunner.js +225 -0
  271. package/lib/quoxon/onboarding/intent.js +53 -0
  272. package/lib/quoxon/onboarding/journey.js +73 -0
  273. package/lib/quoxon/onboarding/preflight.js +142 -0
  274. package/lib/quoxon/onboarding/runner.js +58 -0
  275. package/lib/quoxon/onboarding/view.js +625 -0
  276. package/lib/quoxon/print.js +94 -0
  277. package/lib/quoxon/qart.js +379 -0
  278. package/lib/quoxon/qglyph.js +143 -0
  279. package/lib/quoxon/qhello.js +58 -0
  280. package/lib/quoxon/qmotion.js +111 -0
  281. package/lib/quoxon/render.js +648 -0
  282. package/lib/quoxon/roster.js +221 -0
  283. package/lib/quoxon/route.js +38 -0
  284. package/lib/quoxon/rules.js +130 -0
  285. package/lib/quoxon/session.js +68 -0
  286. package/lib/quoxon/skills.js +30 -0
  287. package/lib/quoxon/slash/compact.js +90 -0
  288. package/lib/quoxon/slash/ledger.js +83 -0
  289. package/lib/quoxon/slash/listView.js +28 -0
  290. package/lib/quoxon/slash/palette.js +104 -0
  291. package/lib/quoxon/slash/paletteController.js +59 -0
  292. package/lib/quoxon/slash/panels.js +101 -0
  293. package/lib/quoxon/slash/picker.js +38 -0
  294. package/lib/quoxon/slash/restart.js +144 -0
  295. package/lib/quoxon/slash/resume.js +59 -0
  296. package/lib/quoxon/slash/settings.js +50 -0
  297. package/lib/quoxon/sound/index.js +186 -0
  298. package/lib/quoxon/sound/mapping.js +119 -0
  299. package/lib/quoxon/sound/output.js +52 -0
  300. package/lib/quoxon/sound/synth.js +122 -0
  301. package/lib/quoxon/term.js +106 -0
  302. package/lib/quoxon/textBuffer.js +242 -0
  303. package/lib/quoxon/theme.js +253 -0
  304. package/lib/quoxon/transcript.js +150 -0
  305. package/lib/quoxon/web/qface.js +81 -0
  306. package/lib/quoxon/welcome.js +52 -0
  307. package/lib/tui/app.jsx +289 -0
  308. package/lib/tui/components/Breadcrumb.jsx +42 -0
  309. package/lib/tui/components/ChatInput.jsx +68 -0
  310. package/lib/tui/components/Confirm.jsx +32 -0
  311. package/lib/tui/components/DataTable.jsx +270 -0
  312. package/lib/tui/components/DetailPanel.jsx +241 -0
  313. package/lib/tui/components/HelpOverlay.jsx +67 -0
  314. package/lib/tui/components/Layout.jsx +129 -0
  315. package/lib/tui/components/Palette.jsx +141 -0
  316. package/lib/tui/components/SearchInput.jsx +125 -0
  317. package/lib/tui/components/Sparkline.jsx +55 -0
  318. package/lib/tui/components/StatusBar.jsx +133 -0
  319. package/lib/tui/components/Toast.jsx +52 -0
  320. package/lib/tui/hooks/useApi.js +44 -0
  321. package/lib/tui/hooks/useHealth.js +90 -0
  322. package/lib/tui/hooks/usePolledApi.js +86 -0
  323. package/lib/tui/hooks/useTerminalSize.js +46 -0
  324. package/lib/tui/index.js +56 -0
  325. package/lib/tui/intro/Intro.jsx +332 -0
  326. package/lib/tui/jsx-loader.js +32 -0
  327. package/lib/tui/keys.js +87 -0
  328. package/lib/tui/registry.js +49 -0
  329. package/lib/tui/theme.js +103 -0
  330. package/lib/tui/views/AdminView.jsx +398 -0
  331. package/lib/tui/views/AgentView.jsx +204 -0
  332. package/lib/tui/views/BackupView.jsx +225 -0
  333. package/lib/tui/views/BrainView.jsx +223 -0
  334. package/lib/tui/views/ChatView.jsx +468 -0
  335. package/lib/tui/views/FilesView.jsx +218 -0
  336. package/lib/tui/views/FleetView.jsx +234 -0
  337. package/lib/tui/views/GovernanceView.jsx +453 -0
  338. package/lib/tui/views/HealthView.jsx +216 -0
  339. package/lib/tui/views/InboxView.jsx +228 -0
  340. package/lib/tui/views/LucidDeck.jsx +727 -0
  341. package/lib/tui/views/MainMenu.jsx +123 -0
  342. package/lib/tui/views/MemoryView.jsx +377 -0
  343. package/lib/tui/views/ObjectivesView.jsx +230 -0
  344. package/lib/tui/views/OrgView.jsx +229 -0
  345. package/lib/tui/views/ToolView.jsx +221 -0
  346. package/lib/tui/views/VaultView.jsx +422 -0
  347. package/lib/tui/views/WorkflowView.jsx +329 -0
  348. package/package.json +72 -4
@@ -0,0 +1,882 @@
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 { formatOutput, formatDetail, printSuccess, printWarning, printInfo } from '../core/output.js';
8
+ import { events, printEvent, line as glyphLine } from '../core/quoxglyph.js';
9
+ import { parseJSON } from './helpers.js';
10
+ import { resolveEndpoint } from '../api/endpoints.js';
11
+
12
+ /**
13
+ * Map a raw /api/v1/gpu/nodes row to the flat shape `fleet gpu`'s table
14
+ * expects. Keeps the GPU-specific field munging (device count + first GPU
15
+ * name, MB -> GB, digest shortening, relative last-seen) local to this
16
+ * command instead of teaching the shared table renderer about GPU rows.
17
+ */
18
+ function formatGpuRow(node) {
19
+ const summary = node.gpu_summary || {};
20
+ const deviceCount = summary.device_count ?? 0;
21
+ const names = Array.isArray(summary.names) ? summary.names : [];
22
+ const gpus = deviceCount > 0
23
+ ? `${deviceCount}x ${names[0] || 'GPU'}`
24
+ : '—';
25
+
26
+ const vramMb = summary.vram_total_mb;
27
+ const vram = typeof vramMb === 'number' ? `${(vramMb / 1024).toFixed(1)} GB` : '—';
28
+
29
+ const backends = Array.isArray(node.backends) ? node.backends.join(', ') : (node.backends || '—');
30
+
31
+ const ward = node.ward_digest ? `${String(node.ward_digest).slice(0, 12)}…` : '—';
32
+
33
+ const lastSeen = relativeTime(node.last_seen_at);
34
+
35
+ return {
36
+ host_id: node.host_id,
37
+ gpus,
38
+ vram,
39
+ backends,
40
+ status: node.status,
41
+ ward,
42
+ last_seen: lastSeen,
43
+ };
44
+ }
45
+
46
+ /**
47
+ * Render a Bastion host's status.last_seen for `fleet hosts` (quox#460).
48
+ * Go's zero time.Time serialises as "0001-01-01T00:00:00Z" — a host that
49
+ * has never checked in gets that literal string, not null/undefined, so a
50
+ * plain falsy check would miss it and print a confusing century-old date.
51
+ */
52
+ function formatBastionLastSeen(iso) {
53
+ if (!iso) return 'never';
54
+ const str = String(iso);
55
+ if (str.startsWith('0001-01-01')) return 'never';
56
+ return str;
57
+ }
58
+
59
+ /** Format an ISO timestamp as a short relative-time string ("5m ago"). */
60
+ function relativeTime(iso) {
61
+ if (!iso) return '—';
62
+ const then = new Date(iso).getTime();
63
+ if (Number.isNaN(then)) return '—';
64
+ const diffSec = Math.max(0, Math.floor((Date.now() - then) / 1000));
65
+ if (diffSec < 60) return `${diffSec}s ago`;
66
+ const diffMin = Math.floor(diffSec / 60);
67
+ if (diffMin < 60) return `${diffMin}m ago`;
68
+ const diffHr = Math.floor(diffMin / 60);
69
+ if (diffHr < 24) return `${diffHr}h ago`;
70
+ const diffDay = Math.floor(diffHr / 24);
71
+ return `${diffDay}d ago`;
72
+ }
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // fleet #425 part B — codex-provision / rehome-agent
76
+ //
77
+ // Both verbs ride the SAME governed exec path as `quox fleet exec` (POST
78
+ // /api/v1/tools/execute with tool_name=ssh_exec), never raw ssh, so policy
79
+ // evaluation and HITL approval on the risky/sudo steps apply naturally.
80
+ //
81
+ // THE 60s PROBLEM: QuoxBastion's /api/v1/exec has a hardcoded 60s cap
82
+ // (exec.go, known + unfixed) and the collector's own callBastion aborts at
83
+ // 30s. `npm install -g` and a QuoxAgent install (~25MB binary + setup) both
84
+ // blow through that, and a transport timeout SIGHUPs the child mid-run. So
85
+ // every long step is LAUNCHED detached with `systemd-run --collect` and then
86
+ // POLLED with short execs against a sentinel file. This is the lesson
87
+ // recorded in the quoxpert-deploy skill ("run long installs detached ...
88
+ // a transport timeout SIGHUPs the child mid-build").
89
+ // ---------------------------------------------------------------------------
90
+
91
+ /** Default Codex model written to ~/.codex/config.toml. */
92
+ export const DEFAULT_CODEX_MODEL = 'gpt-5.4';
93
+
94
+ /** Tools whose input schema has a `host` field (collector reads input.host, not body.host_id). */
95
+ const TOOLS_READING_INPUT_HOST = new Set(['ssh_exec']);
96
+
97
+ /** Locates the vendored (bwrap-capable) codex binary on the host. */
98
+ export const CODEX_VENDOR_FIND_COMMAND =
99
+ "find $(dirname $(readlink -f $(which codex)))/../.. -name codex -path '*vendor*'";
100
+
101
+ /** The one step a human must run themselves. Never scripted — see below. */
102
+ export const CODEX_LOGIN_COMMAND = 'codex login --device-auth';
103
+
104
+ /** GET <collector>/install must answer with a shell script, not a proxy page. */
105
+ export const INSTALL_SCRIPT_MAGIC = '#!/bin/bash';
106
+
107
+ /**
108
+ * The REAL flag names from quoxagent/deploy/install.sh's Usage block. Kept as
109
+ * a named map (not inlined) so fleet-provision.test.js can drift-guard it
110
+ * against the script itself — guessing these silently installs an agent that
111
+ * enrols nowhere.
112
+ */
113
+ export const INSTALL_SH_FLAGS = {
114
+ hostId: '--host-id',
115
+ collector: '--collector',
116
+ org: '--org-id',
117
+ };
118
+
119
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
120
+
121
+ /** POSIX single-quote a string for safe embedding in a shell command. */
122
+ export function shellSingleQuote(value) {
123
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
124
+ }
125
+
126
+ /**
127
+ * Build the exact AppArmor profile body for the codex user-namespace rule.
128
+ * `vendorPath` is the path found by CODEX_VENDOR_FIND_COMMAND.
129
+ */
130
+ export function buildAppArmorProfile(vendorPath) {
131
+ const path = String(vendorPath || '').trim();
132
+ if (!path) {
133
+ throw new Error(
134
+ 'Cannot build the AppArmor profile: no vendor codex path was detected on the host. '
135
+ + `Run \`${CODEX_VENDOR_FIND_COMMAND}\` there and check codex is installed.`
136
+ );
137
+ }
138
+ return [
139
+ 'abi <abi/4.0>,',
140
+ 'include <tunables/global>',
141
+ `profile codex-userns ${path} flags=(unconfined) {`,
142
+ ' userns,',
143
+ '}',
144
+ '',
145
+ ].join('\n');
146
+ }
147
+
148
+ /**
149
+ * Idempotently set the top-level `model` key in a config.toml body without
150
+ * clobbering any other key. A missing key is inserted BEFORE the first
151
+ * [table] header — appending at the end would silently land the key inside
152
+ * whatever table happens to be last, which is a different setting entirely.
153
+ */
154
+ export function upsertModelLine(existing, model) {
155
+ const line = `model = "${model}"`;
156
+ const text = existing == null ? '' : String(existing);
157
+ if (!text.trim()) return `${line}\n`;
158
+
159
+ const lines = text.split('\n');
160
+ let firstTable = lines.findIndex((l) => /^[ \t]*\[/.test(l));
161
+ if (firstTable === -1) firstTable = lines.length;
162
+
163
+ const idx = lines.findIndex((l, i) => i < firstTable && /^[ \t]*model[ \t]*=/.test(l));
164
+ if (idx !== -1) lines[idx] = line;
165
+ else lines.splice(firstTable, 0, line);
166
+
167
+ const out = lines.join('\n');
168
+ return out.endsWith('\n') ? out : `${out}\n`;
169
+ }
170
+
171
+ /** Write a full config.toml body back to the host in one exec. */
172
+ export function buildConfigWriteCommand(content) {
173
+ const body = String(content).endsWith('\n') ? String(content) : `${content}\n`;
174
+ return `mkdir -p ~/.codex && cat > ~/.codex/config.toml <<'QUOX_CODEX_EOF'\n${body}QUOX_CODEX_EOF`;
175
+ }
176
+
177
+ /** Write the AppArmor profile and reload it (sudo — the governed path gates it). */
178
+ export function buildAppArmorInstallCommand(profileBody, target = '/etc/apparmor.d/codex-userns') {
179
+ const body = String(profileBody).endsWith('\n') ? String(profileBody) : `${profileBody}\n`;
180
+ return `sudo tee ${target} > /dev/null <<'QUOX_AA_EOF'\n${body}QUOX_AA_EOF\n`
181
+ + `sudo apparmor_parser -r ${target}`;
182
+ }
183
+
184
+ /** Sentinel + log paths for a detached unit. */
185
+ export function detachedPaths(unit) {
186
+ return { sentinel: `/tmp/${unit}.exit`, log: `/tmp/${unit}.log` };
187
+ }
188
+
189
+ /**
190
+ * Launch `command` as a transient systemd unit so it survives the exec
191
+ * transport closing, recording its exit code in a sentinel file.
192
+ */
193
+ export function buildDetachedRun({ unit, command, sentinel, log }) {
194
+ const inner = `{ ${command}; } > ${log} 2>&1; echo $? > ${sentinel}`;
195
+ return `rm -f ${sentinel} ${log}; sudo systemd-run --collect --unit ${unit} `
196
+ + `/bin/bash -lc ${shellSingleQuote(inner)}`;
197
+ }
198
+
199
+ /** Short exec used to poll a detached unit for completion. */
200
+ export function buildSentinelPollCommand(sentinel) {
201
+ return `if [ -f ${sentinel} ]; then echo "DONE=$(cat ${sentinel})"; else echo PENDING; fi`;
202
+ }
203
+
204
+ /** Parse the sentinel poll output. */
205
+ export function parseSentinel(output) {
206
+ const m = /DONE=(-?\d+)/.exec(String(output == null ? '' : output));
207
+ if (!m) return { done: false, exitCode: null };
208
+ return { done: true, exitCode: Number(m[1]) };
209
+ }
210
+
211
+ /** The full install pipe, using install.sh's REAL flags. */
212
+ export function buildRehomeInstallCommand({ collector, hostId, orgId }) {
213
+ const base = String(collector).replace(/\/+$/, '');
214
+ return `curl -sSL ${base}/install | sudo bash -s --`
215
+ + ` ${INSTALL_SH_FLAGS.hostId} ${shellSingleQuote(hostId)}`
216
+ + ` ${INSTALL_SH_FLAGS.collector} ${shellSingleQuote(base)}`
217
+ + ` ${INSTALL_SH_FLAGS.org} ${shellSingleQuote(orgId)}`;
218
+ }
219
+
220
+ /** Argument validation for `fleet codex-provision`. Returns a list of errors. */
221
+ export function validateCodexProvisionArgs(hostId, options = {}) {
222
+ const usage = 'Usage: quox fleet codex-provision <hostId> [--model <model>]';
223
+ const errors = [];
224
+ if (!hostId || !String(hostId).trim()) errors.push(`Missing <hostId>. ${usage}`);
225
+ if (options.model !== undefined && !String(options.model).trim()) {
226
+ errors.push(`--model needs a model name, e.g. --model ${DEFAULT_CODEX_MODEL}. ${usage}`);
227
+ }
228
+ return errors;
229
+ }
230
+
231
+ /** Argument validation for `fleet rehome-agent`. Returns a list of errors. */
232
+ export function validateRehomeArgs(hostId, options = {}) {
233
+ const usage = 'Usage: quox fleet rehome-agent <hostId> --collector <url> --org <orgId>';
234
+ const errors = [];
235
+ if (!hostId || !String(hostId).trim()) errors.push(`Missing <hostId>. ${usage}`);
236
+ const collector = options.collector == null ? '' : String(options.collector).trim();
237
+ const org = options.org == null ? '' : String(options.org).trim();
238
+ if (!collector) errors.push(`Missing --collector <url>. ${usage}`);
239
+ else if (!/^https?:\/\//i.test(collector)) {
240
+ errors.push(
241
+ '--collector must be an absolute http(s) URL, e.g. --collector http://192.0.2.126:9848'
242
+ );
243
+ }
244
+ if (!org) errors.push(`Missing --org <orgId>. ${usage}`);
245
+ return errors;
246
+ }
247
+
248
+ /**
249
+ * Normalise a governed-exec response into { ok, pending, text, error }.
250
+ * `pending: true` is the collector's real approval-gate shape (agentTools.js:
251
+ * { success:false, pending:true, approvalId, instructions }) — a gated step is
252
+ * NOT a failure, it is waiting on a human.
253
+ */
254
+ export function describeGovernedResult(data) {
255
+ const raw = (data && typeof data === 'object' && data.result !== undefined) ? data.result : data;
256
+ if (raw && typeof raw === 'object') {
257
+ if (raw.pending === true) {
258
+ return {
259
+ ok: false,
260
+ pending: true,
261
+ approvalId: raw.approvalId || null,
262
+ text: raw.instructions || raw.message || 'Awaiting human approval.',
263
+ error: null,
264
+ };
265
+ }
266
+ const text = [raw.stdout, raw.stderr].filter(Boolean).join('\n').trim()
267
+ || (typeof raw.output === 'string' ? raw.output.trim() : '');
268
+ const ok = raw.success === false
269
+ ? false
270
+ : (typeof raw.exit_code === 'number' ? raw.exit_code === 0 : true);
271
+ return { ok, pending: false, text, error: raw.error || null };
272
+ }
273
+ return { ok: true, pending: false, text: raw == null ? '' : String(raw), error: null };
274
+ }
275
+
276
+ /** Fail loudly with the pending-approval detail when a governed step is gated. */
277
+ function approvalError(step, described) {
278
+ return new Error(
279
+ `${step} is waiting on human approval${described.approvalId ? ` (approval ${described.approvalId})` : ''}. `
280
+ + `${described.text}\nApprove it, then re-run this command — every step is idempotent.`
281
+ );
282
+ }
283
+
284
+ export function registerFleetCommands(program, ctx) {
285
+ const { client, auth } = ctx;
286
+
287
+ const fleet = program
288
+ .command('fleet')
289
+ .description('Fleet monitoring and management');
290
+
291
+ fleet
292
+ .command('status')
293
+ .alias('health') // #620: 'quox fleet health' was a dead end; it's the verb people reach for.
294
+ .description('Show fleet status (connection counts plus per-host state)')
295
+ .action(async () => {
296
+ auth.requireAuth();
297
+ // fleet #458: /api/v1/fleet/status is a summary-shaped alias
298
+ // ({ status, total_agents, connected, stale, dead, timestamp }) — it
299
+ // has never returned an `agents`/`hosts` array. Reading data.agents ||
300
+ // data.hosts always fell through to [], so the table rendered zero
301
+ // rows and printed only a dim "No results." line: functionally
302
+ // silent. Render the real status fields plus the per-host table
303
+ // (same shape as `fleet agents`) so the verb actually says something.
304
+ const statusEp = resolveEndpoint('v1.fleet.status.list');
305
+ const status = await client.get(statusEp.path, { service: statusEp.service });
306
+ const agentsEp = resolveEndpoint('v1.agents.list');
307
+ const agentsData = await client.get(agentsEp.path, { service: agentsEp.service });
308
+ const hosts = (agentsData.agents || agentsData || []).map(a => ({
309
+ ...a,
310
+ status: typeof a.status === 'object' ? (a.status?.health ?? a.status?.state ?? '-') : a.status,
311
+ last_seen: typeof a.status?.last_seen_seconds === 'number'
312
+ ? `${a.status.last_seen_seconds}s ago`
313
+ : (a.last_seen ?? '-'),
314
+ }));
315
+
316
+ const format = program.opts().output;
317
+
318
+ if (format === 'json') {
319
+ console.log(JSON.stringify({ ...status, hosts }, null, 2));
320
+ return;
321
+ }
322
+
323
+ if (format === 'quiet') {
324
+ formatOutput({
325
+ columns: ['host_id', 'status', 'version', 'last_seen'],
326
+ rows: hosts,
327
+ }, { format, emptyMessage: 'No hosts reporting.' });
328
+ return;
329
+ }
330
+
331
+ formatDetail(status, {
332
+ format,
333
+ title: 'Fleet Status',
334
+ fields: ['status', 'total_agents', 'connected', 'stale', 'dead', 'timestamp'],
335
+ labels: {
336
+ status: 'Status', total_agents: 'Total Agents', connected: 'Connected',
337
+ stale: 'Stale', dead: 'Dead', timestamp: 'As Of',
338
+ },
339
+ });
340
+ console.log('');
341
+ formatOutput({
342
+ columns: ['host_id', 'status', 'version', 'last_seen'],
343
+ headers: ['Host', 'Status', 'Version', 'Last Seen'],
344
+ rows: hosts,
345
+ }, { format, emptyMessage: 'No hosts reporting.' });
346
+ });
347
+
348
+ fleet
349
+ .command('summary')
350
+ .description('Show fleet summary')
351
+ .action(async () => {
352
+ auth.requireAuth();
353
+ const data = await client.get(resolveEndpoint('v1.fleet.summary.list').path, { service: resolveEndpoint('v1.fleet.summary.list').service });
354
+ // fleet #387: the route returns a NESTED shape —
355
+ // { total_agents, connection: {connected, stale, dead},
356
+ // health: {healthy, warning, critical}, resources: {...} } —
357
+ // but this read five flat keys that don't exist at the top level, so
358
+ // every field rendered as a dash. Flattened here rather than renaming
359
+ // the labels, so the display keeps its meaning:
360
+ // Degraded <- health.warning, Down <- connection.dead.
361
+ // `warning` and `critical` are distinct health states, so `critical`
362
+ // is surfaced too instead of being silently folded into Down.
363
+ const flat = {
364
+ total: data.total_agents,
365
+ connected: data.connection?.connected,
366
+ healthy: data.health?.healthy,
367
+ degraded: data.health?.warning,
368
+ critical: data.health?.critical,
369
+ stale: data.connection?.stale,
370
+ down: data.connection?.dead,
371
+ };
372
+ formatDetail({ ...data, ...flat }, {
373
+ format: program.opts().output,
374
+ title: 'Fleet Summary',
375
+ fields: ['total', 'connected', 'healthy', 'degraded', 'critical', 'stale', 'down'],
376
+ labels: {
377
+ total: 'Total', connected: 'Connected', healthy: 'Healthy',
378
+ degraded: 'Degraded', critical: 'Critical', stale: 'Stale', down: 'Down',
379
+ },
380
+ });
381
+ });
382
+
383
+ fleet
384
+ .command('agents')
385
+ .description('List fleet agents')
386
+ .action(async () => {
387
+ auth.requireAuth();
388
+ const data = await client.get(resolveEndpoint('v1.agents.list').path, { service: resolveEndpoint('v1.agents.list').service });
389
+ formatOutput({
390
+ // fleet #392: status is a nested object and last_seen only exists
391
+ // inside it as last_seen_seconds; both rendered as dashes flat.
392
+ columns: ['host_id', 'status', 'version', 'last_seen'],
393
+ headers: ['Host', 'Status', 'Version', 'Last Seen'],
394
+ rows: (data.agents || data || []).map(a => ({
395
+ ...a,
396
+ status: typeof a.status === 'object' ? (a.status?.health ?? a.status?.state ?? '-') : a.status,
397
+ last_seen: typeof a.status?.last_seen_seconds === 'number'
398
+ ? `${a.status.last_seen_seconds}s ago`
399
+ : (a.last_seen ?? '-'),
400
+ })),
401
+ }, { format: program.opts().output });
402
+ });
403
+
404
+ fleet
405
+ .command('hosts')
406
+ .description('List hosts known to the Bastion registry (quox#460) — the full inventory, not just heartbeating agents')
407
+ .action(async () => {
408
+ auth.requireAuth();
409
+ const ep = resolveEndpoint('v1.fleet.hosts.list');
410
+ const data = await client.get(ep.path, { service: ep.service });
411
+ const raw = data.hosts || data || [];
412
+ const rows = (raw || []).map(h => ({
413
+ id: h.id,
414
+ ip: h.ip,
415
+ group: h.group,
416
+ state: h.status?.state ?? '-',
417
+ health: h.status?.health ?? '-',
418
+ last_seen: formatBastionLastSeen(h.status?.last_seen),
419
+ }));
420
+ formatOutput({
421
+ columns: ['id', 'ip', 'group', 'state', 'health', 'last_seen'],
422
+ headers: ['Host', 'IP', 'Group', 'State', 'Health', 'Last Seen'],
423
+ rows,
424
+ }, { format: program.opts().output, emptyMessage: 'No hosts in the Bastion registry.' });
425
+ });
426
+
427
+ fleet
428
+ .command('gpu')
429
+ .description('List the durable GPU fleet')
430
+ .action(async () => {
431
+ auth.requireAuth();
432
+ const data = await client.get(resolveEndpoint('v1.gpu.nodes.list').path, { service: resolveEndpoint('v1.gpu.nodes.list').service });
433
+ const nodes = data.nodes || data.rows || data;
434
+ const rows = (nodes || []).map(formatGpuRow);
435
+ formatOutput({
436
+ columns: ['host_id', 'gpus', 'vram', 'backends', 'status', 'ward', 'last_seen'],
437
+ headers: ['Host', 'GPUs', 'VRAM', 'Backends', 'Status', 'Ward', 'Last Seen'],
438
+ rows,
439
+ }, { format: program.opts().output, emptyMessage: 'No GPU nodes enrolled yet.' });
440
+ });
441
+
442
+ fleet
443
+ .command('tools')
444
+ .description('List available fleet tools')
445
+ .action(async () => {
446
+ auth.requireAuth();
447
+ const data = await client.get(resolveEndpoint('v1.tools.list').path, { service: resolveEndpoint('v1.tools.list').service });
448
+ formatOutput({
449
+ columns: ['name', 'description', 'category'],
450
+ headers: ['Name', 'Description', 'Category'],
451
+ rows: data.tools || data,
452
+ }, { format: program.opts().output });
453
+ });
454
+
455
+ fleet
456
+ .command('exec')
457
+ .description('Execute a fleet tool')
458
+ .requiredOption('--tool <name>', 'tool name')
459
+ .option('--input <json>', 'input parameters as JSON')
460
+ .option('--host <hostId>', 'target host')
461
+ .option('--org <id>', 'org ID (overrides active org; required for QUOX_SERVICE_KEY automation callers unless QUOX_ORG_ID is set)')
462
+ .option('--reason <text>', 'why you are running this; shown on the approval card if the call is held for a human')
463
+ .option('--session-label <name>', 'name this session on approval cards (default: $QUOX_SESSION)')
464
+ .action(async (options) => {
465
+ auth.requireAuth();
466
+ // Route reads req.body.tool_name (collector server.js /api/v1/tools/execute)
467
+ // — `tool` was silently 400ing every fleet exec. Same body-key bug
468
+ // family as the fixed run.js/chat.js keys.
469
+ const body = { tool_name: options.tool };
470
+ if (options.input) body.input = parseJSON(options.input, '--input');
471
+ if (options.host) body.host_id = options.host;
472
+ // quox#789: tools whose input schema reads input.host (ssh_exec) never
473
+ // see body.host_id, so the approval was stored with targets [null] and
474
+ // failed after the human approved it. Merge --host into input.host.
475
+ if (options.host && TOOLS_READING_INPUT_HOST.has(options.tool)) {
476
+ const inp = body.input && typeof body.input === 'object' && !Array.isArray(body.input) ? body.input : {};
477
+ if (inp.host && String(inp.host) !== String(options.host)) {
478
+ throw new Error(`--host ${options.host} conflicts with input.host ${inp.host}. Give one or make them match.`);
479
+ }
480
+ body.input = { ...inp, host: inp.host || options.host };
481
+ }
482
+ // HITL-CONTEXT: the reason + session label ride to the approval card.
483
+ if (options.reason) body.reason = String(options.reason);
484
+ { const w = missingReasonWarning(options.tool, options.reason); if (w) console.error(w); }
485
+ if (options.sessionLabel) body.session_label = String(options.sessionLabel);
486
+
487
+ // Collector's /api/v1/tools/execute requires org_id in the body for
488
+ // service-key authenticated callers (isServiceKeyAuth = !req.user,
489
+ // services/collector/server.js) — it has no session to derive org from,
490
+ // so it 400s with "org_id is required for service-key authenticated
491
+ // MCP calls" if the body omits it. A human-logged-in caller has
492
+ // req.user and the server derives org from the session instead, which
493
+ // is why this never surfaced for interactive use.
494
+ //
495
+ // Precedence for the org this CLI sends: explicit --org flag > the
496
+ // QUOX_ORG_ID env var (the automation-mode org, named to match
497
+ // QUOX_SERVICE_KEY) > the logged-in profile's stored org.
498
+ const orgId = auth.getOrgId(options.org || process.env.QUOX_ORG_ID);
499
+ if (!orgId && process.env.QUOX_SERVICE_KEY) {
500
+ throw new Error(
501
+ 'QUOX_SERVICE_KEY is set but no org could be resolved. '
502
+ + 'Set QUOX_ORG_ID=<org> for automation callers, or pass --org <id>.'
503
+ );
504
+ }
505
+ if (orgId) body.org_id = orgId;
506
+
507
+ const data = await client.post(resolveEndpoint('v1.tools.execute').path, { service: resolveEndpoint('v1.tools.execute').service, body, timeout: 120000 });
508
+ if (program.opts().output === 'json') {
509
+ console.log(JSON.stringify(data, null, 2));
510
+ } else {
511
+ if (data.result) console.log(data.result);
512
+ else console.log(JSON.stringify(data, null, 2));
513
+ }
514
+ // quox#791: a held call prints how to read the output once approved.
515
+ const heldId = data && data.pending && (data.approvalId || (data._approval && data._approval.id));
516
+ if (heldId) console.error(`after approval run: quox approvals result ${heldId}`);
517
+ });
518
+
519
+ fleet
520
+ .command('host-update <hostId>')
521
+ .description('Update a host in the Bastion registry (link/clear its bootstrap credential)')
522
+ .option('--bootstrap-credential-id <credId>', 'link a QuoxVault bootstrap (purpose:bootstrap) credential to this host')
523
+ .option('--clear-bootstrap-credential', 'clear the host\'s bootstrap credential link')
524
+ .action(async (hostId, options) => {
525
+ auth.requireAuth();
526
+
527
+ if (options.bootstrapCredentialId && options.clearBootstrapCredential) {
528
+ throw new Error('Pass either --bootstrap-credential-id or --clear-bootstrap-credential, not both.');
529
+ }
530
+ if (!options.bootstrapCredentialId && !options.clearBootstrapCredential) {
531
+ throw new Error('Nothing to update. Pass --bootstrap-credential-id <id> or --clear-bootstrap-credential.');
532
+ }
533
+
534
+ // The Bastion update handler merges: only the ssh fields present in the
535
+ // body are changed, so a partial {ssh:{bootstrap_credential_id}} leaves
536
+ // user/port/key_id intact. An empty string clears the link.
537
+ const bootstrapId = options.clearBootstrapCredential ? '' : options.bootstrapCredentialId;
538
+ const ep = resolveEndpoint('v1.fleet.host.update', { id: hostId });
539
+ const data = await client.put(ep.path, {
540
+ service: ep.service,
541
+ body: { ssh: { bootstrap_credential_id: bootstrapId } },
542
+ });
543
+
544
+ const linked = data?.ssh?.bootstrap_credential_id;
545
+ if (program.opts().output === 'json') {
546
+ console.log(JSON.stringify(data, null, 2));
547
+ } else {
548
+ printEvent(events.updated(
549
+ 'bastion',
550
+ 'host',
551
+ hostId,
552
+ options.clearBootstrapCredential ? 'bootstrap credential cleared' : `bootstrap credential linked: ${linked || bootstrapId}`
553
+ ));
554
+ }
555
+ });
556
+
557
+ // ── fleet #425 part B ────────────────────────────────────────────────────
558
+
559
+ /**
560
+ * One governed exec against a host. Same body construction, org precedence
561
+ * and error text as `fleet exec --tool ssh_exec` above — deliberately, so a
562
+ * gated step behaves identically whichever verb the operator reached for.
563
+ */
564
+ async function governedExec(hostId, command, { orgOverride, timeout = 120000 } = {}) {
565
+ const body = { tool_name: 'ssh_exec', input: { host: hostId, command } };
566
+ const orgId = auth.getOrgId(orgOverride || process.env.QUOX_ORG_ID);
567
+ if (!orgId && process.env.QUOX_SERVICE_KEY) {
568
+ throw new Error(
569
+ 'QUOX_SERVICE_KEY is set but no org could be resolved. '
570
+ + 'Set QUOX_ORG_ID=<org> for automation callers, or pass --exec-org <id>.'
571
+ );
572
+ }
573
+ if (orgId) body.org_id = orgId;
574
+ const ep = resolveEndpoint('v1.tools.execute');
575
+ return client.post(ep.path, { service: ep.service, body, timeout });
576
+ }
577
+
578
+ /**
579
+ * Launch a long step detached and poll its sentinel. Returns
580
+ * { state: 'done'|'failed'|'timeout', exitCode, text }.
581
+ */
582
+ async function runDetachedStep(hostId, { unit, command, orgOverride, attempts = 30, intervalMs = 10000 }) {
583
+ const { sentinel, log } = detachedPaths(unit);
584
+
585
+ const launch = describeGovernedResult(
586
+ await governedExec(hostId, buildDetachedRun({ unit, command, sentinel, log }), { orgOverride })
587
+ );
588
+ if (launch.pending) throw approvalError(`Launching ${unit}`, launch);
589
+ if (!launch.ok) {
590
+ return { state: 'failed', exitCode: null, text: launch.error || launch.text, log };
591
+ }
592
+
593
+ for (let i = 0; i < attempts; i++) {
594
+ await sleep(intervalMs);
595
+ const poll = describeGovernedResult(
596
+ await governedExec(hostId, buildSentinelPollCommand(sentinel), { orgOverride, timeout: 60000 })
597
+ );
598
+ if (poll.pending) throw approvalError(`Polling ${unit}`, poll);
599
+ const parsed = parseSentinel(poll.text);
600
+ if (!parsed.done) continue;
601
+ if (parsed.exitCode === 0) return { state: 'done', exitCode: 0, text: '', log };
602
+ const tail = describeGovernedResult(
603
+ await governedExec(hostId, `tail -40 ${log}`, { orgOverride, timeout: 60000 })
604
+ );
605
+ return { state: 'failed', exitCode: parsed.exitCode, text: tail.text, log };
606
+ }
607
+ return { state: 'timeout', exitCode: null, text: '', log };
608
+ }
609
+
610
+ fleet
611
+ .command('codex-provision <hostId>')
612
+ .description('Provision Codex on a fleet host (npm install, AppArmor userns profile, model config) up to the manual device-auth login')
613
+ .option('--model <model>', `model written to ~/.codex/config.toml (default: ${DEFAULT_CODEX_MODEL})`)
614
+ .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)')
615
+ .action(async (hostId, options) => {
616
+ auth.requireAuth();
617
+
618
+ const errors = validateCodexProvisionArgs(hostId, options);
619
+ if (errors.length) throw new Error(errors.join('\n'));
620
+
621
+ const model = (options.model || DEFAULT_CODEX_MODEL).trim();
622
+ const orgOverride = options.execOrg;
623
+ const isJson = program.opts().output === 'json';
624
+ const steps = [];
625
+ const say = (fn, msg) => { if (!isJson) fn(msg); };
626
+
627
+ // 1. npm install -g @openai/codex — detached, it can outlive the 60s cap.
628
+ say(printInfo, `[1/4] Installing @openai/codex on ${hostId} (detached, polled)...`);
629
+ const npm = await runDetachedStep(hostId, {
630
+ unit: 'codex-provision-npm',
631
+ command: 'npm install -g @openai/codex',
632
+ orgOverride,
633
+ });
634
+ steps.push({ step: 'npm_install', ...npm });
635
+ if (npm.state === 'timeout') {
636
+ throw new Error(
637
+ `npm install did not finish within the poll window on ${hostId}. `
638
+ + `It may still be running: check \`systemctl status codex-provision-npm\` and ${npm.log} on the host, then re-run.`
639
+ );
640
+ }
641
+ if (npm.state === 'failed') {
642
+ throw new Error(`npm install -g @openai/codex failed on ${hostId} (exit ${npm.exitCode}):\n${npm.text}`);
643
+ }
644
+ say(printSuccess, ' codex installed.');
645
+
646
+ // 2. Detect the vendored bwrap-capable binary (fast, single exec).
647
+ say(printInfo, '[2/4] Detecting the vendor codex path...');
648
+ const find = describeGovernedResult(
649
+ await governedExec(hostId, CODEX_VENDOR_FIND_COMMAND, { orgOverride })
650
+ );
651
+ if (find.pending) throw approvalError('Vendor path detection', find);
652
+ const vendorPath = String(find.text || '').split('\n').map((l) => l.trim()).filter(Boolean)[0];
653
+ if (!vendorPath) {
654
+ throw new Error(
655
+ `No vendored codex binary found on ${hostId}. Ran: ${CODEX_VENDOR_FIND_COMMAND}\n`
656
+ + `${find.error || find.text || '(no output)'}\n`
657
+ + 'Check codex is on PATH for the exec user and that this npm release still ships a vendor/ tree.'
658
+ );
659
+ }
660
+ steps.push({ step: 'vendor_path', vendor_path: vendorPath });
661
+ say(printSuccess, ` ${vendorPath}`);
662
+
663
+ // 3. AppArmor userns profile (sudo — the governed path gates it).
664
+ say(printInfo, '[3/4] Writing /etc/apparmor.d/codex-userns and reloading...');
665
+ const profile = buildAppArmorProfile(vendorPath);
666
+ const aa = describeGovernedResult(
667
+ await governedExec(hostId, buildAppArmorInstallCommand(profile), { orgOverride })
668
+ );
669
+ if (aa.pending) throw approvalError('The AppArmor profile write', aa);
670
+ if (!aa.ok) {
671
+ throw new Error(`Failed to install the AppArmor profile on ${hostId}: ${aa.error || aa.text}`);
672
+ }
673
+ steps.push({ step: 'apparmor', profile_path: '/etc/apparmor.d/codex-userns' });
674
+ say(printSuccess, ' profile loaded.');
675
+
676
+ // 4. ~/.codex/config.toml — read, upsert the model line locally, write
677
+ // back only if it changed. Other keys are preserved verbatim.
678
+ say(printInfo, `[4/4] Setting model = "${model}" in ~/.codex/config.toml...`);
679
+ const read = describeGovernedResult(
680
+ await governedExec(hostId, 'cat ~/.codex/config.toml 2>/dev/null || true', { orgOverride })
681
+ );
682
+ if (read.pending) throw approvalError('Reading ~/.codex/config.toml', read);
683
+ const current = read.ok ? (read.text || '') : '';
684
+ const next = upsertModelLine(current, model);
685
+ const currentNormalised = (current === '' || current.endsWith('\n')) ? current : `${current}\n`;
686
+ let configChanged = false;
687
+ if (next !== currentNormalised) {
688
+ const write = describeGovernedResult(
689
+ await governedExec(hostId, buildConfigWriteCommand(next), { orgOverride })
690
+ );
691
+ if (write.pending) throw approvalError('Writing ~/.codex/config.toml', write);
692
+ if (!write.ok) throw new Error(`Failed to write ~/.codex/config.toml on ${hostId}: ${write.error || write.text}`);
693
+ configChanged = true;
694
+ }
695
+ steps.push({ step: 'config_toml', model, changed: configChanged });
696
+ say(printSuccess, configChanged ? ` model set to ${model}.` : ` already model = "${model}", left alone.`);
697
+
698
+ if (isJson) {
699
+ console.log(JSON.stringify({
700
+ host_id: hostId,
701
+ model,
702
+ vendor_path: vendorPath,
703
+ steps,
704
+ manual_step: { command: CODEX_LOGIN_COMMAND, host: hostId, reason: 'device-auth is interactive and must not be scripted' },
705
+ }, null, 2));
706
+ return;
707
+ }
708
+
709
+ console.log();
710
+ console.log(chalk.bold('One manual step remains — it cannot be scripted.'));
711
+ console.log();
712
+ console.log(` On ${chalk.cyan(hostId)}, run: ${chalk.bold(CODEX_LOGIN_COMMAND)}`);
713
+ console.log();
714
+ console.log(chalk.dim(' It prints a URL and a one-time code. Open the URL, enter the code,'));
715
+ console.log(chalk.dim(' and finish the sign-in in a browser. The code expires after 15 minutes,'));
716
+ console.log(chalk.dim(' so start it when you are ready to complete it.'));
717
+ console.log(chalk.dim(' Quox never brokers that login: the device-auth code is a human credential.'));
718
+ console.log();
719
+ });
720
+
721
+ fleet
722
+ .command('rehome-agent <hostId>')
723
+ .description('Re-enrol a host\'s QuoxAgent against a different collector by clean reinstall from that collector')
724
+ .requiredOption('--collector <url>', 'the NEW collector base URL, e.g. http://192.0.2.126:9848')
725
+ // NOT requiredOption: --org shadows the root program's global --org
726
+ // <slug> (bin/quox.js). Commander's mandatory-option check runs BEFORE
727
+ // the mergeShadowedGlobals preAction hook, so a requiredOption here
728
+ // always fired "required option '--org' not specified" even when the
729
+ // user supplied it (quox#457). validateRehomeArgs below already reports
730
+ // a missing --org with the same usage string.
731
+ .option('--org <orgId>', 'org the agent should enrol into on the new collector')
732
+ .option('--exec-org <id>', 'org ID for the governed exec calls against the CURRENT collector (defaults to the active org)')
733
+ .option('--wait <seconds>', 'how long to wait for the host to appear on the new collector', '120')
734
+ .action(async (hostId, options) => {
735
+ auth.requireAuth();
736
+
737
+ const errors = validateRehomeArgs(hostId, options);
738
+ if (errors.length) throw new Error(errors.join('\n'));
739
+
740
+ const collector = String(options.collector).trim().replace(/\/+$/, '');
741
+ const orgId = String(options.org).trim();
742
+ const orgOverride = options.execOrg;
743
+ const isJson = program.opts().output === 'json';
744
+ const say = (fn, msg) => { if (!isJson) fn(msg); };
745
+
746
+ // 1. Preflight the NEW collector actually serves the installer. Same
747
+ // actionable-error shape as the collector's own GET /install 503.
748
+ const installUrl = `${collector}/install`;
749
+ say(printInfo, `[1/3] Preflight: GET ${installUrl}`);
750
+ let resp;
751
+ try {
752
+ resp = await fetch(installUrl, { signal: AbortSignal.timeout(10000) });
753
+ } catch (err) {
754
+ throw new Error(
755
+ `Preflight failed: could not reach ${installUrl} (${err.message}). `
756
+ + 'Check the new collector is running and reachable from this machine, then retry.'
757
+ );
758
+ }
759
+ if (!resp.ok) {
760
+ const body = (await resp.text().catch(() => '')).slice(0, 300);
761
+ throw new Error(
762
+ `Preflight failed: GET ${installUrl} returned HTTP ${resp.status}.\n${body}\n`
763
+ + 'The new collector must serve quoxagent/deploy/install.sh: set QuoxAgent_ROOT on that '
764
+ + 'collector and make sure the quoxagent checkout is mounted into its container.'
765
+ );
766
+ }
767
+ const script = await resp.text();
768
+ if (!script.startsWith(INSTALL_SCRIPT_MAGIC)) {
769
+ throw new Error(
770
+ `Preflight failed: GET ${installUrl} returned 200 but the body does not start with `
771
+ + `"${INSTALL_SCRIPT_MAGIC}" (got "${script.slice(0, 60).replace(/\n/g, '\\n')}").\n`
772
+ + 'Something other than the QuoxAgent installer is answering there (a proxy error page '
773
+ + 'or a login redirect). Point --collector at the collector itself, not a gateway.'
774
+ );
775
+ }
776
+ say(printSuccess, ` ${script.length} bytes of installer, starts with ${INSTALL_SCRIPT_MAGIC}.`);
777
+
778
+ // 2. Clean reinstall from the new collector — detached: it downloads a
779
+ // ~25MB binary plus setup, well over the exec cap.
780
+ say(printInfo, `[2/3] Reinstalling QuoxAgent on ${hostId} from ${collector} (detached, polled)...`);
781
+ const install = await runDetachedStep(hostId, {
782
+ unit: 'quoxagent-rehome',
783
+ command: buildRehomeInstallCommand({ collector, hostId, orgId }),
784
+ orgOverride,
785
+ });
786
+ if (install.state === 'timeout') {
787
+ throw new Error(
788
+ `The install did not finish within the poll window on ${hostId}. `
789
+ + `It may still be running: check \`systemctl status quoxagent-rehome\` and ${install.log} on the host, then re-run.`
790
+ );
791
+ }
792
+ if (install.state === 'failed') {
793
+ throw new Error(`QuoxAgent install failed on ${hostId} (exit ${install.exitCode}):\n${install.text}`);
794
+ }
795
+ say(printSuccess, ' install completed.');
796
+
797
+ // 3. Bounded wait for the host to heartbeat into the NEW collector.
798
+ // Same endpoint `fleet agents` reads, pointed at the new base URL.
799
+ const waitSeconds = Math.max(0, Number.parseInt(options.wait, 10) || 0);
800
+ const agentsPath = resolveEndpoint('v1.agents.list').path;
801
+ say(printInfo, `[3/3] Waiting up to ${waitSeconds}s for ${hostId} on ${collector}${agentsPath}...`);
802
+ const deadline = Date.now() + waitSeconds * 1000;
803
+ let enrolled = false;
804
+ let lastDetail = 'no successful listing yet';
805
+ do {
806
+ try {
807
+ const listResp = await fetch(`${collector}${agentsPath}`, { signal: AbortSignal.timeout(10000) });
808
+ if (!listResp.ok) {
809
+ // An error body is not data: a 401 is "cannot see", not "absent".
810
+ lastDetail = `HTTP ${listResp.status} from ${collector}${agentsPath}`;
811
+ } else {
812
+ const data = await listResp.json();
813
+ const agents = data.agents || data || [];
814
+ enrolled = (Array.isArray(agents) ? agents : []).some((a) => a.host_id === hostId);
815
+ lastDetail = enrolled ? 'heartbeating' : `listing returned ${Array.isArray(agents) ? agents.length : 0} agents, none matching ${hostId}`;
816
+ }
817
+ } catch (err) {
818
+ lastDetail = err.message;
819
+ }
820
+ if (enrolled || Date.now() >= deadline) break;
821
+ await sleep(5000);
822
+ } while (Date.now() < deadline);
823
+
824
+ if (isJson) {
825
+ console.log(JSON.stringify({
826
+ host_id: hostId,
827
+ collector,
828
+ org_id: orgId,
829
+ install: { state: install.state, exit_code: install.exitCode },
830
+ enrolled,
831
+ detail: lastDetail,
832
+ reminder: 'dev.register/dev.sessions capability profiles are signed per-collector; dev-enabled hosts must re-register with the new collector.',
833
+ }, null, 2));
834
+ return;
835
+ }
836
+
837
+ if (enrolled) printEvent(glyphLine('bastion', 'agent rehomed', { result: `${hostId} -> ${collector}`, status: 'ok' }));
838
+ else printWarning(`${hostId} has NOT been seen on ${collector} yet (${lastDetail}). The install succeeded; verify with \`quox fleet agents\` against the new collector.`);
839
+
840
+ console.log();
841
+ console.log(chalk.bold('Reminder: dev capability profiles are per-collector.'));
842
+ console.log(chalk.dim(' dev.register / dev.sessions profiles are SIGNED by the collector that issued them,'));
843
+ console.log(chalk.dim(` so a dev-enabled host must re-register with ${collector} before dev sessions work again.`));
844
+ console.log();
845
+ });
846
+
847
+ fleet
848
+ .command('watch')
849
+ .description('Watch fleet status in real-time')
850
+ .action(async () => {
851
+ auth.requireAuth();
852
+ console.log(chalk.dim('Watching fleet status... (Ctrl+C to stop)\n'));
853
+
854
+ const poll = async () => {
855
+ try {
856
+ const data = await client.get(resolveEndpoint('v1.fleet.status.list').path, { service: resolveEndpoint('v1.fleet.status.list').service });
857
+ const agents = data.agents || data.hosts || [];
858
+ console.clear();
859
+ console.log(chalk.bold('Fleet Status') + chalk.dim(` (${new Date().toLocaleTimeString()})`));
860
+ console.log();
861
+ formatOutput({
862
+ columns: ['host_id', 'ip', 'status', 'cpu_percent', 'memory_percent', 'uptime'],
863
+ headers: ['Host', 'IP', 'Status', 'CPU%', 'Mem%', 'Uptime'],
864
+ rows: agents,
865
+ }, { format: 'table' });
866
+ } catch (err) {
867
+ console.error(chalk.red(`Error: ${err.message}`));
868
+ }
869
+ };
870
+
871
+ await poll();
872
+ const interval = setInterval(poll, 5000);
873
+ process.on('SIGINT', () => {
874
+ clearInterval(interval);
875
+ console.log(chalk.dim('\nStopped watching.'));
876
+ process.exit(0);
877
+ });
878
+
879
+ // Keep alive
880
+ await new Promise(() => {});
881
+ });
882
+ }