quoxcli 0.4.0 → 0.6.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 (199) hide show
  1. package/CHANGELOG.md +110 -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/admin.js +7 -0
  6. package/lib/api/endpoints/agent-attach.js +7 -0
  7. package/lib/api/endpoints/compliance.js +25 -0
  8. package/lib/api/endpoints/governance.js +7 -0
  9. package/lib/api/endpoints/inbox.js +6 -0
  10. package/lib/api/endpoints/inboxq.js +58 -0
  11. package/lib/api/endpoints/index.js +4 -0
  12. package/lib/api/endpoints/lucid.js +17 -0
  13. package/lib/api/endpoints/mcp.js +35 -0
  14. package/lib/api/endpoints/mon.js +122 -0
  15. package/lib/api/endpoints/notesq.js +2 -0
  16. package/lib/api/endpoints/quoxplan.js +51 -0
  17. package/lib/api/endpoints/tools.js +13 -0
  18. package/lib/api/endpoints/v1.js +13 -0
  19. package/lib/commands/actions.js +51 -13
  20. package/lib/commands/admin.js +35 -0
  21. package/lib/commands/agentAttach.js +15 -0
  22. package/lib/commands/approvals.js +193 -3
  23. package/lib/commands/ask.js +1 -1
  24. package/lib/commands/auth.js +64 -11
  25. package/lib/commands/backup.js +11 -4
  26. package/lib/commands/capabilities.js +3 -2
  27. package/lib/commands/chat.js +15 -0
  28. package/lib/commands/claude.js +4 -1
  29. package/lib/commands/compliance.js +109 -0
  30. package/lib/commands/connect.js +69 -0
  31. package/lib/commands/contacts.js +2 -0
  32. package/lib/commands/dev.js +6 -8
  33. package/lib/commands/doctor.js +22 -3
  34. package/lib/commands/evidence.js +2 -1
  35. package/lib/commands/gripe.js +24 -13
  36. package/lib/commands/inbox.js +37 -3
  37. package/lib/commands/inboxq.js +147 -0
  38. package/lib/commands/kuma.js +78 -5
  39. package/lib/commands/licenceLinkView.js +311 -0
  40. package/lib/commands/linkedin.js +19 -0
  41. package/lib/commands/loop.js +30 -3
  42. package/lib/commands/lucid.js +7 -0
  43. package/lib/commands/lucidGoals.js +389 -0
  44. package/lib/commands/mail.js +427 -25
  45. package/lib/commands/mcp.js +190 -0
  46. package/lib/commands/memory.js +68 -4
  47. package/lib/commands/memorySync.js +158 -0
  48. package/lib/commands/mon.js +69 -144
  49. package/lib/commands/monitoring.js +68 -0
  50. package/lib/commands/notes.js +29 -7
  51. package/lib/commands/notesSeal.js +617 -0
  52. package/lib/commands/objective.js +5 -2
  53. package/lib/commands/offload.js +52 -12
  54. package/lib/commands/plan.js +487 -52
  55. package/lib/commands/provider.js +29 -8
  56. package/lib/commands/qlarity.js +32 -1
  57. package/lib/commands/run.js +2 -1
  58. package/lib/commands/server.js +37 -0
  59. package/lib/commands/setup.js +127 -75
  60. package/lib/commands/skills.js +4 -1
  61. package/lib/commands/slot.js +49 -9
  62. package/lib/commands/status.js +92 -44
  63. package/lib/commands/tool.js +30 -0
  64. package/lib/commands/vault.js +6 -3
  65. package/lib/commands/volt.js +6 -1
  66. package/lib/commands/walkthrough.js +14 -2
  67. package/lib/commands/watch.js +11 -2
  68. package/lib/commands/wazuh.js +880 -0
  69. package/lib/commands/wazuhIntake.js +358 -0
  70. package/lib/commands/wazuhReport.js +192 -0
  71. package/lib/commands/workflow.js +47 -4
  72. package/lib/commands/x.js +50 -0
  73. package/lib/core/announce.js +86 -0
  74. package/lib/core/auth.js +20 -2
  75. package/lib/core/callMe.js +118 -0
  76. package/lib/core/client.js +27 -1
  77. package/lib/core/commandRegistrars.js +9 -0
  78. package/lib/core/config.js +42 -3
  79. package/lib/core/confirm.js +68 -7
  80. package/lib/core/crossOrgApprovals.js +69 -0
  81. package/lib/core/errors.js +52 -1
  82. package/lib/core/governedSession.js +135 -5
  83. package/lib/core/inboxResolution.js +3 -2
  84. package/lib/core/loginGuard.js +73 -0
  85. package/lib/core/offload.js +120 -0
  86. package/lib/core/org-override.js +5 -1
  87. package/lib/core/output.js +30 -5
  88. package/lib/core/profileTrust.js +182 -0
  89. package/lib/core/quoxglyph.js +22 -1
  90. package/lib/core/router.js +14 -0
  91. package/lib/core/selfUpdate.js +2 -0
  92. package/lib/core/stepUpPrompt.js +99 -0
  93. package/lib/core/version.js +1 -1
  94. package/lib/core/walkthrough.js +9 -5
  95. package/lib/dragon/select.js +2 -2
  96. package/lib/quoxon/EXTENSIONS.md +142 -19
  97. package/lib/quoxon/api.js +189 -34
  98. package/lib/quoxon/app.js +1277 -188
  99. package/lib/quoxon/approvalBatch.js +117 -0
  100. package/lib/quoxon/approvalFacts.js +56 -2
  101. package/lib/quoxon/approvalSentence.js +8 -4
  102. package/lib/quoxon/approvalsView.js +72 -12
  103. package/lib/quoxon/ask.js +4 -2
  104. package/lib/quoxon/autoOpen.js +94 -0
  105. package/lib/quoxon/backgroundWork.js +59 -0
  106. package/lib/quoxon/bang.js +8 -2
  107. package/lib/quoxon/capabilities.js +7 -2
  108. package/lib/quoxon/commandShort.js +8 -1
  109. package/lib/quoxon/commands.js +42 -12
  110. package/lib/quoxon/connect.js +5 -3
  111. package/lib/quoxon/deckLazy.js +58 -0
  112. package/lib/quoxon/delegationDragon.js +49 -0
  113. package/lib/quoxon/destructive.js +35 -0
  114. package/lib/quoxon/dnd.js +118 -0
  115. package/lib/quoxon/dragonCompanion.js +37 -7
  116. package/lib/quoxon/dragonMoments.js +78 -0
  117. package/lib/quoxon/echo.js +1 -0
  118. package/lib/quoxon/events.js +55 -8
  119. package/lib/quoxon/extensions.js +400 -38
  120. package/lib/quoxon/foreignText.js +49 -0
  121. package/lib/quoxon/gripeQueue.js +135 -0
  122. package/lib/quoxon/inboxView.js +125 -22
  123. package/lib/quoxon/keys.js +2 -2
  124. package/lib/quoxon/launcher.js +2 -1
  125. package/lib/quoxon/loopCard.js +7 -2
  126. package/lib/quoxon/loopCards.js +17 -8
  127. package/lib/quoxon/loopStream.js +20 -9
  128. package/lib/quoxon/mailView.js +71 -0
  129. package/lib/quoxon/main.js +27 -6
  130. package/lib/quoxon/markdown.js +1 -1
  131. package/lib/quoxon/model.js +15 -0
  132. package/lib/quoxon/neverAnswered.js +20 -0
  133. package/lib/quoxon/objects/away.js +43 -5
  134. package/lib/quoxon/onboarding/connectServer.js +162 -0
  135. package/lib/quoxon/onboarding/copy.js +6 -0
  136. package/lib/quoxon/onboarding/dockerOffer.js +3 -1
  137. package/lib/quoxon/onboarding/index.js +5 -43
  138. package/lib/quoxon/onboarding/probe.js +20 -1
  139. package/lib/quoxon/onboarding/view.js +16 -3
  140. package/lib/quoxon/orgSwitch.js +22 -0
  141. package/lib/quoxon/print.js +2 -0
  142. package/lib/quoxon/profileSwitch.js +7 -0
  143. package/lib/quoxon/promptHistory.js +3 -1
  144. package/lib/quoxon/render.js +68 -16
  145. package/lib/quoxon/resend.js +14 -0
  146. package/lib/quoxon/roster.js +10 -5
  147. package/lib/quoxon/rosterSessions.js +44 -0
  148. package/lib/quoxon/rows.js +3 -1
  149. package/lib/quoxon/screenContext.js +1 -1
  150. package/lib/quoxon/secretShape.js +88 -0
  151. package/lib/quoxon/slash/agents.js +2 -1
  152. package/lib/quoxon/slash/argSources.js +9 -2
  153. package/lib/quoxon/slash/args.js +4 -2
  154. package/lib/quoxon/slash/broadcast.js +48 -11
  155. package/lib/quoxon/slash/broadcastRun.js +24 -7
  156. package/lib/quoxon/slash/cliLeaves.json +98 -1
  157. package/lib/quoxon/slash/dashboard.js +2 -0
  158. package/lib/quoxon/slash/hatWalk.js +1 -1
  159. package/lib/quoxon/slash/listView.js +2 -1
  160. package/lib/quoxon/slash/palette.js +63 -1
  161. package/lib/quoxon/slash/paletteController.js +15 -5
  162. package/lib/quoxon/slash/restart.js +9 -4
  163. package/lib/quoxon/slash/stepUp.js +5 -5
  164. package/lib/quoxon/slash/stepUpCmds.js +74 -1
  165. package/lib/quoxon/slashSpinner.js +35 -0
  166. package/lib/quoxon/slotAttach.js +8 -0
  167. package/lib/quoxon/slotChat.js +24 -3
  168. package/lib/quoxon/slotFormat.js +1 -1
  169. package/lib/quoxon/slotOutbox.js +113 -0
  170. package/lib/quoxon/slotRun.js +2 -0
  171. package/lib/quoxon/suggestCommand.js +1 -1
  172. package/lib/quoxon/term.js +25 -1
  173. package/lib/quoxon/transcript.js +2 -1
  174. package/lib/quoxon/webPty.js +15 -0
  175. package/lib/quoxon/welcome.js +1 -1
  176. package/lib/quoxon/whatsnew.js +1 -1
  177. package/lib/quoxon/whatsnew.jsonl +42 -0
  178. package/lib/render/planBrief.js +614 -0
  179. package/lib/server/installFlow.js +168 -0
  180. package/lib/server/status.js +15 -1
  181. package/lib/toolOutputShape.js +61 -3
  182. package/package.json +3 -2
  183. package/vendor/dragonlab/SOURCE +3 -3
  184. package/vendor/dragonlab/design/quoxperticus/v2/quoxperticus-master.svg +59 -0
  185. package/vendor/dragonlab/design/quoxperticus/v3/character.mjs +43 -0
  186. package/vendor/dragonlab/design/quoxperticus/v4/character.mjs +57 -0
  187. package/vendor/dragonlab/design/quoxperticus/v4/review.mjs +35 -0
  188. package/vendor/dragonlab/design/quoxperticus/v4/watch.mjs +56 -0
  189. package/vendor/dragonlab/design/vulcan/v1/character.mjs +34 -0
  190. package/vendor/dragonlab/design/vulcan/v1/vulcan-master.svg +57 -0
  191. package/vendor/dragonlab/design/vulcan/v2/character.mjs +39 -0
  192. package/vendor/dragonlab/design/vulcan/v3/character.mjs +41 -0
  193. package/vendor/dragonlab/design/vulcan/v3/review.mjs +35 -0
  194. package/vendor/dragonlab/design/vulcan/v3/watch.mjs +56 -0
  195. package/vendor/dragonlab/factory/svg-rig-contour.mjs +64 -0
  196. package/vendor/dragonlab/integration/companions-v1/characters.mjs +20 -0
  197. package/vendor/dragonlab/integration/companions-v1/quoxperticus.mjs +2 -0
  198. package/vendor/dragonlab/integration/companions-v1/view.mjs +1 -1
  199. package/vendor/dragonlab/integration/companions-v1/vulcan.mjs +2 -0
