quoxcli 0.3.1 → 0.5.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 (306) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +2 -2
  3. package/bin/quox.js +19 -1
  4. package/bin/quoxon.js +6 -1
  5. package/lib/api/endpoints/actions.js +15 -0
  6. package/lib/api/endpoints/admin.js +27 -0
  7. package/lib/api/endpoints/agent-attach.js +7 -0
  8. package/lib/api/endpoints/blog.js +16 -0
  9. package/lib/api/endpoints/chat.js +7 -0
  10. package/lib/api/endpoints/compliance.js +25 -0
  11. package/lib/api/endpoints/contactsq.js +4 -0
  12. package/lib/api/endpoints/devServerRegistry.js +25 -0
  13. package/lib/api/endpoints/engagement.js +26 -0
  14. package/lib/api/endpoints/evidence.js +20 -0
  15. package/lib/api/endpoints/hat.js +14 -0
  16. package/lib/api/endpoints/inbox.js +6 -0
  17. package/lib/api/endpoints/inboxq.js +19 -0
  18. package/lib/api/endpoints/index.js +14 -0
  19. package/lib/api/endpoints/loops.js +26 -0
  20. package/lib/api/endpoints/mon.js +295 -0
  21. package/lib/api/endpoints/objectives.js +6 -0
  22. package/lib/api/endpoints/quoxseo.js +6 -0
  23. package/lib/api/endpoints/server.js +21 -0
  24. package/lib/api/endpoints/setup.js +6 -0
  25. package/lib/api/endpoints/tools.js +13 -0
  26. package/lib/api/endpoints/v1.js +47 -0
  27. package/lib/commands/actions.js +81 -11
  28. package/lib/commands/admin.js +35 -0
  29. package/lib/commands/agent.js +11 -4
  30. package/lib/commands/agentAttach.js +61 -7
  31. package/lib/commands/approvals.js +198 -3
  32. package/lib/commands/ask.js +4 -2
  33. package/lib/commands/auth.js +64 -11
  34. package/lib/commands/backup.js +11 -4
  35. package/lib/commands/blog.js +110 -0
  36. package/lib/commands/capabilities.js +3 -2
  37. package/lib/commands/chat.js +142 -28
  38. package/lib/commands/chatCompanion.js +27 -0
  39. package/lib/commands/chatState.js +45 -0
  40. package/lib/commands/claude.js +7 -33
  41. package/lib/commands/compliance.js +109 -0
  42. package/lib/commands/connect.js +69 -0
  43. package/lib/commands/contacts.js +85 -4
  44. package/lib/commands/contactsSendAt.js +21 -0
  45. package/lib/commands/dev.js +6 -8
  46. package/lib/commands/doctor.js +22 -3
  47. package/lib/commands/engagement.js +90 -0
  48. package/lib/commands/evidence.js +155 -0
  49. package/lib/commands/ext.js +23 -0
  50. package/lib/commands/fleet.js +25 -0
  51. package/lib/commands/gripe.js +24 -13
  52. package/lib/commands/hat.js +76 -0
  53. package/lib/commands/image.js +68 -0
  54. package/lib/commands/inbox.js +37 -3
  55. package/lib/commands/inboxq.js +147 -0
  56. package/lib/commands/kuma.js +1123 -0
  57. package/lib/commands/kumaSla.js +174 -0
  58. package/lib/commands/licence.js +39 -1
  59. package/lib/commands/licenceLinkView.js +311 -0
  60. package/lib/commands/linkedin.js +19 -0
  61. package/lib/commands/loop.js +318 -0
  62. package/lib/commands/lucid.js +1 -0
  63. package/lib/commands/mail.js +161 -20
  64. package/lib/commands/memory.js +68 -4
  65. package/lib/commands/memorySync.js +158 -0
  66. package/lib/commands/mon.js +55 -144
  67. package/lib/commands/notes.js +29 -7
  68. package/lib/commands/notesSeal.js +259 -0
  69. package/lib/commands/objective.js +53 -3
  70. package/lib/commands/offload.js +2 -3
  71. package/lib/commands/org.js +21 -4
  72. package/lib/commands/plan.js +17 -0
  73. package/lib/commands/provider.js +87 -8
  74. package/lib/commands/qlarity.js +32 -1
  75. package/lib/commands/quoxseo.js +31 -0
  76. package/lib/commands/research.js +79 -0
  77. package/lib/commands/rows.js +9 -0
  78. package/lib/commands/run.js +20 -2
  79. package/lib/commands/scope.js +2 -2
  80. package/lib/commands/server.js +150 -0
  81. package/lib/commands/setup.js +127 -75
  82. package/lib/commands/shortcut.js +41 -0
  83. package/lib/commands/skills.js +24 -5
  84. package/lib/commands/slot.js +49 -9
  85. package/lib/commands/status.js +92 -44
  86. package/lib/commands/tool.js +30 -0
  87. package/lib/commands/vault.js +6 -3
  88. package/lib/commands/vm.js +2 -2
  89. package/lib/commands/walkthrough.js +14 -2
  90. package/lib/commands/watch.js +11 -2
  91. package/lib/commands/wazuh.js +824 -0
  92. package/lib/commands/wazuhIntake.js +317 -0
  93. package/lib/commands/wazuhReport.js +192 -0
  94. package/lib/commands/workflow.js +47 -4
  95. package/lib/commands/x.js +50 -0
  96. package/lib/commands/youtube.js +172 -0
  97. package/lib/core/actionItems.js +115 -0
  98. package/lib/core/agentAlias.js +2 -0
  99. package/lib/core/agentFamilies.js +7 -0
  100. package/lib/core/announce.js +86 -0
  101. package/lib/core/auth.js +20 -2
  102. package/lib/core/callMe.js +118 -0
  103. package/lib/core/client.js +37 -3
  104. package/lib/core/commandRegistrars.js +17 -0
  105. package/lib/core/config.js +29 -3
  106. package/lib/core/confirm.js +68 -7
  107. package/lib/core/crossOrgApprovals.js +69 -0
  108. package/lib/core/errors.js +52 -1
  109. package/lib/core/findingReconcile.js +90 -0
  110. package/lib/core/global-flags.js +1 -1
  111. package/lib/core/governedSession.js +136 -8
  112. package/lib/core/inboxResolution.js +3 -2
  113. package/lib/core/liveSessionsReport.js +1 -1
  114. package/lib/core/loginGuard.js +73 -0
  115. package/lib/core/loopGlyph.js +97 -0
  116. package/lib/core/offload.js +73 -7
  117. package/lib/core/org-override.js +5 -1
  118. package/lib/core/output.js +8 -8
  119. package/lib/core/profileTrust.js +182 -0
  120. package/lib/core/quoxglyph.js +22 -1
  121. package/lib/core/router.js +14 -0
  122. package/lib/core/sessionHeartbeat.js +12 -8
  123. package/lib/core/shortcut.js +83 -0
  124. package/lib/core/stepUpPrompt.js +99 -0
  125. package/lib/core/version.js +1 -1
  126. package/lib/core/vm.js +5 -0
  127. package/lib/core/walkthrough.js +9 -5
  128. package/lib/dragon/companionView.js +34 -0
  129. package/lib/dragon/frames.js +118 -0
  130. package/lib/dragon/jobs.js +123 -0
  131. package/lib/dragon/renderWorker.js +15 -0
  132. package/lib/dragon/select.js +72 -0
  133. package/lib/evidence/bundleExport.js +47 -0
  134. package/lib/evidence/bundleVerify.js +77 -0
  135. package/lib/quoxon/EXTENSIONS.md +146 -0
  136. package/lib/quoxon/api.js +364 -27
  137. package/lib/quoxon/app.js +1629 -183
  138. package/lib/quoxon/approvalBatch.js +117 -0
  139. package/lib/quoxon/approvalFacts.js +64 -8
  140. package/lib/quoxon/approvalSentence.js +48 -0
  141. package/lib/quoxon/approvalsView.js +124 -7
  142. package/lib/quoxon/autoOpen.js +94 -0
  143. package/lib/quoxon/backgroundWork.js +59 -0
  144. package/lib/quoxon/bang.js +1 -1
  145. package/lib/quoxon/capabilities.js +7 -2
  146. package/lib/quoxon/commandShort.js +22 -3
  147. package/lib/quoxon/commands.js +110 -15
  148. package/lib/quoxon/connect.js +5 -3
  149. package/lib/quoxon/countdownLine.js +10 -1
  150. package/lib/quoxon/deck.js +30 -0
  151. package/lib/quoxon/deckLazy.js +58 -0
  152. package/lib/quoxon/demo.autoplay.js +1 -1
  153. package/lib/quoxon/demo.js +3 -1
  154. package/lib/quoxon/destructive.js +35 -0
  155. package/lib/quoxon/dividerAocl.js +4 -3
  156. package/lib/quoxon/dividerRule.js +3 -3
  157. package/lib/quoxon/dnd.js +71 -0
  158. package/lib/quoxon/dragonCompanion.js +285 -0
  159. package/lib/quoxon/dragonModes.js +1 -0
  160. package/lib/quoxon/dragonMoments.js +160 -0
  161. package/lib/quoxon/dragonReads.js +68 -0
  162. package/lib/quoxon/dragonView.js +2 -2
  163. package/lib/quoxon/echo.js +1 -0
  164. package/lib/quoxon/events.js +67 -10
  165. package/lib/quoxon/extensions.js +636 -0
  166. package/lib/quoxon/gripeQueue.js +135 -0
  167. package/lib/quoxon/inboxView.js +182 -0
  168. package/lib/quoxon/installCli.js +79 -0
  169. package/lib/quoxon/keys.js +4 -4
  170. package/lib/quoxon/loopCard.js +460 -0
  171. package/lib/quoxon/loopCards.js +189 -0
  172. package/lib/quoxon/loopStream.js +98 -0
  173. package/lib/quoxon/mailView.js +71 -0
  174. package/lib/quoxon/main.js +29 -5
  175. package/lib/quoxon/markdown.js +1 -1
  176. package/lib/quoxon/model.js +15 -0
  177. package/lib/quoxon/mouse.js +12 -1
  178. package/lib/quoxon/neverAnswered.js +20 -0
  179. package/lib/quoxon/objectiveOffer.js +82 -0
  180. package/lib/quoxon/objects/away.js +43 -5
  181. package/lib/quoxon/objects/index.js +22 -4
  182. package/lib/quoxon/objects/model.js +33 -0
  183. package/lib/quoxon/objects/proof.js +12 -4
  184. package/lib/quoxon/onboarding/connectServer.js +162 -0
  185. package/lib/quoxon/onboarding/copy.js +6 -0
  186. package/lib/quoxon/onboarding/index.js +5 -43
  187. package/lib/quoxon/onboarding/probe.js +20 -1
  188. package/lib/quoxon/onboarding/shortcutOffer.js +28 -0
  189. package/lib/quoxon/onboarding/view.js +16 -3
  190. package/lib/quoxon/orgSwitch.js +49 -1
  191. package/lib/quoxon/planSteps.js +51 -0
  192. package/lib/quoxon/print.js +8 -3
  193. package/lib/quoxon/profileSwitch.js +7 -0
  194. package/lib/quoxon/promptHistory.js +3 -1
  195. package/lib/quoxon/qdragonFly.js +7 -6
  196. package/lib/quoxon/qmotion.js +17 -0
  197. package/lib/quoxon/render.js +187 -41
  198. package/lib/quoxon/roster.js +36 -17
  199. package/lib/quoxon/rosterSessions.js +44 -0
  200. package/lib/quoxon/rows.js +244 -1
  201. package/lib/quoxon/screenContext.js +1 -1
  202. package/lib/quoxon/secretShape.js +86 -0
  203. package/lib/quoxon/serverChip.js +42 -0
  204. package/lib/quoxon/session.js +10 -0
  205. package/lib/quoxon/slash/agents.js +2 -1
  206. package/lib/quoxon/slash/argSources.js +9 -2
  207. package/lib/quoxon/slash/args.js +4 -2
  208. package/lib/quoxon/slash/broadcast.js +18 -5
  209. package/lib/quoxon/slash/broadcastRun.js +13 -2
  210. package/lib/quoxon/slash/cliCommand.js +42 -1
  211. package/lib/quoxon/slash/cliLeaves.json +139 -2
  212. package/lib/quoxon/slash/evidenceCmd.js +34 -0
  213. package/lib/quoxon/slash/hatWalk.js +1 -1
  214. package/lib/quoxon/slash/palette.js +23 -2
  215. package/lib/quoxon/slash/paletteController.js +10 -4
  216. package/lib/quoxon/slash/qpeCmd.js +35 -0
  217. package/lib/quoxon/slash/restart.js +31 -4
  218. package/lib/quoxon/slash/resume.js +18 -1
  219. package/lib/quoxon/slash/stepUp.js +5 -5
  220. package/lib/quoxon/slash/stepUpCmds.js +74 -1
  221. package/lib/quoxon/slash/todoCmd.js +122 -0
  222. package/lib/quoxon/slashSpinner.js +35 -0
  223. package/lib/quoxon/slotAttach.js +8 -0
  224. package/lib/quoxon/slotChat.js +17 -0
  225. package/lib/quoxon/slotFormat.js +1 -1
  226. package/lib/quoxon/slotOutbox.js +113 -0
  227. package/lib/quoxon/suggestCommand.js +34 -3
  228. package/lib/quoxon/term.js +25 -1
  229. package/lib/quoxon/toolCards.js +30 -0
  230. package/lib/quoxon/transcript.js +2 -1
  231. package/lib/quoxon/welcome.js +1 -1
  232. package/lib/quoxon/whatsnew.js +1 -1
  233. package/lib/quoxon/whatsnew.jsonl +81 -0
  234. package/lib/quoxon/windowOrg.js +11 -1
  235. package/lib/server/devServers.js +137 -0
  236. package/lib/server/installFlow.js +168 -0
  237. package/lib/server/status.js +15 -1
  238. package/lib/toolOutputShape.js +55 -0
  239. package/package.json +4 -2
  240. package/vendor/dragonlab/SOURCE +4 -0
  241. package/vendor/dragonlab/design/cipher/v1/cipher-master.svg +46 -0
  242. package/vendor/dragonlab/design/cipher/v2/character.mjs +49 -0
  243. package/vendor/dragonlab/design/cipher/v2/review.mjs +33 -0
  244. package/vendor/dragonlab/design/cipher/v2/watch.mjs +56 -0
  245. package/vendor/dragonlab/design/drago/v1/character.mjs +19 -0
  246. package/vendor/dragonlab/design/drago/v1/drago-master.svg +56 -0
  247. package/vendor/dragonlab/design/drago/v2/character.mjs +87 -0
  248. package/vendor/dragonlab/design/drago/v4/character.mjs +67 -0
  249. package/vendor/dragonlab/design/drago/v4/profile.mjs +35 -0
  250. package/vendor/dragonlab/design/drago/v4/review.mjs +33 -0
  251. package/vendor/dragonlab/design/drago/v4/watch.mjs +56 -0
  252. package/vendor/dragonlab/design/hyperion/v1/hyperion-master.svg +56 -0
  253. package/vendor/dragonlab/design/hyperion/v2/character.mjs +79 -0
  254. package/vendor/dragonlab/design/hyperion/v2/review.mjs +33 -0
  255. package/vendor/dragonlab/design/hyperion/v2/watch.mjs +56 -0
  256. package/vendor/dragonlab/design/lucid/recovery/arrival.mjs +22 -0
  257. package/vendor/dragonlab/design/lucid/recovery/character.mjs +106 -0
  258. package/vendor/dragonlab/design/lucid/recovery/layout.mjs +50 -0
  259. package/vendor/dragonlab/design/lucid/recovery/lucid-master.svg +78 -0
  260. package/vendor/dragonlab/design/lucid/recovery/motion.mjs +42 -0
  261. package/vendor/dragonlab/design/lucid/recovery/render.mjs +121 -0
  262. package/vendor/dragonlab/design/lucid/recovery/review.mjs +68 -0
  263. package/vendor/dragonlab/design/lucid/recovery/verify.mjs +84 -0
  264. package/vendor/dragonlab/design/lucid/recovery/watch.mjs +56 -0
  265. package/vendor/dragonlab/design/quoxperticus/v2/quoxperticus-master.svg +59 -0
  266. package/vendor/dragonlab/design/quoxperticus/v3/character.mjs +43 -0
  267. package/vendor/dragonlab/design/quoxperticus/v4/character.mjs +57 -0
  268. package/vendor/dragonlab/design/quoxperticus/v4/review.mjs +35 -0
  269. package/vendor/dragonlab/design/quoxperticus/v4/watch.mjs +56 -0
  270. package/vendor/dragonlab/design/seer/v1/seer-master.svg +53 -0
  271. package/vendor/dragonlab/design/seer/v2/character.mjs +88 -0
  272. package/vendor/dragonlab/design/seer/v3/character.mjs +33 -0
  273. package/vendor/dragonlab/design/seer/v4/character.mjs +66 -0
  274. package/vendor/dragonlab/design/seer/v4/review.mjs +33 -0
  275. package/vendor/dragonlab/design/seer/v4/watch.mjs +56 -0
  276. package/vendor/dragonlab/design/stage3/companion-review.mjs +28 -0
  277. package/vendor/dragonlab/design/stage3/sentinel-art.js +39 -0
  278. package/vendor/dragonlab/design/stage3/sentinel-braille.js +216 -0
  279. package/vendor/dragonlab/design/stage3/sentinel-character.js +257 -0
  280. package/vendor/dragonlab/design/stage3/sentinel-icon.js +103 -0
  281. package/vendor/dragonlab/design/stage3/sentinel-warden-eyes.js +95 -0
  282. package/vendor/dragonlab/design/stage3/svg-coverage.js +148 -0
  283. package/vendor/dragonlab/design/vulcan/v1/character.mjs +34 -0
  284. package/vendor/dragonlab/design/vulcan/v1/vulcan-master.svg +57 -0
  285. package/vendor/dragonlab/design/vulcan/v2/character.mjs +39 -0
  286. package/vendor/dragonlab/design/vulcan/v3/character.mjs +41 -0
  287. package/vendor/dragonlab/design/vulcan/v3/review.mjs +35 -0
  288. package/vendor/dragonlab/design/vulcan/v3/watch.mjs +56 -0
  289. package/vendor/dragonlab/factory/svg-rig-contour.mjs +64 -0
  290. package/vendor/dragonlab/factory/svg-rig.mjs +63 -0
  291. package/vendor/dragonlab/integration/companions-v1/characters.mjs +108 -0
  292. package/vendor/dragonlab/integration/companions-v1/cipher.mjs +2 -0
  293. package/vendor/dragonlab/integration/companions-v1/companion.mjs +63 -0
  294. package/vendor/dragonlab/integration/companions-v1/drago.mjs +2 -0
  295. package/vendor/dragonlab/integration/companions-v1/hyperion.mjs +2 -0
  296. package/vendor/dragonlab/integration/companions-v1/lucid.mjs +2 -0
  297. package/vendor/dragonlab/integration/companions-v1/quoxperticus.mjs +2 -0
  298. package/vendor/dragonlab/integration/companions-v1/seer.mjs +2 -0
  299. package/vendor/dragonlab/integration/companions-v1/sentinel.mjs +2 -0
  300. package/vendor/dragonlab/integration/companions-v1/view.mjs +28 -0
  301. package/vendor/dragonlab/integration/companions-v1/vulcan.mjs +2 -0
  302. package/vendor/dragonlab/integration/hyperion-v3/activity.mjs +76 -0
  303. package/vendor/dragonlab/lib/power-effects.js +294 -0
  304. package/vendor/dragonlab/lib/power-registry.js +74 -0
  305. package/vendor/dragonlab/vendor/quox-dragon/qart.js +348 -0
  306. package/vendor/dragonlab/vendor/quox-dragon/qeyes.js +213 -0