@@ -11,6 +11,23 @@ import { resolveOrgId, parseJSON } from './helpers.js';
11
11
  import { resolveEndpoint } from '../api/endpoints.js';
12
12
  import { confirmDestructive } from '../core/confirm.js';
13
13
 
14
+ // QUOXON (owner decision 2026-11-05, "opt-in yes"): a created workflow is not armed unless --activate was given.
15
+ // Say which, from the engine's own answer (active / trigger_state), never a guess. Silent in --json mode.
16
+ function printArmState(created, fallbackId) {
17
+ if (getOutputFormat() === 'json' || !created || typeof created !== 'object') return;
18
+ const id = created.id || fallbackId;
19
+ const ts = created.trigger_state && typeof created.trigger_state === 'object' ? created.trigger_state : null;
20
+ const active = ts && typeof ts.active === 'boolean' ? ts.active : created.active;
21
+ if (typeof active !== 'boolean') return;
22
+ const nodes = created.definition?.nodes || created.nodes || [];
23
+ const hasTrigger = Array.isArray(nodes) && nodes.some((n) => n && typeof n.type === 'string' && n.type.startsWith('trigger:'));
24
+ if (active) {
25
+ printInfo(ts?.next_run_at ? `Active: its trigger is armed, next run ${ts.next_run_at}.` : 'Active: its trigger is armed.');
26
+ } else if (hasTrigger) {
27
+ printInfo(`NOT active: its trigger is not armed, so it will not run on its own. Arm it with: quox trigger workflow-activate ${id}`);
28
+ }
29
+ }
30
+
14
31
  // Top-level Workflow fields compared for `workflow apply`'s diff-preview
15
32
  // (mirrors core/workflow.ts's WorkflowSchema field set, minus `id`).
16
33
  const APPLY_DIFF_FIELDS = ['name', 'description', 'notes', 'version', 'nodes', 'edges', 'variables', 'credentials', 'settings', 'metadata'];
@@ -242,10 +259,11 @@ export function registerWorkflowCommands(program, ctx) {
242
259
 
243
260
  workflow
244
261
  .command('create')
245
- .description('Create a new workflow in the workflow engine (quoxflow). --steps takes a JSON array of nodes (chained in order) or an object with nodes and optional edges')
262
+ .description('Create a new workflow in the workflow engine (quoxflow). --steps takes a JSON array of nodes (chained in order) or an object with nodes and optional edges. The workflow is created NOT active (its schedule or trigger is not armed) unless you pass --activate; arm it later with `quox trigger workflow-activate <id>`')
246
263
  .requiredOption('--name <name>', 'workflow name')
247
264
  .option('--description <desc>', 'description')
248
265
  .option('--steps <json>', 'nodes as a JSON array (chained sequentially), or {"nodes":[...],"edges":[...]}')
266
+ .option('--activate', 'arm the workflow now: register its schedule/webhook/event trigger (default: created inactive)')
249
267
  .action(async (options) => {
250
268
  auth.requireAuth();
251
269
  // #1190: the engine is the one workflow store. Same id shape as `workflow apply`.
@@ -264,12 +282,13 @@ export function registerWorkflowCommands(program, ctx) {
264
282
  throw new Error('--steps must be a JSON array of nodes, or an object with a "nodes" array (and optional "edges").');
265
283
  }
266
284
  }
267
- const body = { id, name: options.name, nodes, edges };
285
+ const body = { id, name: options.name, nodes, edges, activate: options.activate === true };
268
286
  if (options.description) body.description = options.description;
269
287
  const ep = resolveEndpoint('workflows.applyCreate');
270
288
  const res = await client.post(ep.path, { service: ep.service, body });
271
289
  const created = res?.data || res;
272
290
  printEvent(events.created('quox', 'workflow', `${options.name} (quoxflow) id:${created.id || id}`));
291
+ printArmState(created, id);
273
292
  // #1000: --json gets the saved workflow (the event line is suppressed in json mode, which left nothing)
274
293
  if (getOutputFormat() === 'json') formatDetail(created, { format: 'json' });
275
294
  });