@@ -12,6 +12,7 @@
12
12
  import { resolveActing, resolveActingToken } from './actingAs.js';
13
13
  import { detectCallerSource } from './callerSource.js';
14
14
  import { QuoxApiError, QuoxNetworkError, QuoxAuthError, QuoxTransientAuthError } from './errors.js';
15
+ import { tlsFailureSentence } from './profileTrust.js';
15
16
  import { joinServiceUrl, isLocalServiceTarget } from './config.js';
16
17
 
17
18
  const RETRY_STATUSES = new Set([429, 502, 503]);
@@ -30,14 +31,21 @@ const TLS_TRUST_CODES = new Set([
30
31
  'ERR_TLS_CERT_ALTNAME_INVALID',
31
32
  ]);
32
33
 
34
+ function tlsConnectUrl(u) {
35
+ try { return new URL(u).origin; } catch { return '<url>'; }
36
+ }
37
+
33
38
  export function describeNetworkError(service, baseUrl, err) {
34
39
  const code = err?.cause?.code || err?.code;
35
40
  let msg = `Could not connect to ${service} service at ${baseUrl}: ${err.message}`;
36
41
  if (code && TLS_TRUST_CODES.has(code)) {
37
42
  msg += `\n TLS: certificate not trusted (${code}). A new QuoxCORE uses its own self-signed certificate; trust that file`
43
+ + `\n Trust this server for one profile with: quox connect ${tlsConnectUrl(baseUrl)} --profile <name>`
44
+ + `\n (it shows the certificate SHA256 and asks y/N; with no terminal add --fingerprint <sha256>). Or trust the file for the whole shell`
38
45
  + `\n (on the server, by default ~/quox-install/quox/ssl/quox.crt; copy it here if the server is another machine):`
39
46
  + `\n export NODE_EXTRA_CA_CERTS=<path to quox.crt>`
40
- + `\n Never disable TLS verification (NODE_TLS_REJECT_UNAUTHORIZED=0) instead.`;
47
+ + `\n Never disable TLS verification (NODE_TLS_REJECT_UNAUTHORIZED=0) instead.`
48
+ + `\n ${tlsFailureSentence(code, baseUrl)}`;
41
49
  } else if (code === 'ERR_SSL_WRONG_VERSION_NUMBER' || code === 'EPROTO') {
42
50
  // quox#532 defect 1: the profile says https:// but the service answered
43
51
  // PLAINTEXT (classic: url stamped https://localhost:3000 against plain
@@ -68,6 +76,20 @@ function generateCorrelationId() {
68
76
  return `cli-${t}-${r}`;
69
77
  }
70
78
 
79
+ /**
80
+ * PROFILE-NO-FALLBACK (2026-10-29): an explicitly chosen profile (-p / --profile / QUOX_PROFILE) with no
81
+ * session must never borrow this box's ambient service key: a memory create ran as the SERVICE, not objeng.
82
+ * The implicit fallback stays for the no-profile-chosen case. Throws before any request is sent.
83
+ */
84
+ function assertNoExplicitProfileFallback(config) {
85
+ const explicit = Boolean(config?._explicitProfile) || Boolean(String(process.env.QUOX_PROFILE || '').trim());
86
+ if (!explicit) return;
87
+ let name = null;
88
+ try { name = typeof config.getActiveProfileName === 'function' ? config.getActiveProfileName() : null; } catch { name = null; }
89
+ name = name || config._effectiveProfile || process.env.QUOX_PROFILE;
90
+ throw new QuoxAuthError(`Profile ${name} is signed out. Sign in: quox -p ${name} login`);
91
+ }
92
+
71
93
  /**
72
94
  * HITL-CONTEXT (owner 2026-10-07): name the calling session and profile so an
73
95
  * approval this call triggers says "VULCAN (CLI) · profile moo" instead of a
@@ -84,6 +106,8 @@ export function callerContextHeaders(config, env = process.env, detectDeps = {})
84
106
  try { session = detectCallerSource(env, detectDeps).label; } catch { session = null; }
85
107
  }
86
108
  if (session) headers['X-Quox-Session'] = clean(session, 48);
109
+ // The proof engine marks its turns: the server then files no approval for them (a read case must never page the owner).
110
+ if (env.QUOX_PROOF_TURN === '1') headers['X-Quox-Proof-Turn'] = '1';
87
111
  let profile = null;
88
112
  try { profile = typeof config?.getActiveProfileName === 'function' ? config.getActiveProfileName() : null; } catch { profile = null; }
89
113
  if (profile) {
@@ -115,6 +139,14 @@ export function createClient(config, auth) {
115
139
  // never tried again for the rest of its life). A cooldown keeps the
116
140
  // don't-hammer-auth property without the death sentence.
117
141
  const REFRESH_RETRY_COOLDOWN_MS = 60 * 1000;
142
+ // quox#1375: a profile with nothing to refresh with (the collector's quox_cli hop writes only the caller's access
143
+ // token) must surface the route's own 401, not "Session expired... No refresh token", which hid the real cause.
144
+ // A token-file principal re-mints its own file and a resident re-auths by signed challenge, so both still refresh.
145
+ const canRefresh = () => {
146
+ if (process.env.QUOX_TOKEN_FILE) return true;
147
+ const a = typeof config.getAuth === 'function' ? config.getAuth() : null;
148
+ return !!(a && (a.refresh_token || a.kind === 'resident'));
149
+ };
118
150
  let refreshFailedAt = 0;
119
151
  const correlationId = generateCorrelationId();
120
152
 
@@ -201,6 +233,7 @@ export function createClient(config, auth) {
201
233
  } else if (tokens?.access_token) {
202
234
  fetchHeaders['Authorization'] = `Bearer ${tokens.access_token}`;
203
235
  } else if (serviceKey && isLocalServiceTarget(url.href)) {
236
+ assertNoExplicitProfileFallback(config);
204
237
  // QUOXON-Z2H lane R: the implicit fallback is THIS box's key; never
205
238
  // offer it to another host (a remote profile with no login). A caller
206
239
  // that owns a remote key says so with QUOX_FORCE_SERVICE_KEY.
@@ -291,7 +324,7 @@ export function createClient(config, auth) {
291
324
  }
292
325
 
293
326
  const refreshCoolingDown = refreshFailedAt && (Date.now() - refreshFailedAt < REFRESH_RETRY_COOLDOWN_MS);
294
- if (res.status === 401 && !noAuth && !actingAs && !refreshedThisRequest && !refreshCoolingDown && looksExpired) {
327
+ if (res.status === 401 && !noAuth && !actingAs && !refreshedThisRequest && !refreshCoolingDown && looksExpired && canRefresh()) {
295
328
  refreshedThisRequest = true;
296
329
  try {
297
330
  if (!refreshing) {
@@ -530,6 +563,7 @@ export function createClient(config, auth) {
530
563
  } else if (streamTokens?.access_token) {
531
564
  fetchHeaders['Authorization'] = `Bearer ${streamTokens.access_token}`;
532
565
  } else if (process.env.QUOX_SERVICE_KEY && isLocalServiceTarget(url.href)) {
566
+ assertNoExplicitProfileFallback(config);
533
567
  streamServiceKey = process.env.QUOX_SERVICE_KEY;
534
568
  fetchHeaders['X-Service-Key'] = streamServiceKey;
535
569
  }
@@ -575,7 +609,7 @@ export function createClient(config, auth) {
575
609
  try { looksExpired = (await res.clone().json())?.error === 'invalid_token'; } catch { /* non-JSON body */ }
576
610
  }