@@ -288,7 +307,22 @@ export function registerWorkflowCommands(program, ctx) {
288
307
  if (options.name) body.name = options.name;
289
308
  if (options.description) body.description = options.description;
290
309
  if (options.status) body.status = options.status;
291
- const data = await client.patch(resolveEndpoint('workflows.update', { orgId, workflowId }).path, { service: resolveEndpoint('workflows.update', { orgId, workflowId }).service, body });
310
+ // Same order as `workflow get`/`delete`: `list` shows engine (quoxflow) workflows, so try the engine first and
311
+ // only fall back to the auth store on a 404. Any other engine error (403 etc.) is surfaced, never masked.
312
+ let data;
313
+ if (options.status === undefined) {
314
+ try {
315
+ const ep = resolveEndpoint('workflows.applyUpdate', { workflowId });
316
+ const res = await client.put(ep.path, { service: ep.service, body });
317
+ data = res?.data || res;
318
+ } catch (err) {
319
+ if (err.status !== 404) throw err;
320
+ }
321
+ }
322
+ if (data === undefined) {
323
+ const ep = resolveEndpoint('workflows.update', { orgId, workflowId });
324
+ data = await client.patch(ep.path, { service: ep.service, body });
325
+ }
292
326
  printEvent(events.updated('quox', 'workflow', workflowId));
293
327
  if (getOutputFormat() === 'json') formatDetail(data?.workflow || data, { format: 'json' });
294
328
  });
@@ -485,6 +519,7 @@ export function registerWorkflowCommands(program, ctx) {
485
519
  .description('Create or update a workflow declaratively from a YAML/JSON file (quoxflow-direct: POST if the file has no id, PUT with a diff-preview if it does)')
486
520
  .requiredOption('-f, --file <path>', 'path to a workflow definition file (.yaml/.yml/.json)')
487
521
  .option('--force', 'skip the diff-preview confirmation on update')
522
+ .option('--activate', 'when this creates the workflow, arm its trigger now (default: created inactive). An update never changes whether it is armed: use `quox trigger workflow-activate|workflow-deactivate`')
488
523
  .action(async (options) => {
489
524
  auth.requireAuth();
490
525
  const wf = readDeclarativeFile(options.file);
@@ -538,10 +573,13 @@ export function registerWorkflowCommands(program, ctx) {
538
573
  const createEp = resolveEndpoint('workflows.applyCreate');
539
574
  // #1000: quoxflow's WorkflowSchema requires an id, so "no id = create" 400'd. Mint one in the
540
575
  // engine's own wf-<ms>-<6> shape; the created id is printed, so the file can be given it to update later.
541
- const body = wf.id ? wf : { ...wf, id: `wf-${Date.now()}-${Math.random().toString(36).slice(2, 8).padEnd(6, '0')}` };
576
+ const minted = wf.id ? wf : { ...wf, id: `wf-${Date.now()}-${Math.random().toString(36).slice(2, 8).padEnd(6, '0')}` };
577
+ // Owner decision 2026-11-05: arming is opt-in; a file's own `activate` key is not an opt-in, only --activate is.
578
+ const body = { ...minted, activate: options.activate === true };
542
579
  const data = await client.post(createEp.path, { service: createEp.service, body });
543
580
  const created = data.data || data;
544
581
  printEvent(events.created('quox', 'workflow', `${created.name || wf.name || created.id} (quoxflow) id:${created.id}`));
582
+ printArmState(created, body.id);
545
583
  formatDetail(created, { format: program.opts().output });
546
584
  });
547
585
 
@@ -600,6 +638,7 @@ export function registerWorkflowCommands(program, ctx) {
600
638
  .requiredOption('-f, --file <path>', 'path to an n8n workflow export (.yaml/.yml/.json)')
601
639
  .option('--generate-ids', 'generate new node/workflow ids instead of reusing the n8n ones')
602
640
  .option('--include-positions', 'preserve n8n canvas node positions')
641
+ .option('--activate', 'arm the imported workflow\'s trigger now (default: imported inactive)')
603
642
  .action(async (options) => {
604
643
  auth.requireAuth();
605
644
  const parsed = readDeclarativeFile(options.file);
@@ -608,6 +647,9 @@ export function registerWorkflowCommands(program, ctx) {
608
647
  const importOptions = { ...(parsed?.options || {}) };
609
648
  if (options.generateIds) importOptions.generateIds = true;
610
649
  if (options.includePositions) importOptions.includePositions = true;
650
+ // Owner decision 2026-11-05: only --activate arms an import (a wrapped file's own options.activate does not).
651
+ delete importOptions.activate;
652
+ if (options.activate) importOptions.activate = true;
611
653
 
612
654
  const ep = resolveEndpoint('workflows.importN8n');
613
655
  const data = await client.post(ep.path, {
@@ -616,6 +658,7 @@ export function registerWorkflowCommands(program, ctx) {
616
658
  });
617
659
  const result = data.data || data;
618
660
  printEvent(glyphLine('quox', 'n8n workflow imported', { result: result.workflow?.id || result.id, status: 'ok' }));
661
+ printArmState(result.workflow, result.workflow?.id || result.id);
619
662
  if (result.warnings?.length) {
620
663
  for (const w of result.warnings) printWarning(w);
621
664
  }
package/lib/commands/x.js CHANGED
@@ -389,6 +389,56 @@ export function registerXCommands(program, ctx) {
389
389
  }
390
390
  });
391
391
 
392
+ // ── x whoami / accounts ───────────────────────────────────────────────────
393
+ // quox#867: a focused read of the connected social account (platform, handle,
394
+ // connection status), without the posting-budget half of `x status`. Backs the
395
+ // chat read-parity lane (an agent/CLI asking "which X account am I connected as?").
396
+ x
397
+ .command('whoami')
398
+ .alias('accounts')
399
+ .description('Show the connected X account: platform, handle and connection status')
400
+ .action(async () => {
401
+ auth.requireAuth();
402
+ const orgId = resolveOrgId(auth, program);
403
+ const ep = resolveEndpoint('v1.tools.execute');
404
+
405
+ let cred = null;
406
+ let credError = null;
407
+ try {
408
+ const credData = await client.post(ep.path, {
409
+ service: ep.service,
410
+ body: { tool_name: 'twitter_verify_credentials', input: {}, org_id: orgId },
411
+ });
412
+ if (credData && credData.success === false) {
413
+ credError = credData.error || 'unknown error';
414
+ } else {
415
+ cred = credData && credData.result ? credData.result : credData;
416
+ }
417
+ } catch (err) {
418
+ credError = err.message;
419
+ }
420
+
421
+ const account = {
422
+ platform: 'x',
423
+ connected: !credError && !!cred,
424
+ handle: cred && cred.username ? `@${cred.username}` : null,
425
+ name: (cred && cred.name) || null,
426
+ id: (cred && cred.id) || null,
427
+ error: credError,
428
+ };
429
+
430
+ if (program.opts().output === 'json') {
431
+ formatDetail({ accounts: [account] }, { format: 'json' });
432
+ return;
433
+ }
434
+
435
+ if (credError) {
436
+ printWarning(`X: not connected (twitter_verify_credentials via POST ${ep.path}): ${credError}`);
437
+ } else {
438
+ printSuccess(`X: connected as ${account.handle}${account.name ? ` (${account.name})` : ''}`);
439
+ }
440
+ });
441
+
392
442
  // ── x media-root ────────────────────────────────────────────────────────
393
443
 
394
444
  x
@@ -0,0 +1,86 @@
1
+ /**
2
+ * announce: the ONE renderer CLI verbs use to say "Quox did something".
3
+ *
4
+ * Owner order 2026-10-29: if anything Quox happens, it announces. A verb that
5
+ * writes or governs prints exactly one QuoxGlyph line:
6
+ * <icon> NAME │ <verb object> │ <result>[ · <duration>] <status>
7
+ * built on core/quoxglyph.js (the canonical grammar), never hand-typed.
8
+ *
9
+ * Output rules:
10
+ * - stderr, not stdout: scripted stdout (tables, ids, --json) is byte-for-byte unchanged;
11
+ * - silent under --json / --quiet;
12
+ * - brand-gradient name only when stderr is a TTY, plain when piped or redirected.
13
+ */
14
+
15
+ import { line } from './quoxglyph.js';
16
+ import { getOutputFormat } from './output.js';
17
+
18
+ // eslint-disable-next-line no-control-regex
19
+ const ANSI = /\x1b\[[0-9;]*m/g;
20
+
21
+ function errMessage(err) {
22
+ if (!err) return 'failed';
23
+ const body = err.body && (err.body.message || err.body.error);
24
+ return String(body || err.message || err).replace(/\s+/g, ' ').trim();
25
+ }
26
+
27
+ /** Render without printing. opts: {result, ms, status} (status defaults to ok). */
28
+ export function renderAnnounce(surface, verb, opts = {}) {
29
+ let s = line(surface, verb, { status: 'ok', ...opts });
30
+ if (!(process.stderr && process.stderr.isTTY)) s = s.replace(ANSI, '');
31
+ return tidy(s);
32
+ }
33
+
34
+ // No result: the grammar's third field is just the status glyph (avoid a double space after the separator).
35
+ const tidy = (s) => s.replace(/│ {2}(?=\S)/, '│ ');
36
+
37
+ export function announceMuted() { return muted(); }
38
+ function muted() {
39
+ const f = getOutputFormat();
40
+ return f === 'json' || f === 'quiet';
41
+ }
42
+
43
+ /** Print one announce line to stderr. Returns the rendered line (or null when muted). */
44
+ export function announce(surface, verb, opts = {}) {
45
+ if (muted()) return null;
46
+ const s = renderAnnounce(surface, verb, opts);
47
+ process.stderr.write(`${s}\n`);
48
+ return s;
49
+ }
50
+
51
+ /** The failure twin: one line, status fail, reason as the result. */
52
+ export function announceFailure(surface, verb, err) {
53
+ if (err && typeof err === 'object') { try { Object.defineProperty(err, 'quoxAnnounced', { value: true, configurable: true }); } catch { /* frozen */ } }
54
+ return announce(surface, verb, { result: errMessage(err), status: 'fail' });
55
+ }
56
+
57
+ /**
58
+ * Command-level wrapper: a verb whose SUCCESS announces itself inline gets its FAILURE exit announced here, once, so
59
+ * every exit (return or throw) of the verb prints exactly one QuoxGlyph line. `verb` is a string, or a function of the
60
+ * action's arguments. An error already announced (by an inner announceFailure) is not announced twice.
61
+ */
62
+ export function announceOnFail(surface, verb, fn) {
63
+ return async (...args) => {
64
+ try { return await fn(...args); } catch (err) {
65
+ if (err && typeof err === 'object' && !err.quoxAnnounced) {
66
+ announceFailure(surface, typeof verb === 'function' ? verb(...args) : verb, err);
67
+ } else if (!err || typeof err !== 'object') announceFailure(surface, typeof verb === 'function' ? verb(...args) : verb, err);
68
+ throw err;
69
+ }
70
+ };
71
+ }
72
+
73
+ // quox_event statuses arrive as QuoxGlyph status names (ok fail busy warn block).
74
+ const EVENT_STATUS = new Set(['ok', 'fail', 'busy', 'warn', 'block']);
75
+
76
+ /**
77
+ * The plain (no ANSI) QuoxGlyph line for a collector `quox_event` frame, so the TUI transcript
78
+ * and `--print` render a turn's Quox effects with the SAME renderer the verbs use.
79
+ * Returns null for a frame with nothing to say.
80
+ */
81
+ export function eventLine(ev) {
82
+ if (!ev || typeof ev !== 'object' || !ev.verb) return null;
83
+ const surface = String(ev.surface || 'quox').toLowerCase().replace(/[^a-z0-9_-]/g, '') || 'quox';
84
+ const status = EVENT_STATUS.has(ev.status) ? ev.status : 'ok';
85
+ return tidy(line(surface, String(ev.verb), { status, ...(ev.result ? { result: String(ev.result) } : {}) }).replace(ANSI, ''));
86
+ }
package/lib/core/auth.js CHANGED
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { readFileSync, statSync, openSync, closeSync, unlinkSync } from 'node:fs';
9
9
  import { QuoxAuthError, QuoxApiError, QuoxTransientAuthError } from './errors.js';
10
+ import { rateLimitHint, retryAfterMsFrom } from './loginGuard.js';
10
11
  import { loadResidentProfile, residentLogin, resolveResidentBaseUrl } from './residentAuth.js';
11
12
 
12
13
  // A resident agent has no refresh token: its expired-access-token seam is
@@ -159,6 +160,17 @@ async function acquireRefreshLock(file, { staleMs = 20000, waitMs = 20000 } = {}
159
160
  }
160
161
  }
161
162
 
163
+ // H4: tag a failed bare fetch with the server and profile so the central error mapping can name the exact
164
+ // `quox connect` command when the cause is a certificate this machine does not trust.
165
+ async function fetchTagged(config, baseUrl, url, opts) {
166
+ try { return await fetch(url, opts); } catch (e) {
167
+ if (e && typeof e === 'object') {
168
+ try { e.quoxUrl = baseUrl; e.quoxProfile = config?.getActiveProfileName?.(); } catch { /* tagging is best effort */ }
169
+ }
170
+ throw e;
171
+ }
172
+ }
173
+
162
174
  export function createAuth(config) {
163
175
  return {
164
176
  async login(baseUrl, email, password, orgId) {
@@ -170,7 +182,7 @@ export function createAuth(config) {
170
182
  const body = { email, password, client: 'cli' };
171
183
  if (orgId) body.org_id = orgId;
172
184
 
173
- const res = await fetch(url, {
185
+ const res = await fetchTagged(config, baseUrl, url, {
174
186
  method: 'POST',
175
187
  headers: { 'Content-Type': 'application/json' },
176
188
  body: JSON.stringify(body),
@@ -179,6 +191,12 @@ export function createAuth(config) {
179
191
  const data = await res.json();
180
192
 
181
193
  if (!res.ok) {
194
+ if (res.status === 429) {
195
+ const retryAfterMs = retryAfterMsFrom(res, data);
196
+ const err = new QuoxApiError(429, data.error, rateLimitHint({ retryAfterMs }));
197
+ err.retryAfterMs = retryAfterMs;
198
+ throw err;
199
+ }
182
200
  throw new QuoxApiError(res.status, data.error, data.message || 'Login failed');
183
201
  }
184
202
 
@@ -204,7 +222,7 @@ export function createAuth(config) {
204
222
 
205
223
  async verifyMfa(baseUrl, token, code) {
206
224
  const url = `${baseUrl}/api/mfa/verify`;
207
- const res = await fetch(url, {
225
+ const res = await fetchTagged(config, baseUrl, url, {
208
226
  method: 'POST',
209
227
  headers: {
210
228
  'Content-Type': 'application/json',
@@ -0,0 +1,118 @@
1
+ /**
2
+ * callMe: the shared core behind `quox approvals call-me` and the TUI
3
+ * /callmeforapprovals. It reads and flips the ApprovalsQ call-me policy (the
4
+ * switch for the watcher that phones a human when a high-risk approval sits
5
+ * unanswered). The server route is human-org-admin only; agent identities and
6
+ * service keys are refused there, and every write is audited (AEE).
7
+ *
8
+ * It NEVER approves or denies anything. on/off flip `enabled` only (plus the
9
+ * optional --after / --quiet knobs); every other stored field is preserved.
10
+ */
11
+
12
+ import { resolveEndpoint } from '../api/endpoints.js';
13
+ import { QuoxUserError } from './errors.js';
14
+
15
+ const HHMM = /^(\d{1,2}):(\d{2})$/;
16
+ const ok = (s) => { const m = HHMM.exec(s); return Boolean(m && Number(m[1]) <= 23 && Number(m[2]) <= 59); };
17
+
18
+ /** "22:00-08:00" -> { start, end }, or throws a QuoxUserError naming the fix. */
19
+ export function parseQuiet(text) {
20
+ const m = /^(\d{1,2}:\d{2})\s*-\s*(\d{1,2}:\d{2})$/.exec(String(text || '').trim());
21
+ if (!m || !ok(m[1]) || !ok(m[2])) {
22
+ throw new QuoxUserError(`--quiet must look like 22:00-08:00 (got "${text}")`, { code: 'bad_quiet_hours' });
23
+ }
24
+ return { start: m[1], end: m[2] };
25
+ }
26
+
27
+ /** --after <minutes>: integer 1-1440, or throws. */
28
+ export function parseAfter(text) {
29
+ const n = Number(text);
30
+ if (!Number.isInteger(n) || n < 1 || n > 1440) {
31
+ throw new QuoxUserError(`--after must be whole minutes between 1 and 1440 (got "${text}")`, { code: 'bad_after' });
32
+ }
33
+ return n;
34
+ }
35
+
36
+ /** Build the PUT body for an action (on|off) plus options { after, quiet }. */
37
+ export function buildChange(action, { after, quiet } = {}) {
38
+ if (action !== 'on' && action !== 'off') {
39
+ throw new QuoxUserError(`Unknown action "${action}". Use status, on or off.`, { code: 'bad_action' });
40
+ }
41
+ const body = { enabled: action === 'on' };
42
+ if (after !== undefined && after !== null && after !== '') body.age_threshold_minutes = parseAfter(after);
43
+ if (quiet) body.quiet_hours = parseQuiet(quiet);
44
+ return body;
45
+ }
46
+
47
+ /** One honest line about the watcher. Unknown is said as unknown, never guessed as alive. */
48
+ export function watcherLine(w) {
49
+ if (!w || typeof w !== 'object') return 'watcher: unknown (the server did not report it)';
50
+ if (w.running === false) {
51
+ return w.reason === 'env_flag_off'
52
+ ? 'watcher: NOT RUNNING (QUOX_APPROVALSQ_WATCHER is off on the collector, so no call will be placed)'
53
+ : 'watcher: NOT RUNNING (it did not start, so no call will be placed)';
54
+ }
55
+ if (w.running === true) {
56
+ if (!w.last_tick_at) return 'watcher: started, no check completed yet';
57
+ const tail = w.stale ? ' STALE, it has stopped checking' : (w.last_tick_ok === false ? ' (last check had errors)' : '');
58
+ return `watcher: alive, last check ${w.last_tick_at}${tail}`;
59
+ }
60
+ return 'watcher: unknown (the server did not report it)';
61
+ }
62
+
63
+ /** Human-readable status lines from a { policy, watcher } response. */
64
+ export function statusLines(data) {
65
+ const p = (data && data.policy) || {};
66
+ const q = p.quiet_hours || {};
67
+ const lines = [
68
+ `call me for approvals: ${p.enabled ? 'ON' : 'OFF'}`,
69
+ `rings after: ${p.age_threshold_minutes} min unanswered`,
70
+ `quiet hours: ${q.start}-${q.end} ${q.timezone || ''}`.trimEnd(),
71
+ `min tier: ${p.min_tier}`,
72
+ watcherLine(data && data.watcher),
73
+ ];
74
+ if (p.enabled && p.destination_configured === false) lines.push('calls go to: the owner mobile (QuoxVoice default; no destination number set on this policy)');
75
+ return lines;
76
+ }
77
+
78
+ /** Plain-words refusal for a 401/403 from the server. */
79
+ function refusal(e) {
80
+ if (e && e.status === 402) {
81
+ return new QuoxUserError('QuoxVoice is not licensed for this org, so call-me for approvals is unavailable.', { code: 'license_required', status: 402 });
82
+ }
83
+ if (e && (e.status === 403 || e.status === 401)) {
84
+ return new QuoxUserError('Only a signed-in human org admin can change or read the call-me policy. Agent identities and service keys are refused.', { code: 'admin_only', status: e.status });
85
+ }
86
+ return e;
87
+ }
88
+
89
+ export async function getCallMe(client) {
90
+ const ep = resolveEndpoint('v1.approvals.call-me.get');
91
+ try { return await client.get(ep.path, { service: ep.service, retries: 0 }); } catch (e) { throw refusal(e); }
92
+ }
93
+
94
+ export async function setCallMe(client, change) {
95
+ const ep = resolveEndpoint('v1.approvals.call-me.put');
96
+ try { return await client.put(ep.path, { service: ep.service, body: change, retries: 0 }); } catch (e) { throw refusal(e); }
97
+ }
98
+
99
+ /** Parse TUI args: "[on|off|status] [--after N] [--quiet HH:MM-HH:MM]" -> { action, after, quiet }. */
100
+ export function parseSlashArgs(text) {
101
+ const toks = String(text || '').trim().split(/\s+/).filter(Boolean);
102
+ const out = { action: 'status' };
103
+ for (let i = 0; i < toks.length; i++) {
104
+ const t = toks[i];
105
+ if (t === 'on' || t === 'off' || t === 'status') out.action = t;
106
+ else if (t === '--after') out.after = toks[++i];
107
+ else if (t === '--quiet') out.quiet = toks[++i];
108
+ else throw new QuoxUserError(`Unknown argument "${t}". Usage: /callmeforapprovals [on|off|status] [--after <minutes>] [--quiet HH:MM-HH:MM]`, { code: 'bad_args' });
109
+ }
110
+ return out;
111
+ }
112
+
113
+ /** Run an action end to end -> { data, changed }. status reads; on/off write. */
114
+ export async function runCallMe(client, { action = 'status', after, quiet } = {}) {
115
+ if (action === 'status') return { data: await getCallMe(client), changed: false };
116
+ const body = buildChange(action, { after, quiet });
117
+ return { data: await setCallMe(client, body), changed: true };
118
+ }
@@ -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) {
@@ -209,6 +233,7 @@ export function createClient(config, auth) {
209
233
  } else if (tokens?.access_token) {
210
234
  fetchHeaders['Authorization'] = `Bearer ${tokens.access_token}`;
211
235
  } else if (serviceKey && isLocalServiceTarget(url.href)) {
236
+ assertNoExplicitProfileFallback(config);
212
237
  // QUOXON-Z2H lane R: the implicit fallback is THIS box's key; never
213
238
  // offer it to another host (a remote profile with no login). A caller
214
239
  // that owns a remote key says so with QUOX_FORCE_SERVICE_KEY.
@@ -538,6 +563,7 @@ export function createClient(config, auth) {
538
563
  } else if (streamTokens?.access_token) {
539
564
  fetchHeaders['Authorization'] = `Bearer ${streamTokens.access_token}`;
540
565
  } else if (process.env.QUOX_SERVICE_KEY && isLocalServiceTarget(url.href)) {
566
+ assertNoExplicitProfileFallback(config);
541
567
  streamServiceKey = process.env.QUOX_SERVICE_KEY;
542
568
  fetchHeaders['X-Service-Key'] = streamServiceKey;
543
569
  }
@@ -11,6 +11,7 @@ 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'],
15
16
  ['../commands/shortcut.js', 'registerShortcutCommand'],
16
17
  ['../commands/open.js', 'registerOpenCommand'],
@@ -45,11 +46,17 @@ export const COMMAND_REGISTRARS = [
45
46
  ['../commands/notes.js', 'registerNotesCommands'],
46
47
  ['../commands/blog.js', 'registerBlogCommands'],
47
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'],
48
54
  ['../commands/licence.js', 'registerLicenceCommands'],
49
55
  ['../commands/run.js', 'registerRunCommands'],
50
56
  ['../commands/file.js', 'registerFileCommands'],
51
57
  ['../commands/evidence.js', 'registerEvidenceCommands'],
52
58
  ['../commands/admin.js', 'registerAdminCommands'],
59
+ ['../commands/mcp.js', 'registerMcpCommands'],
53
60
  ['../commands/aee.js', 'registerAeeCommands'],
54
61
  ['../commands/integration.js', 'registerIntegrationCommands'],
55
62
  ['../commands/consent.js', 'registerConsentCommands'],
@@ -65,6 +72,7 @@ export const COMMAND_REGISTRARS = [
65
72
  ['../commands/vault.js', 'registerVaultCommands'],
66
73
  ['../commands/volt.js', 'registerVoltCommands'],
67
74
  ['../commands/governance.js', 'registerGovernanceCommands'],
75
+ ['../commands/compliance.js', 'registerComplianceCommands'],
68
76
  ['../commands/skills.js', 'registerSkillsCommands'],
69
77
  ['../commands/claude.js', 'registerClaudeCommands'],
70
78
  ['../commands/monitoring.js', 'registerMonitoringCommands'],
@@ -90,6 +98,7 @@ export const COMMAND_REGISTRARS = [
90
98
  ['../commands/trace.js', 'registerTraceCommands'],
91
99
  ['../commands/mon.js', 'registerMonCommands'],
92
100
  ['../commands/kuma.js', 'registerKumaCommands'],
101
+ ['../commands/wazuh.js', 'registerWazuhCommands'],
93
102
  ['../commands/quoxbox.js', 'registerQuoxboxCommands'],
94
103
  ['../commands/plan.js', 'registerPlanCommands'],
95
104
  ['../commands/engagement.js', 'registerEngagementCommands'],
@@ -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;
@@ -41,6 +41,15 @@ function acquireLock(lockFile) {
41
41
  function releaseLock(lockFile) { try { unlinkSync(lockFile); } catch { /* already gone */ } }
42
42
  const J = (v) => JSON.stringify(v === undefined ? null : v);
43
43
 
44
+ function envTokenExpiryMs(token) {
45
+ try {
46
+ const payload = JSON.parse(Buffer.from(String(token).split('.')[1], 'base64url').toString('utf8'));
47
+ return typeof payload.exp === 'number' ? payload.exp * 1000 : null;
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
44
53
  const DEFAULT_CONFIG = {
45
54
  version: 1,
46
55
  activeProfile: 'local',
@@ -346,6 +355,32 @@ export function loadConfig(profileOverride) {
346
355
  this.save();
347
356
  },
348
357
 
358
+ // ONBOARD-P1 (quox connect): a named profile that is not the active one, created or updated in place.
359
+ // Never moves the persisted default.
360
+ getProfile(name) { return this._data.profiles[name] || null; },
361
+ upsertProfile(name, patch = {}) {
362
+ let prof = this._data.profiles[name];
363
+ if (!prof) {
364
+ prof = this._data.profiles[name] = { url: patch.url || DEFAULT_CONFIG.profiles.local.url, auth: null };
365
+ this._removedProfiles?.delete(name);
366
+ }
367
+ Object.assign(prof, patch);
368
+ this.save();
369
+ return prof;
370
+ },
371
+ // Put a profile back exactly as it was before upsertProfile (snapshot from getProfile, null = did not exist).
372
+ restoreProfile(name, snapshot) {
373
+ if (snapshot == null) {
374
+ delete this._data.profiles[name];
375
+ this._removedProfiles.add(name);
376
+ } else {
377
+ const cur = this._data.profiles[name] || (this._data.profiles[name] = {});
378
+ for (const k of Object.keys(cur)) if (!(k in snapshot)) delete cur[k];
379
+ Object.assign(cur, JSON.parse(JSON.stringify(snapshot)));
380
+ }
381
+ this.save();
382
+ },
383
+
349
384
  removeProfile(name) {
350
385
  if (name === this._effectiveProfile) {
351
386
  throw new Error('Cannot remove the active profile. Switch first.');
@@ -391,7 +426,11 @@ export function loadConfig(profileOverride) {
391
426
  getAuth() {
392
427
  // Env var override
393
428
  if (process.env.QUOX_TOKEN) {
394
- return { access_token: process.env.QUOX_TOKEN };
429
+ // The JWT's own exp is the env token's expiry. Without it auth.isExpired() read every env
430
+ // token as expired and tried a refresh there is no refresh token for (the dashboard /cli
431
+ // browser terminal then opened the conversation app signed out). Not verification: the server verifies.
432
+ const exp = envTokenExpiryMs(process.env.QUOX_TOKEN);
433
+ return exp ? { access_token: process.env.QUOX_TOKEN, expires_at: exp } : { access_token: process.env.QUOX_TOKEN };
395
434
  }
396
435
  return this.getActiveProfile()?.auth || null;
397
436
  },