577
611
  const refreshCoolingDown = refreshFailedAt && (Date.now() - refreshFailedAt < REFRESH_RETRY_COOLDOWN_MS);
578
- if (looksExpired && !refreshCoolingDown) {
612
+ if (looksExpired && !refreshCoolingDown && canRefresh()) {
579
613
  try {
580
614
  if (!refreshing) {
581
615
  refreshing = auth.refresh(config.getServiceUrl('auth')).finally(() => { refreshing = null; });
@@ -11,7 +11,9 @@ export const COMMAND_REGISTRARS = [
11
11
  ['../commands/status.js', 'registerStatusCommand'],
12
12
  ['../commands/doctor.js', 'registerDoctorCommand'],
13
13
  ['../commands/server.js', 'registerServerCommands'],
14
+ ['../commands/connect.js', 'registerConnectCommand'],
14
15
  ['../commands/update.js', 'registerUpdateCommand'],
16
+ ['../commands/shortcut.js', 'registerShortcutCommand'],
15
17
  ['../commands/open.js', 'registerOpenCommand'],
16
18
  ['../commands/peek.js', 'registerPeekCommand'],
17
19
  ['../commands/aside.js', 'registerAsideCommand'],
@@ -26,6 +28,7 @@ export const COMMAND_REGISTRARS = [
26
28
  ['../commands/tool.js', 'registerToolCommands'],
27
29
  ['../commands/workflow.js', 'registerWorkflowCommands'],
28
30
  ['../commands/objective.js', 'registerObjectiveCommands'],
31
+ ['../commands/loop.js', 'registerLoopCommands'],
29
32
  ['../commands/assistant.js', 'registerAssistantCommands'],
30
33
  ['../commands/fleet.js', 'registerFleetCommands'],
31
34
  ['../commands/chat.js', 'registerChatCommands'],
@@ -41,10 +44,17 @@ export const COMMAND_REGISTRARS = [
41
44
  ['../commands/mail.js', 'registerMailCommands'],
42
45
  ['../commands/calendar.js', 'registerCalendarCommands'],
43
46
  ['../commands/notes.js', 'registerNotesCommands'],
47
+ ['../commands/blog.js', 'registerBlogCommands'],
44
48
  ['../commands/contacts.js', 'registerContactsCommands'],
49
+ // `quox inboxq watch` was consolidated into `quox mail watch` (the canonical
50
+ // InboxQ namespace: accounts/zones/sync/watches/proposals). The command file
51
+ // and its test are kept but unregistered; full deletion is a follow-up that
52
+ // needs a test-removal approval. Do not re-register without re-consolidating.
53
+ // ['../commands/inboxq.js', 'registerInboxqCommands'],
45
54
  ['../commands/licence.js', 'registerLicenceCommands'],
46
55
  ['../commands/run.js', 'registerRunCommands'],
47
56
  ['../commands/file.js', 'registerFileCommands'],
57
+ ['../commands/evidence.js', 'registerEvidenceCommands'],
48
58
  ['../commands/admin.js', 'registerAdminCommands'],
49
59
  ['../commands/aee.js', 'registerAeeCommands'],
50
60
  ['../commands/integration.js', 'registerIntegrationCommands'],
@@ -61,6 +71,7 @@ export const COMMAND_REGISTRARS = [
61
71
  ['../commands/vault.js', 'registerVaultCommands'],
62
72
  ['../commands/volt.js', 'registerVoltCommands'],
63
73
  ['../commands/governance.js', 'registerGovernanceCommands'],
74
+ ['../commands/compliance.js', 'registerComplianceCommands'],
64
75
  ['../commands/skills.js', 'registerSkillsCommands'],
65
76
  ['../commands/claude.js', 'registerClaudeCommands'],
66
77
  ['../commands/monitoring.js', 'registerMonitoringCommands'],
@@ -80,10 +91,13 @@ export const COMMAND_REGISTRARS = [
80
91
  ['../commands/capabilities.js', 'registerCapabilitiesCommands'],
81
92
  ['../commands/provider.js', 'registerProviderCommands'],
82
93
  ['../commands/tips.js', 'registerTipsCommands'],
94
+ ['../commands/ext.js', 'registerExtCommands'],
83
95
  ['../commands/quoxchat.js', 'registerQuoxchatCommands'],
84
96
  ['../commands/budget.js', 'registerBudgetCommands'],
85
97
  ['../commands/trace.js', 'registerTraceCommands'],
86
98
  ['../commands/mon.js', 'registerMonCommands'],
99
+ ['../commands/kuma.js', 'registerKumaCommands'],
100
+ ['../commands/wazuh.js', 'registerWazuhCommands'],
87
101
  ['../commands/quoxbox.js', 'registerQuoxboxCommands'],
88
102
  ['../commands/plan.js', 'registerPlanCommands'],
89
103
  ['../commands/engagement.js', 'registerEngagementCommands'],
@@ -91,6 +105,7 @@ export const COMMAND_REGISTRARS = [
91
105
  ['../commands/discord.js', 'registerDiscordCommands'],
92
106
  ['../commands/qlarity.js', 'registerQlarityCommands'],
93
107
  ['../commands/fetchAllowlist.js', 'registerFetchAllowlistCommands'],
108
+ ['../commands/research.js', 'registerResearchCommands'],
94
109
  ['../commands/reach.js', 'registerReachCommands'],
95
110
  ['../commands/inventory.js', 'registerInventoryCommands'],
96
111
  ['../commands/policy.js', 'registerPolicyCommands'],
@@ -115,6 +130,8 @@ export const COMMAND_REGISTRARS = [
115
130
  ['../commands/facebook.js', 'registerFacebookCommands'],
116
131
  ['../commands/instagram.js', 'registerInstagramCommands'],
117
132
  ['../commands/tiktok.js', 'registerTikTokCommands'],
133
+ ['../commands/youtube.js', 'registerYouTubeCommands'],
134
+ ['../commands/image.js', 'registerImageCommands'],
118
135
  ['../commands/offload.js', 'registerOffloadCommands'],
119
136
  ];
120
137
 
@@ -9,8 +9,8 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync, renameSy
9
9
  import { join } from 'node:path';
10
10
  import { homedir, networkInterfaces, hostname as osHostname } from 'node:os';
11
11
 
12
- function getConfigDir() { return join(homedir(), '.config', 'quox'); }
13
- function getConfigFile() { return join(getConfigDir(), 'config.json'); }
12
+ export function getConfigDir() { return join(homedir(), '.config', 'quox'); }
13
+ export function getConfigFile() { return join(getConfigDir(), 'config.json'); }
14
14
 
15
15
  // quox#713: cross-process lock around read-merge-write of config.json.
16
16
  const LOCK_STALE_MS = 10000;
@@ -220,7 +220,7 @@ export function deriveServiceUrl(baseUrl, port, service, opts = {}) {
220
220
  // only correct source for the flag at ctx-build time.
221
221
  // Commands whose remaining argv belongs to another program (`quox claude -p
222
222
  // "prompt"` is claude's --print, not quox's --profile).
223
- const WRAPPED_PROGRAM_COMMANDS = new Set(['claude', 'codex', 'gemini', 'wrap']);
223
+ const WRAPPED_PROGRAM_COMMANDS = new Set(['claude', 'codex', 'wrap']);
224
224
 
225
225
  export function resolveProfileFlagFromArgv(argv) {
226
226
  for (let i = 0; i < argv.length; i++) {
@@ -346,6 +346,32 @@ export function loadConfig(profileOverride) {
346
346
  this.save();
347
347
  },
348
348
 
349
+ // ONBOARD-P1 (quox connect): a named profile that is not the active one, created or updated in place.
350
+ // Never moves the persisted default.
351
+ getProfile(name) { return this._data.profiles[name] || null; },
352
+ upsertProfile(name, patch = {}) {
353
+ let prof = this._data.profiles[name];
354
+ if (!prof) {
355
+ prof = this._data.profiles[name] = { url: patch.url || DEFAULT_CONFIG.profiles.local.url, auth: null };
356
+ this._removedProfiles?.delete(name);
357
+ }
358
+ Object.assign(prof, patch);
359
+ this.save();
360
+ return prof;
361
+ },
362
+ // Put a profile back exactly as it was before upsertProfile (snapshot from getProfile, null = did not exist).
363
+ restoreProfile(name, snapshot) {
364
+ if (snapshot == null) {
365
+ delete this._data.profiles[name];
366
+ this._removedProfiles.add(name);
367
+ } else {
368
+ const cur = this._data.profiles[name] || (this._data.profiles[name] = {});
369
+ for (const k of Object.keys(cur)) if (!(k in snapshot)) delete cur[k];
370
+ Object.assign(cur, JSON.parse(JSON.stringify(snapshot)));
371
+ }
372
+ this.save();
373
+ },
374
+
349
375
  removeProfile(name) {
350
376
  if (name === this._effectiveProfile) {
351
377
  throw new Error('Cannot remove the active profile. Switch first.');
@@ -17,6 +17,51 @@
17
17
  * @param {string} message - confirmation prompt text
18
18
  * @returns {Promise<boolean>}
19
19
  */
20
+ import { QuoxUserError } from './errors.js';
21
+
22
+ /**
23
+ * Ask y/N, but treat a closed stdin (EOF) as "no answer" (resolves false)
24
+ * instead of leaving the prompt pending, which Node reports as a leaked
25
+ * "Detected unsettled top-level await" warning.
26
+ */
27
+ async function askConfirm(message) {
28
+ const { default: inquirer } = await import('inquirer');
29
+ let onEnd;
30
+ const eof = new Promise((resolve) => {
31
+ onEnd = () => resolve(false);
32
+ process.stdin.once('end', onEnd);
33
+ process.stdin.once('close', onEnd);
34
+ });
35
+ try {
36
+ const ans = await Promise.race([
37
+ inquirer.prompt([{ type: 'confirm', name: 'confirm', message, default: false }]),
38
+ eof,
39
+ ]);
40
+ return ans === false ? false : ans.confirm;
41
+ } finally {
42
+ process.stdin.off('end', onEnd);
43
+ process.stdin.off('close', onEnd);
44
+ }
45
+ }
46
+
47
+ /**
48
+ * y/N for verbs that have their own non-interactive flag (e.g. `--i-accept`).
49
+ * With no terminal it does not open a prompt: it throws a one-line
50
+ * QuoxUserError naming the flag, exit code 2.
51
+ * @param {string} message
52
+ * @param {{ flag?: string, purpose?: string }} [opts]
53
+ * @returns {Promise<boolean>}
54
+ */
55
+ export async function confirmInteractive(message, { flag = '--yes', purpose = 'confirm' } = {}) {
56
+ if (!process.stdin.isTTY) {
57
+ throw new QuoxUserError(
58
+ `No terminal to answer y/N here. Read the output above, then re-run with ${flag} to ${purpose}.`,
59
+ { exitCode: 2, code: 'no_terminal' },
60
+ );
61
+ }
62
+ return askConfirm(message);
63
+ }
64
+
20
65
  export async function confirmDestructive(program, options, message) {
21
66
  if (options.force === true || program.opts().yes === true) {
22
67
  return true;
@@ -26,12 +71,28 @@ export async function confirmDestructive(program, options, message) {
26
71
  throw new Error(`${message} — this needs confirmation; re-run with --force (or -y) in a non-interactive shell`);
27
72
  }
28
73
 
74
+ return askConfirm(message);
75
+ }
76
+
77
+ /**
78
+ * Ask for a typed line (e.g. the name of the object about to be deleted). EOF settles as null.
79
+ * Callers check the terminal first; this never opens a prompt without one.
80
+ * @param {string} message
81
+ * @returns {Promise<string|null>}
82
+ */
83
+ export async function askTyped(message) {
29
84
  const { default: inquirer } = await import('inquirer');
30
- const ans = await inquirer.prompt([{
31
- type: 'confirm',
32
- name: 'confirm',
33
- message,
34
- default: false,
35
- }]);
36
- return ans.confirm;
85
+ let onEnd;
86
+ const eof = new Promise((resolve) => {
87
+ onEnd = () => resolve(null);
88
+ process.stdin.once('end', onEnd);
89
+ process.stdin.once('close', onEnd);
90
+ });
91
+ try {
92
+ const ans = await Promise.race([inquirer.prompt([{ type: 'input', name: 'typed', message }]), eof]);
93
+ return ans === null ? null : String(ans.typed ?? '');
94
+ } finally {
95
+ process.stdin.off('end', onEnd);
96
+ process.stdin.off('close', onEnd);
97
+ }
37
98
  }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * crossOrgApprovals: "N more waiting in <org>" for the orgs the user belongs to OTHER than the active one.
3
+ *
4
+ * Read-only and counts only. orgs.list is scoped to the caller, and each count is the user's own
5
+ * GET /api/orgs/:orgId/inbox/stats (server: active membership of THAT org, owner/admin see the whole org,
6
+ * members only their own items), read with the caller's OWN token: the server checks membership of the URL org
7
+ * in the database, not the token's org claim, so no per-org token is minted (a mint is an auth.org_switch audit
8
+ * event and ~2s each). No card content crosses orgs and nothing here can decide anything; deciding in another org
9
+ * is an explicit `--org <id>` on the verb. Counts (never tokens) are cached 60s in-process.
10
+ */
11
+ import { resolveEndpoint } from '../api/endpoints.js';
12
+
13
+ const listOf = (d) => (Array.isArray(d) ? d : (d?.organizations || d?.orgs || d?.data || []));
14
+
15
+ const CACHE_MS = 60 * 1000;
16
+ const cache = new Map(); // `${userScope}|${currentOrgId}` -> { at, res }
17
+ export const _clearCrossOrgCache = () => cache.clear();
18
+
19
+ /**
20
+ * @param {object} a
21
+ * @param {object} a.client the caller's client (lists memberships, reads each org's counts)
22
+ * @param {string|null} a.currentOrgId
23
+ * @param {()=>number} [a.now]
24
+ * @returns {Promise<{ waiting: {id:string,name:string,count:number}[], failed: number, checked: boolean }>}
25
+ * failed counts orgs that could not be read: an unreadable org is NOT "nothing waiting".
26
+ */
27
+ export async function otherOrgsWaiting({ client, currentOrgId, now = Date.now }) {
28
+ if (!client) return { waiting: [], failed: 0, checked: false };
29
+ const key = `${currentOrgId}`;
30
+ const hit = cache.get(key);
31
+ if (hit && now() - hit.at < CACHE_MS) return hit.res;
32
+ let orgs;
33
+ try { const ep = resolveEndpoint('orgs.list'); orgs = listOf(await client.get(ep.path, { service: ep.service })); }
34
+ catch { return { waiting: [], failed: 1, checked: true }; }
35
+ const others = orgs.filter((o) => o?.id && o.id !== currentOrgId);
36
+ const waiting = []; let failed = 0;
37
+ await Promise.all(others.map(async (o) => {
38
+ try {
39
+ const ep = resolveEndpoint('inbox.stats.list', { orgId: o.id });
40
+ const d = await client.get(ep.path, { service: ep.service, retries: 0 });
41
+ const count = Number(d?.awaiting_human ?? d?.pending_approvals ?? 0);
42
+ if (count > 0) waiting.push({ id: o.id, name: o.name || o.slug || o.id, count });
43
+ } catch { failed += 1; }
44
+ }));
45
+ waiting.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
46
+ const res = { waiting, failed, checked: true };
47
+ if (!failed) cache.set(key, { at: now(), res }); // a partial answer is never cached
48
+ return res;
49
+ }
50
+
51
+ /** One line per org (plain terminal): the exact command to run. */
52
+ export function crossOrgLines(res, verb = 'list') {
53
+ const out = (res?.waiting || []).map((w) => `${w.count} more waiting in ${w.name}: quox approvals ${verb} --org ${w.id}`);
54
+ if (res?.failed) out.push(`Could not check ${res.failed} other org${res.failed === 1 ? '' : 's'} for waiting approvals; that does not mean nothing is waiting there.`);
55
+ return out;
56
+ }
57
+
58
+ /** TUI wording: switching is the TUI's own move. */
59
+ export function crossOrgTuiLines(res) {
60
+ const out = (res?.waiting || []).map((w) => `${w.count} more waiting in ${w.name}: /org switch ${w.name}`);
61
+ if (res?.failed) out.push(`Could not check ${res.failed} other org${res.failed === 1 ? '' : 's'} for waiting approvals; that does not mean nothing is waiting there.`);
62
+ return out;
63
+ }
64
+
65
+ /** The client for a verb: the profile client for the session org, a minted same-user token client for another. */
66
+ export async function clientForTarget({ client, clientForOrg, sessionOrgId, orgId }) {
67
+ if (!orgId || orgId === sessionOrgId || typeof clientForOrg !== 'function') return client;
68
+ return clientForOrg(orgId);
69
+ }
@@ -214,6 +214,14 @@ export function explainError(err, ctx = null) {
214
214
  const raw = RAW_HEADLINE.test(String(err.message || '').split('\n')[0]) || err.reasonFromError === true;
215
215
  const reason = serverReason(err);
216
216
 
217
+ // SKILLRIGHTS #478: require-acceptance refusal is not a generic 409 conflict.
218
+ if (status === 409 && (code === 'acceptance_required' || body.error === 'acceptance_required')) {
219
+ const pack = typeof body.packId === 'string' && body.packId ? body.packId : 'quox-core-dev-pack';
220
+ return {
221
+ headline: sentence(reason || `This org's distribution policy is require-acceptance, and nobody has accepted the current terms for pack '${pack}'`),
222
+ next: `accept the terms as yourself: quox dev skills accept ${pack} (add --i-accept with no terminal), then re-run the sync.`,
223
+ };
224
+ }
217
225
  // #8 service-key fallback: the real problem is "no stored session", say it once.
218
226
  if (err.serviceKeyFallback && (status === 401 || status === 403)) {
219
227
  return {
@@ -268,6 +276,28 @@ export function explainError(err, ctx = null) {
268
276
  if (status === 404 && /^approval not found$/i.test(reason || '')) {
269
277
  return { headline: 'No approval with that id in this org; it may belong to another org or be past retention.', next: notFoundNext(ctx) || '`quox approvals --help` shows how to list what exists.' };
270
278
  }
279
+ // DS-010 / #1335: a step-up refusal is about WHO is at the keyboard, not about org or role. The old fall-through
280
+ // sent people to "switch org / ask an admin", which cannot help: only the approver's own PIN does.
281
+ if (status === 403 && /step_up_required/.test(all)) {
282
+ if (body.reason === 'non_human') {
283
+ return { headline: 'This approval needs a person, and this login is an agent or service identity.', next: 'approve it from a person\'s own login (the dashboard, or `quox` in their terminal).' };
284
+ }
285
+ return {
286
+ headline: 'This approval needs your step-up PIN.',
287
+ next: body.pin_set === false
288
+ ? 'set a PIN in the dashboard (Settings > Account > Approval PIN), then run this again in a terminal.'
289
+ : 'run this command in an interactive terminal; it will ask for the PIN with hidden input. The PIN cannot be passed as a flag or environment variable.',
290
+ };
291
+ }
292
+ // the auth service's requireInstanceAdmin refusal (code instance_admin_required): instance admin is instance-wide,
293
+ // so neither switching org nor asking an org admin can help
294
+ if (status === 403 && /instance_admin_required|instance administrator access required/i.test(all)) {
295
+ return { headline: sentence(reason || 'Instance administrator access required'), next: 'this needs an instance administrator account. Run it with a profile that is one (quox -p <profile> ...), or ask your instance admin.' };
296
+ }
297
+ // an instance-admin gate: switching org cannot help, only an instance admin can
298
+ if (status === 403 && /instance admin required/i.test(all)) {
299
+ return { headline: 'This needs an instance admin, and this login is not one.', next: 'ask an instance admin of this Quox server to run it, or to make your login an instance admin.' };
300
+ }
271
301
  // #5 a role refusal is not an org-context problem
272
302
  if (status === 403 && /insufficient permissions?|lacks?[ _]capability|requires? (an? |the )?(admin|owner) role|not an? (org )?(admin|owner)/i.test(all)) {
273
303
  return { headline: sentence(reason || 'Your role does not allow this'), next: 'your role in this org does not allow this; ask an org owner or admin to do it or to raise your role.' };
@@ -332,6 +362,7 @@ function fallbackExplain(err, ctx) {
332
362
  }
333
363
 
334
364
  // CLI-ERRORS: an instruction sentence (the step the user takes) vs the rest (what happened).
365
+ const TLS_HINT_CODES = ['DEPTH_ZERO_SELF_SIGNED_CERT', 'SELF_SIGNED_CERT_IN_CHAIN', 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY', 'ERR_TLS_CERT_ALTNAME_INVALID', 'CERT_HAS_EXPIRED'];
335
366
  const INSTRUCTION = /^(run|check|use|retry|try|pass|fix|set|pick|switch|ask|log ?in|re-?run|please)\b/i;
336
367
 
337
368
  function splitInstruction(text) {
@@ -351,6 +382,10 @@ function splitInstruction(text) {
351
382
  * CLI-ERRORS: errors the CLI raises itself (auth, network, validation, anything else) in the same
352
383
  * contract as API errors: one headline, at most one Next, the rest (`more`) only with --verbose.
353
384
  */
385
+ function connectUrl(u) {
386
+ try { return new URL(u).origin; } catch { return '<url>'; }
387
+ }
388
+
354
389
  export function explainLocal(err) {
355
390
  if (!err || err instanceof QuoxApiError || err instanceof QuoxUserError) return null;
356
391
  const message = String(err.message || '');
@@ -362,7 +397,7 @@ export function explainLocal(err) {
362
397
  const more = message.split('\n').slice(1).map((l) => l.trim()).filter(Boolean);
363
398
  if (m && code && /CERT|SELF_SIGNED|UNABLE_TO_VERIFY/.test(code)) {
364
399
  return { headline: `Could not reach the ${svc} service at ${url}: its certificate is not trusted (${code}).`,
365
- next: 'trust it with export NODE_EXTRA_CA_CERTS=<path to quox.crt> (on the server: ~/quox-install/quox/ssl/quox.crt); never disable TLS verification.', more };
400
+ next: `trust this server for one profile with quox connect ${connectUrl(url)} --profile <name> (it shows the certificate SHA256 and asks y/N; with no terminal add --fingerprint <sha256>); never disable TLS verification.`, more };
366
401
  }
367
402
  if (m && (code === 'ERR_SSL_WRONG_VERSION_NUMBER' || code === 'EPROTO')) {
368
403
  return { headline: `The ${svc} service at ${url} speaks plain http, but this profile's URL says https://.`,
@@ -389,6 +424,20 @@ export function explainLocal(err) {
389
424
  return sp;
390
425
  }
391
426
  if (err instanceof QuoxValidationError) return splitInstruction(message);
427
+ // H4: a bare fetch (login, MFA, refresh) that died on a certificate this machine does not trust. undici reports it
428
+ // as "fetch failed"; name the problem and the exact next command. Never auto-trust, never TLS-off.
429
+ const tlsCode = TLS_HINT_CODES.find((c) => c === (err.cause?.cause?.code || err.cause?.code || err.code));
430
+ if (tlsCode && err.quoxUrl) {
431
+ const profile = err.quoxProfile ? ` --profile ${err.quoxProfile}` : '';
432
+ const expired = tlsCode === 'CERT_HAS_EXPIRED' || tlsCode === 'ERR_TLS_CERT_ALTNAME_INVALID';
433
+ return { headline: expired
434
+ ? `The server at ${err.quoxUrl} presents a certificate that ${tlsCode === 'CERT_HAS_EXPIRED' ? 'has expired' : 'does not name the address you used'} (${tlsCode}).`
435
+ : `The server at ${err.quoxUrl} uses a certificate this machine does not trust yet.`,
436
+ next: expired
437
+ ? 'the server needs a certificate that is current and names this address; trusting it would not help. Never disable TLS verification.'
438
+ : `run quox connect ${err.quoxUrl}${profile} (it shows the SHA256 fingerprint and asks y/N); with no terminal use quox connect ${err.quoxUrl}${profile} --trust-cert, or --fingerprint <sha256> to pin it. Never disable TLS verification.`,
439
+ more: [`TLS error: ${tlsCode}`] };
440
+ }
392
441
  const first = message.split('\n')[0];
393
442
  return { headline: sentence(first || 'Something went wrong'), next: null, more: message.split('\n').slice(1), stack: true };
394
443
  }
@@ -404,6 +453,7 @@ function renderContract(err, ex, ctx) {
404
453
  }
405
454
  if (err.detail) console.error(chalk.dim(` Detail: ${renderDetail(err.detail)}`));
406
455
  if (isVerbose(ctx)) {
456
+ if (err.debug) console.error(chalk.dim(` Debug: ${renderDetail(err.debug)}`));
407
457
  if (err.status) console.error(chalk.dim(` Status: ${err.status} ${typeof err.code === 'object' && err.code ? renderDetail(err.code) : (err.code || '')}`));
408
458
  if (err instanceof QuoxApiError && err.message && err.message !== ex.headline) console.error(chalk.dim(` Server said: ${err.message}`));
409
459
  for (const line of ex.more || []) if (line) console.error(chalk.dim(` More: ${line}`));
@@ -420,6 +470,7 @@ export function handleError(err, outputFormat = 'table', ctx = null) {
420
470
  };
421
471
  if (err.status) obj.status = err.status;
422
472
  if (err.detail) obj.detail = err.detail;
473
+ if (err.debug) obj.debug = err.debug; // plumbing kept out of the headline, still there for scripts
423
474
  // LL-3d: scripts parsing --json get the server's own body verbatim, not
424
475
  // our prose rendering of it.
425
476
  if (err.body && typeof err.body === 'object') obj.body = err.body;
@@ -0,0 +1,90 @@
1
+ /**
2
+ * SITREP-FINDINGS: gather the evidence that a security finding was fixed, on the host where
3
+ * git and gh live, for `quox engagement reconcile`. The collector makes the decision
4
+ * (securityFindings.reconcileFinding); this module only finds facts and never guesses:
5
+ * - issue: the stored issue_ref, the gripe_url Quox filed, a --link, or quox#N / #N in the
6
+ * title (quox#N in the description). Its state comes from `gh issue view`; unreadable = left out.
7
+ * - fix: commits on origin/main whose message cites the finding id or the linked issue
8
+ * number, or a --link commit. landed = an ancestor of origin/main; deployed = an ancestor of
9
+ * the build tree's HEAD (the tree quox-build last built from). A commit not on main is no fix.
10
+ */
11
+
12
+ const GH_ISSUE_URL = /github\.com\/([\w.-]+)\/([\w.-]+)\/issues\/(\d+)/;
13
+
14
+ export function issueRefsFor(finding, link = {}, issueRepo = 'quoxai/quox') {
15
+ const out = [];
16
+ const add = (r) => { if (r && !out.includes(r)) out.push(r); };
17
+ if (finding.issue_ref) add(finding.issue_ref);
18
+ const m = GH_ISSUE_URL.exec(finding.gripe_url || '');
19
+ if (m) add(`${m[1]}/${m[2]}#${m[3]}`);
20
+ if (link.issue) add(normIssue(link.issue, issueRepo));
21
+ const t = /(?:quox)?#(\d{1,6})\b/.exec(String(finding.title || ''));
22
+ if (t) add(`${issueRepo}#${t[1]}`);
23
+ const d = /\bquox#(\d{1,6})\b/.exec(String(finding.description || ''));
24
+ if (d) add(`${issueRepo}#${d[1]}`);
25
+ return out;
26
+ }
27
+
28
+ export function normIssue(ref, issueRepo = 'quoxai/quox') {
29
+ const s = String(ref).trim();
30
+ const m = GH_ISSUE_URL.exec(s);
31
+ if (m) return `${m[1]}/${m[2]}#${m[3]}`;
32
+ if (/^[\w.-]+\/[\w.-]+#\d+$/.test(s)) return s;
33
+ const n = /^(?:quox)?#?(\d+)$/.exec(s);
34
+ return n ? `${issueRepo}#${n[1]}` : null;
35
+ }
36
+
37
+ function issueState(ref, run) {
38
+ const [repo, n] = ref.split('#');
39
+ const r = run('gh', ['issue', 'view', n, '-R', repo, '--json', 'state']);
40
+ if (!r.ok) return null;
41
+ try {
42
+ const st = String(JSON.parse(r.out).state || '').toLowerCase();
43
+ return st === 'open' || st === 'closed' ? st : null;
44
+ } catch { return null; }
45
+ }
46
+
47
+ function commitsCiting(term, regex, { repoDir, run }) {
48
+ const r = run('git', ['-C', repoDir, 'log', 'origin/main', '--format=%H', ...(regex ? ['-E'] : ['--fixed-strings']), `--grep=${term}`]);
49
+ if (!r.ok) return [];
50
+ return r.out.split('\n').map((x) => x.trim()).filter((x) => /^[0-9a-f]{40}$/i.test(x));
51
+ }
52
+
53
+ export function gatherEvidence(finding, link = {}, { repoDir, buildDir, issueRepo = 'quoxai/quox', run }) {
54
+ const ev = {};
55
+ const refs = issueRefsFor(finding, link, issueRepo);
56
+ if (refs.length) {
57
+ ev.issue_ref = refs[0];
58
+ const st = issueState(refs[0], run);
59
+ if (st) ev.issue_state = st;
60
+ }
61
+
62
+ const shas = [];
63
+ const add = (s) => { if (s && !shas.includes(s)) shas.push(s); };
64
+ if (link.commit) {
65
+ const full = run('git', ['-C', repoDir, 'rev-parse', '--verify', `${link.commit}^{commit}`]);
66
+ add(full.ok && /^[0-9a-f]{40}$/i.test(full.out.trim()) ? full.out.trim() : link.commit);
67
+ }
68
+ if (finding.fix_commit) add(finding.fix_commit);
69
+ for (const s of commitsCiting(finding.id, false, { repoDir, run })) add(s);
70
+ if (ev.issue_ref && ev.issue_ref.startsWith(`${issueRepo}#`)) {
71
+ const n = ev.issue_ref.split('#')[1];
72
+ for (const s of commitsCiting(`#${n}([^0-9]|$)`, true, { repoDir, run })) add(s);
73
+ }
74
+ if (!shas.length) return ev;
75
+
76
+ const head = run('git', ['-C', buildDir, 'rev-parse', 'HEAD']);
77
+ const buildHead = head.ok ? head.out.trim() : null;
78
+ const isAnc = (dir, sha, tip) => run('git', ['-C', dir, 'merge-base', '--is-ancestor', sha, tip]).ok;
79
+ const landed = shas.filter((s) => isAnc(repoDir, s, 'origin/main'));
80
+ const deployed = buildHead ? landed.filter((s) => isAnc(buildDir, s, buildHead)) : [];
81
+ if (deployed.length) { ev.fix_commit = deployed[0]; ev.fix_state = 'deployed'; } else if (landed.length) { ev.fix_commit = landed[0]; ev.fix_state = 'landed'; }
82
+ return ev;
83
+ }
84
+
85
+ export function evidenceChanged(finding, ev) {
86
+ for (const k of ['issue_ref', 'issue_state', 'fix_commit', 'fix_state']) {
87
+ if (ev[k] != null && ev[k] !== (finding[k] ?? null)) return true;
88
+ }
89
+ return false;
90
+ }
@@ -87,7 +87,7 @@ const HONOURS_GLOBAL_DRY_RUN = Symbol.for('quox.forwardsRawArgs');
87
87
  /**
88
88
  * Mark a leaf that honours a --dry-run WITHOUT declaring its own option,
89
89
  * because it forwards every raw argv token after its verb word to a wrapped
90
- * tool (claude, codex, gemini, wrap). Such a leaf honours the flag only when
90
+ * tool (claude, codex, wrap). Such a leaf honours the flag only when
91
91
  * it is typed AFTER the verb (it then reaches the wrapped tool); typed before
92
92
  * the verb it reaches nothing, so it is refused like any other.
93
93
  */