quoxcli 0.2.0 → 0.4.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.
- package/LICENSE +1 -1
- package/README.md +2 -0
- package/bin/quox.js +99 -126
- package/lib/api/endpoints/actions.js +15 -0
- package/lib/api/endpoints/admin.js +46 -0
- package/lib/api/endpoints/agent-schedules.js +26 -0
- package/lib/api/endpoints/agents.js +7 -0
- package/lib/api/endpoints/assistants.js +12 -0
- package/lib/api/endpoints/away.js +15 -0
- package/lib/api/endpoints/blog.js +16 -0
- package/lib/api/endpoints/chat.js +14 -0
- package/lib/api/endpoints/contactsq.js +6 -0
- package/lib/api/endpoints/conversations.js +14 -0
- package/lib/api/endpoints/devServerRegistry.js +25 -0
- package/lib/api/endpoints/engagement.js +26 -0
- package/lib/api/endpoints/evidence.js +20 -0
- package/lib/api/endpoints/executions.js +13 -0
- package/lib/api/endpoints/fetchAllowlist.js +27 -0
- package/lib/api/endpoints/files.js +14 -0
- package/lib/api/endpoints/hat.js +44 -0
- package/lib/api/endpoints/inbox.js +20 -0
- package/lib/api/endpoints/index.js +26 -0
- package/lib/api/endpoints/loops.js +26 -0
- package/lib/api/endpoints/memory.js +19 -0
- package/lib/api/endpoints/mind.js +13 -0
- package/lib/api/endpoints/mon.js +179 -0
- package/lib/api/endpoints/objectives.js +12 -0
- package/lib/api/endpoints/plugins.js +18 -0
- package/lib/api/endpoints/presence.js +33 -0
- package/lib/api/endpoints/quoxplan.js +7 -0
- package/lib/api/endpoints/quoxseo.js +12 -0
- package/lib/api/endpoints/router.js +17 -0
- package/lib/api/endpoints/safety.js +7 -0
- package/lib/api/endpoints/server.js +21 -0
- package/lib/api/endpoints/setup.js +6 -0
- package/lib/api/endpoints/skills.js +20 -0
- package/lib/api/endpoints/stepup.js +20 -0
- package/lib/api/endpoints/teams.js +2 -2
- package/lib/api/endpoints/triggers.js +26 -0
- package/lib/api/endpoints/v1.js +120 -0
- package/lib/api/endpoints/ward.js +7 -0
- package/lib/api/endpoints/workflows.js +7 -0
- package/lib/api/endpoints/x.js +8 -0
- package/lib/commands/actions.js +131 -0
- package/lib/commands/admin.js +16 -0
- package/lib/commands/agent.js +170 -27
- package/lib/commands/agentAttach.js +140 -13
- package/lib/commands/approvals.js +78 -1
- package/lib/commands/aside.js +24 -0
- package/lib/commands/ask.js +146 -0
- package/lib/commands/assistant.js +59 -4
- package/lib/commands/auth.js +8 -2
- package/lib/commands/backup.js +8 -4
- package/lib/commands/blog.js +110 -0
- package/lib/commands/brain.js +105 -14
- package/lib/commands/build.js +19 -5
- package/lib/commands/chat.js +180 -26
- package/lib/commands/chatCompanion.js +27 -0
- package/lib/commands/chatState.js +45 -0
- package/lib/commands/claude.js +35 -38
- package/lib/commands/contacts.js +134 -4
- package/lib/commands/contactsSendAt.js +21 -0
- package/lib/commands/conversation.js +35 -4
- package/lib/commands/dashboard.js +43 -0
- package/lib/commands/department.js +28 -7
- package/lib/commands/devResident.js +15 -3
- package/lib/commands/devSupervisor.js +13 -1
- package/lib/commands/discord.js +75 -39
- package/lib/commands/done.js +98 -28
- package/lib/commands/engagement.js +107 -6
- package/lib/commands/evidence.js +154 -0
- package/lib/commands/ext.js +23 -0
- package/lib/commands/fetchAllowlist.js +71 -0
- package/lib/commands/file.js +38 -1
- package/lib/commands/fleet.js +336 -4
- package/lib/commands/governance.js +12 -3
- package/lib/commands/gripe.js +44 -13
- package/lib/commands/hat.js +254 -9
- package/lib/commands/helpers.js +18 -1
- package/lib/commands/hyperion.js +58 -0
- package/lib/commands/image.js +68 -0
- package/lib/commands/inbox.js +74 -23
- package/lib/commands/kuma.js +1050 -0
- package/lib/commands/kumaSla.js +174 -0
- package/lib/commands/licence.js +52 -155
- package/lib/commands/loop.js +291 -0
- package/lib/commands/lucid.js +10 -4
- package/lib/commands/memory.js +85 -8
- package/lib/commands/mind.js +71 -0
- package/lib/commands/mon.js +69 -2
- package/lib/commands/monitoring.js +1 -1
- package/lib/commands/motion.js +2 -2
- package/lib/commands/nucleus.js +48 -17
- package/lib/commands/objective.js +59 -1
- package/lib/commands/offload.js +89 -0
- package/lib/commands/onboard.js +2 -1
- package/lib/commands/org.js +30 -3
- package/lib/commands/peek.js +28 -0
- package/lib/commands/plan.js +38 -0
- package/lib/commands/plugin.js +110 -0
- package/lib/commands/presence.js +89 -0
- package/lib/commands/provider.js +217 -0
- package/lib/commands/qlarity.js +19 -3
- package/lib/commands/quoxchat.js +13 -4
- package/lib/commands/quoxseo.js +69 -0
- package/lib/commands/reach.js +2 -1
- package/lib/commands/research.js +79 -0
- package/lib/commands/router.js +77 -0
- package/lib/commands/rows.js +367 -0
- package/lib/commands/run.js +82 -10
- package/lib/commands/scope.js +2 -2
- package/lib/commands/server.js +405 -0
- package/lib/commands/setup.js +33 -7
- package/lib/commands/shortcut.js +41 -0
- package/lib/commands/skills.js +68 -9
- package/lib/commands/slot.js +260 -7
- package/lib/commands/status.js +1 -1
- package/lib/commands/team.js +3 -1
- package/lib/commands/tips.js +23 -0
- package/lib/commands/tool.js +133 -45
- package/lib/commands/trace.js +12 -7
- package/lib/commands/trigger.js +95 -0
- package/lib/commands/update.js +46 -0
- package/lib/commands/vault.js +31 -8
- package/lib/commands/vm.js +237 -0
- package/lib/commands/voice.js +34 -3
- package/lib/commands/volt.js +45 -3
- package/lib/commands/walkthrough.js +32 -0
- package/lib/commands/watch.js +73 -21
- package/lib/commands/welcome.js +14 -0
- package/lib/commands/workflow.js +189 -32
- package/lib/commands/x.js +13 -4
- package/lib/commands/youtube.js +172 -0
- package/lib/core/actingAs.js +2 -2
- package/lib/core/actionItems.js +115 -0
- package/lib/core/activeSummary.js +186 -0
- package/lib/core/agentAlias.js +2 -0
- package/lib/core/agentFamilies.js +7 -0
- package/lib/core/aside.js +54 -0
- package/lib/core/askDecision.js +60 -0
- package/lib/core/auth.js +164 -13
- package/lib/core/callerSource.js +1 -1
- package/lib/core/cliBuild.js +62 -0
- package/lib/core/client.js +152 -35
- package/lib/core/commandRegistrars.js +130 -0
- package/lib/core/config.js +143 -25
- package/lib/core/customHats.js +168 -0
- package/lib/core/doneEngineFetch.js +8 -0
- package/lib/core/errors.js +329 -20
- package/lib/core/findingReconcile.js +90 -0
- package/lib/core/global-flags.js +105 -5
- package/lib/core/governedSession.js +1 -3
- package/lib/core/hyperion.js +102 -0
- package/lib/core/inboxResolution.js +65 -0
- package/lib/core/lazyCommands.js +4 -1
- package/lib/core/liveSessionsReport.js +1 -1
- package/lib/core/loopGlyph.js +97 -0
- package/lib/core/offload.js +533 -0
- package/lib/core/org-override.js +1 -1
- package/lib/core/orgResolve.js +70 -0
- package/lib/core/output.js +11 -11
- package/lib/core/peek.js +126 -0
- package/lib/core/profileUsers.js +51 -0
- package/lib/core/promptCancel.js +18 -0
- package/lib/core/repoPath.js +11 -0
- package/lib/core/residentSeed.js +3 -1
- package/lib/core/rootOptions.js +39 -0
- package/lib/core/router.js +168 -0
- package/lib/core/selfUpdate.js +188 -0
- package/lib/core/sessionHeartbeat.js +12 -8
- package/lib/core/sessionsOverview.js +64 -0
- package/lib/core/shortcut.js +83 -0
- package/lib/core/slotKeeper.js +248 -3
- package/lib/core/slotWake.js +120 -0
- package/lib/core/supervisorEngine.js +71 -2
- package/lib/core/version.js +1 -1
- package/lib/core/vm.js +366 -0
- package/lib/core/vmTui.js +86 -0
- package/lib/core/walkthrough.js +35 -0
- package/lib/dragon/companionView.js +34 -0
- package/lib/dragon/frames.js +118 -0
- package/lib/dragon/jobs.js +123 -0
- package/lib/dragon/renderWorker.js +15 -0
- package/lib/dragon/select.js +72 -0
- package/lib/evidence/bundleExport.js +47 -0
- package/lib/evidence/bundleVerify.js +77 -0
- package/lib/hyperion/deck.js +29 -0
- package/lib/lucid/LucidCard.jsx +68 -0
- package/lib/lucid/LucidIntro.jsx +45 -58
- package/lib/lucid/cards.js +86 -0
- package/lib/lucid/markdown.js +81 -0
- package/lib/lucid/motion.js +56 -0
- package/lib/quoxon/EXTENSIONS.md +23 -0
- package/lib/quoxon/activity.js +14 -2
- package/lib/quoxon/aocl.js +117 -0
- package/lib/quoxon/api.js +690 -25
- package/lib/quoxon/app.js +3586 -250
- package/lib/quoxon/approvalFacts.js +96 -0
- package/lib/quoxon/approvalSentence.js +44 -0
- package/lib/quoxon/approvalsView.js +384 -0
- package/lib/quoxon/asideView.js +40 -0
- package/lib/quoxon/ask.js +121 -0
- package/lib/quoxon/away.js +52 -0
- package/lib/quoxon/bang.js +224 -0
- package/lib/quoxon/bulkRisk.js +17 -0
- package/lib/quoxon/capabilities.js +13 -6
- package/lib/quoxon/cells.js +53 -0
- package/lib/quoxon/commandShort.js +124 -0
- package/lib/quoxon/commands.js +429 -39
- package/lib/quoxon/connect.js +210 -23
- package/lib/quoxon/connectCard.js +19 -2
- package/lib/quoxon/consent.js +86 -0
- package/lib/quoxon/copy.js +4 -3
- package/lib/quoxon/countdownLine.js +20 -0
- package/lib/quoxon/deck.js +30 -0
- package/lib/quoxon/demo.autoplay.js +29 -0
- package/lib/quoxon/demo.fixtures.js +1 -1
- package/lib/quoxon/demo.js +46 -3
- package/lib/quoxon/devObserver.js +20 -0
- package/lib/quoxon/dividerAocl.js +192 -0
- package/lib/quoxon/dividerRule.js +57 -0
- package/lib/quoxon/dragonCompanion.js +255 -0
- package/lib/quoxon/dragonModes.js +151 -0
- package/lib/quoxon/dragonMoments.js +82 -0
- package/lib/quoxon/dragonReads.js +68 -0
- package/lib/quoxon/dragonView.js +41 -0
- package/lib/quoxon/earlyInput.js +40 -0
- package/lib/quoxon/echo.js +15 -0
- package/lib/quoxon/egg.js +2 -2
- package/lib/quoxon/events.js +266 -26
- package/lib/quoxon/extensions.js +276 -0
- package/lib/quoxon/feedback.js +50 -0
- package/lib/quoxon/gallery.js +21 -7
- package/lib/quoxon/handoffOffer.js +78 -0
- package/lib/quoxon/hatSwitch.js +37 -0
- package/lib/quoxon/headless.js +161 -0
- package/lib/quoxon/hostUrls.js +41 -0
- package/lib/quoxon/inboxView.js +79 -0
- package/lib/quoxon/indicator.js +6 -2
- package/lib/quoxon/installCli.js +79 -0
- package/lib/quoxon/intent.js +12 -1
- package/lib/quoxon/keys.js +51 -8
- package/lib/quoxon/launcher.js +103 -0
- package/lib/quoxon/loopCard.js +456 -0
- package/lib/quoxon/loopCards.js +180 -0
- package/lib/quoxon/loopStream.js +87 -0
- package/lib/quoxon/main.js +101 -160
- package/lib/quoxon/markdown.js +45 -21
- package/lib/quoxon/markdownSettle.js +19 -0
- package/lib/quoxon/matrixDomains.json +46 -0
- package/lib/quoxon/mentions.js +90 -0
- package/lib/quoxon/mind.js +103 -0
- package/lib/quoxon/mode.js +46 -0
- package/lib/quoxon/model.js +132 -26
- package/lib/quoxon/modelPrefs.js +345 -0
- package/lib/quoxon/motion/clock.js +3 -2
- package/lib/quoxon/motion/level.js +91 -0
- package/lib/quoxon/mouse.js +222 -0
- package/lib/quoxon/noai.js +57 -0
- package/lib/quoxon/objectiveOffer.js +82 -0
- package/lib/quoxon/objects/away.js +72 -0
- package/lib/quoxon/objects/fetch.js +17 -5
- package/lib/quoxon/objects/fleet.js +149 -0
- package/lib/quoxon/objects/health.js +186 -0
- package/lib/quoxon/objects/index.js +46 -7
- package/lib/quoxon/objects/mirror.js +137 -0
- package/lib/quoxon/objects/model.js +33 -0
- package/lib/quoxon/objects/peek.js +17 -0
- package/lib/quoxon/objects/proof.js +12 -4
- package/lib/quoxon/objects/prove.js +130 -0
- package/lib/quoxon/objects/replay.js +125 -0
- package/lib/quoxon/objects/sitrep.js +58 -0
- package/lib/quoxon/objects/view.js +11 -0
- package/lib/quoxon/onboarding/copy.js +32 -4
- package/lib/quoxon/onboarding/credential.js +17 -4
- package/lib/quoxon/onboarding/dockerOffer.js +45 -0
- package/lib/quoxon/onboarding/failureCause.js +33 -0
- package/lib/quoxon/onboarding/finish.js +15 -0
- package/lib/quoxon/onboarding/index.js +34 -7
- package/lib/quoxon/onboarding/installRunner.js +64 -5
- package/lib/quoxon/onboarding/preflight.js +17 -3
- package/lib/quoxon/onboarding/probe.js +65 -0
- package/lib/quoxon/onboarding/shortcutOffer.js +28 -0
- package/lib/quoxon/onboarding/view.js +76 -19
- package/lib/quoxon/orgLabel.js +10 -0
- package/lib/quoxon/orgSwitch.js +59 -0
- package/lib/quoxon/ownBroadcast.js +22 -0
- package/lib/quoxon/palette.js +43 -0
- package/lib/quoxon/planSteps.js +51 -0
- package/lib/quoxon/print.js +12 -5
- package/lib/quoxon/profileSwitch.js +53 -0
- package/lib/quoxon/promptHistory.js +41 -0
- package/lib/quoxon/providers.js +48 -0
- package/lib/quoxon/qart.js +20 -51
- package/lib/quoxon/qdragon.js +410 -0
- package/lib/quoxon/qdragonFly.js +62 -0
- package/lib/quoxon/qdragonScenes.js +282 -0
- package/lib/quoxon/qeyes.js +213 -0
- package/lib/quoxon/qmotion.js +75 -37
- package/lib/quoxon/render.js +740 -124
- package/lib/quoxon/resend.js +72 -0
- package/lib/quoxon/roster.js +117 -17
- package/lib/quoxon/route.js +6 -5
- package/lib/quoxon/rows.js +954 -0
- package/lib/quoxon/rowsStore.js +80 -0
- package/lib/quoxon/screenContext.js +150 -0
- package/lib/quoxon/screenFrames.js +254 -0
- package/lib/quoxon/serverChip.js +42 -0
- package/lib/quoxon/session.js +182 -2
- package/lib/quoxon/sessionDead.js +17 -0
- package/lib/quoxon/signin.js +125 -0
- package/lib/quoxon/slash/agents.js +112 -0
- package/lib/quoxon/slash/argSources.js +65 -0
- package/lib/quoxon/slash/args.js +65 -0
- package/lib/quoxon/slash/automate.js +89 -0
- package/lib/quoxon/slash/broadcast.js +140 -0
- package/lib/quoxon/slash/broadcastRun.js +108 -0
- package/lib/quoxon/slash/card.js +30 -0
- package/lib/quoxon/slash/challenge.js +114 -0
- package/lib/quoxon/slash/cliCommand.js +94 -0
- package/lib/quoxon/slash/cliLeaves.json +1023 -0
- package/lib/quoxon/slash/compact.js +1 -1
- package/lib/quoxon/slash/confirm.js +30 -0
- package/lib/quoxon/slash/crystallize.js +69 -0
- package/lib/quoxon/slash/dashboard.js +136 -0
- package/lib/quoxon/slash/draft.js +54 -0
- package/lib/quoxon/slash/evidenceCmd.js +34 -0
- package/lib/quoxon/slash/hatWalk.js +206 -0
- package/lib/quoxon/slash/inverses.js +77 -0
- package/lib/quoxon/slash/killer.js +24 -0
- package/lib/quoxon/slash/killerApi.js +16 -0
- package/lib/quoxon/slash/ledger.js +13 -8
- package/lib/quoxon/slash/listView.js +3 -1
- package/lib/quoxon/slash/modelCmd.js +261 -0
- package/lib/quoxon/slash/palette.js +22 -8
- package/lib/quoxon/slash/paletteController.js +117 -7
- package/lib/quoxon/slash/panels.js +21 -6
- package/lib/quoxon/slash/picker.js +20 -2
- package/lib/quoxon/slash/qpeCmd.js +35 -0
- package/lib/quoxon/slash/restart.js +95 -13
- package/lib/quoxon/slash/resume.js +18 -1
- package/lib/quoxon/slash/rowCmd.js +157 -0
- package/lib/quoxon/slash/rowWalk.js +175 -0
- package/lib/quoxon/slash/settings.js +16 -6
- package/lib/quoxon/slash/spawn.js +177 -0
- package/lib/quoxon/slash/stepUp.js +274 -0
- package/lib/quoxon/slash/stepUpCard.js +60 -0
- package/lib/quoxon/slash/stepUpCmds.js +78 -0
- package/lib/quoxon/slash/todoCmd.js +122 -0
- package/lib/quoxon/slash/undoApi.js +38 -0
- package/lib/quoxon/slash/undoCmd.js +49 -0
- package/lib/quoxon/slash/undoWish.js +15 -0
- package/lib/quoxon/slash/watch.js +164 -0
- package/lib/quoxon/slash/wish.js +23 -0
- package/lib/quoxon/slash/wishCmd.js +32 -0
- package/lib/quoxon/slotChat.js +300 -0
- package/lib/quoxon/slotFormat.js +195 -0
- package/lib/quoxon/slotRun.js +131 -0
- package/lib/quoxon/sound/mapping.js +2 -1
- package/lib/quoxon/stray.js +22 -0
- package/lib/quoxon/suggestCommand.js +90 -0
- package/lib/quoxon/term.js +46 -11
- package/lib/quoxon/textBuffer.js +176 -2
- package/lib/quoxon/theme.js +58 -25
- package/lib/quoxon/tips.js +433 -0
- package/lib/quoxon/toolCards.js +30 -0
- package/lib/quoxon/toolError.js +41 -0
- package/lib/quoxon/transcript.js +42 -4
- package/lib/quoxon/web/qface.js +18 -10
- package/lib/quoxon/welcome.js +1 -1
- package/lib/quoxon/whatsnew.js +136 -0
- package/lib/quoxon/whatsnew.jsonl +134 -0
- package/lib/quoxon/windowOrg.js +123 -0
- package/lib/server/devServers.js +137 -0
- package/lib/server/diag.js +129 -0
- package/lib/server/doctor.js +71 -0
- package/lib/server/install.js +149 -0
- package/lib/server/pluginEnable.js +161 -0
- package/lib/server/skew.js +56 -0
- package/lib/server/snapshot.js +131 -0
- package/lib/server/status.js +128 -0
- package/lib/server/sys.js +65 -0
- package/lib/server/uninstall.js +63 -0
- package/lib/server/update.js +234 -0
- package/lib/toolOutputShape.js +315 -0
- package/lib/tui/views/HyperionDeck.jsx +79 -0
- package/lib/tui/views/LucidDeck.jsx +209 -217
- package/package.json +5 -1
- package/vendor/dragonlab/SOURCE +4 -0
- package/vendor/dragonlab/design/cipher/v1/cipher-master.svg +46 -0
- package/vendor/dragonlab/design/cipher/v2/character.mjs +49 -0
- package/vendor/dragonlab/design/cipher/v2/review.mjs +33 -0
- package/vendor/dragonlab/design/cipher/v2/watch.mjs +56 -0
- package/vendor/dragonlab/design/drago/v1/character.mjs +19 -0
- package/vendor/dragonlab/design/drago/v1/drago-master.svg +56 -0
- package/vendor/dragonlab/design/drago/v2/character.mjs +87 -0
- package/vendor/dragonlab/design/drago/v4/character.mjs +67 -0
- package/vendor/dragonlab/design/drago/v4/profile.mjs +35 -0
- package/vendor/dragonlab/design/drago/v4/review.mjs +33 -0
- package/vendor/dragonlab/design/drago/v4/watch.mjs +56 -0
- package/vendor/dragonlab/design/hyperion/v1/hyperion-master.svg +56 -0
- package/vendor/dragonlab/design/hyperion/v2/character.mjs +79 -0
- package/vendor/dragonlab/design/hyperion/v2/review.mjs +33 -0
- package/vendor/dragonlab/design/hyperion/v2/watch.mjs +56 -0
- package/vendor/dragonlab/design/lucid/recovery/arrival.mjs +22 -0
- package/vendor/dragonlab/design/lucid/recovery/character.mjs +106 -0
- package/vendor/dragonlab/design/lucid/recovery/layout.mjs +50 -0
- package/vendor/dragonlab/design/lucid/recovery/lucid-master.svg +78 -0
- package/vendor/dragonlab/design/lucid/recovery/motion.mjs +42 -0
- package/vendor/dragonlab/design/lucid/recovery/render.mjs +121 -0
- package/vendor/dragonlab/design/lucid/recovery/review.mjs +68 -0
- package/vendor/dragonlab/design/lucid/recovery/verify.mjs +84 -0
- package/vendor/dragonlab/design/lucid/recovery/watch.mjs +56 -0
- package/vendor/dragonlab/design/seer/v1/seer-master.svg +53 -0
- package/vendor/dragonlab/design/seer/v2/character.mjs +88 -0
- package/vendor/dragonlab/design/seer/v3/character.mjs +33 -0
- package/vendor/dragonlab/design/seer/v4/character.mjs +66 -0
- package/vendor/dragonlab/design/seer/v4/review.mjs +33 -0
- package/vendor/dragonlab/design/seer/v4/watch.mjs +56 -0
- package/vendor/dragonlab/design/stage3/companion-review.mjs +28 -0
- package/vendor/dragonlab/design/stage3/sentinel-art.js +39 -0
- package/vendor/dragonlab/design/stage3/sentinel-braille.js +216 -0
- package/vendor/dragonlab/design/stage3/sentinel-character.js +257 -0
- package/vendor/dragonlab/design/stage3/sentinel-icon.js +103 -0
- package/vendor/dragonlab/design/stage3/sentinel-warden-eyes.js +95 -0
- package/vendor/dragonlab/design/stage3/svg-coverage.js +148 -0
- package/vendor/dragonlab/factory/svg-rig.mjs +63 -0
- package/vendor/dragonlab/integration/companions-v1/characters.mjs +88 -0
- package/vendor/dragonlab/integration/companions-v1/cipher.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/companion.mjs +63 -0
- package/vendor/dragonlab/integration/companions-v1/drago.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/hyperion.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/lucid.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/seer.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/sentinel.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/view.mjs +28 -0
- package/vendor/dragonlab/integration/hyperion-v3/activity.mjs +76 -0
- package/vendor/dragonlab/lib/power-effects.js +294 -0
- package/vendor/dragonlab/lib/power-registry.js +74 -0
- package/vendor/dragonlab/vendor/quox-dragon/qart.js +348 -0
- package/vendor/dragonlab/vendor/quox-dragon/qeyes.js +213 -0
- package/lib/core/websiteSession.js +0 -148
package/lib/core/auth.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* Tokens stored in config profile's auth field.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { readFileSync, statSync, openSync, closeSync, unlinkSync } from 'node:fs';
|
|
8
9
|
import { QuoxAuthError, QuoxApiError, QuoxTransientAuthError } from './errors.js';
|
|
9
10
|
import { loadResidentProfile, residentLogin, resolveResidentBaseUrl } from './residentAuth.js';
|
|
10
11
|
|
|
@@ -94,11 +95,79 @@ function residentProfileHint(config, deadOrgId = null) {
|
|
|
94
95
|
}
|
|
95
96
|
}
|
|
96
97
|
|
|
98
|
+
// QUOX_TOKEN_FILE: the W4 builder principal (SDK-TEAM design v2, 2.1.3) runs
|
|
99
|
+
// on ONE agent token that an owner-side helper writes to a file and rewrites
|
|
100
|
+
// near expiry. The builder holds no refresh token, no user session and no
|
|
101
|
+
// service key, so this mode is exclusive and fails CLOSED: the file's token or
|
|
102
|
+
// nothing, never the profile, never an ambient QUOX_SERVICE_KEY. Its "refresh"
|
|
103
|
+
// is re-reading the file; it never calls the auth service and never writes or
|
|
104
|
+
// clears the profile.
|
|
105
|
+
function tokenFilePath() {
|
|
106
|
+
return process.env.QUOX_TOKEN_FILE || null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function readTokenFile(file) {
|
|
110
|
+
let st;
|
|
111
|
+
try {
|
|
112
|
+
st = statSync(file);
|
|
113
|
+
} catch {
|
|
114
|
+
throw new QuoxAuthError(`QUOX_TOKEN_FILE ${file} is not readable. The owner-side builder helper writes it; is the lease live?`);
|
|
115
|
+
}
|
|
116
|
+
if (st.mode & 0o004) {
|
|
117
|
+
throw new QuoxAuthError(`QUOX_TOKEN_FILE ${file} is readable by other users (mode ${(st.mode & 0o777).toString(8)}); refusing to use it. Expected 0640 or tighter.`);
|
|
118
|
+
}
|
|
119
|
+
const token = readFileSync(file, 'utf8').trim();
|
|
120
|
+
if (!token) throw new QuoxAuthError(`QUOX_TOKEN_FILE ${file} is empty.`);
|
|
121
|
+
return { access_token: token, expires_at: jwtExpiryMs(token), token_file: file };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Expiry only, for the client's "looks expired, try the refresh seam" signal.
|
|
125
|
+
// Not verification: the server verifies. Undecodable means no local expiry.
|
|
126
|
+
function jwtExpiryMs(token) {
|
|
127
|
+
try {
|
|
128
|
+
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString('utf8'));
|
|
129
|
+
return typeof payload.exp === 'number' ? payload.exp * 1000 : null;
|
|
130
|
+
} catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Keep the last login email on the profile (not a secret) so a signed-out session can prefill /login. */
|
|
136
|
+
export function rememberEmail(config, email) {
|
|
137
|
+
try {
|
|
138
|
+
const prof = config.getActiveProfile?.();
|
|
139
|
+
if (prof && email && prof.last_email !== email) { prof.last_email = email; config.save?.(); }
|
|
140
|
+
} catch { /* best-effort */ }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Cross-process refresh lock (wx file next to the credentials). Returns a release function. Never throws:
|
|
144
|
+
// after the wait it proceeds unlocked, because a stuck lock must not sign anyone out.
|
|
145
|
+
async function acquireRefreshLock(file, { staleMs = 20000, waitMs = 20000 } = {}) {
|
|
146
|
+
const deadline = Date.now() + waitMs;
|
|
147
|
+
for (;;) {
|
|
148
|
+
try {
|
|
149
|
+
closeSync(openSync(file, 'wx', 0o600));
|
|
150
|
+
return () => { try { unlinkSync(file); } catch { /* already gone */ } };
|
|
151
|
+
} catch (e) {
|
|
152
|
+
if (e.code !== 'EEXIST') return () => {};
|
|
153
|
+
try {
|
|
154
|
+
if (Date.now() - statSync(file).mtimeMs > staleMs) { try { unlinkSync(file); } catch { /* raced */ } continue; }
|
|
155
|
+
} catch { continue; }
|
|
156
|
+
if (Date.now() > deadline) return () => {};
|
|
157
|
+
await new Promise(r => setTimeout(r, 25));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
97
162
|
export function createAuth(config) {
|
|
98
163
|
return {
|
|
99
164
|
async login(baseUrl, email, password, orgId) {
|
|
165
|
+
if (tokenFilePath()) {
|
|
166
|
+
throw new QuoxAuthError('QUOX_TOKEN_FILE is set: this principal runs on its issued token only and cannot log in as a user.');
|
|
167
|
+
}
|
|
100
168
|
const url = `${baseUrl}/api/auth/login`;
|
|
101
|
-
|
|
169
|
+
// client: 'cli' marks this a CLI/TUI sign-in so the server gives it the longer CLI session window.
|
|
170
|
+
const body = { email, password, client: 'cli' };
|
|
102
171
|
if (orgId) body.org_id = orgId;
|
|
103
172
|
|
|
104
173
|
const res = await fetch(url, {
|
|
@@ -129,6 +198,7 @@ export function createAuth(config) {
|
|
|
129
198
|
};
|
|
130
199
|
|
|
131
200
|
config.setAuth(authData);
|
|
201
|
+
rememberEmail(config, email);
|
|
132
202
|
return { ...data, authData };
|
|
133
203
|
},
|
|
134
204
|
|
|
@@ -175,7 +245,52 @@ export function createAuth(config) {
|
|
|
175
245
|
config.clearAuth();
|
|
176
246
|
},
|
|
177
247
|
|
|
248
|
+
/**
|
|
249
|
+
* Refresh the session. Stale-sign-in safe: serialised across processes by a lock file next to the
|
|
250
|
+
* credentials, and it adopts credentials another window or `quox login` wrote since this process
|
|
251
|
+
* loaded. A fresh token on disk is reused with no server call (so two windows never spend one
|
|
252
|
+
* rotating refresh token twice); a rejected refresh re-reads disk and retries ONCE if it changed.
|
|
253
|
+
*/
|
|
178
254
|
async refresh(baseUrl) {
|
|
255
|
+
if (tokenFilePath() || typeof config.reloadAuth !== 'function' || typeof config.refreshLockFile !== 'function') {
|
|
256
|
+
return this._refreshOnce(baseUrl);
|
|
257
|
+
}
|
|
258
|
+
const lock = await acquireRefreshLock(config.refreshLockFile());
|
|
259
|
+
try {
|
|
260
|
+
if (config.reloadAuth() && config.getAuth()?.access_token && !this.isExpired()) {
|
|
261
|
+
return { access_token: config.getAuth().access_token, reused: true };
|
|
262
|
+
}
|
|
263
|
+
const sent = config.getAuth()?.refresh_token;
|
|
264
|
+
try {
|
|
265
|
+
return await this._refreshOnce(baseUrl);
|
|
266
|
+
} catch (e) {
|
|
267
|
+
if (e?.code === 'session_expired') {
|
|
268
|
+
config.reloadAuth();
|
|
269
|
+
const now = config.getAuth();
|
|
270
|
+
if (now?.refresh_token && now.refresh_token !== sent) {
|
|
271
|
+
if (!this.isExpired()) return { access_token: now.access_token, reused: true };
|
|
272
|
+
return await this._refreshOnce(baseUrl);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
throw e;
|
|
276
|
+
}
|
|
277
|
+
} finally {
|
|
278
|
+
lock();
|
|
279
|
+
}
|
|
280
|
+
},
|
|
281
|
+
|
|
282
|
+
async _refreshOnce(baseUrl) {
|
|
283
|
+
const tokenFile = tokenFilePath();
|
|
284
|
+
if (tokenFile) {
|
|
285
|
+
const before = this._tokenFileLast;
|
|
286
|
+
const now = readTokenFile(tokenFile);
|
|
287
|
+
const stillExpired = now.expires_at && Date.now() > now.expires_at;
|
|
288
|
+
if (stillExpired || (before && now.access_token === before)) {
|
|
289
|
+
throw new QuoxAuthError(`QUOX_TOKEN_FILE ${tokenFile} has not been renewed; the owner-side helper re-mints it. Not falling back to any other credential.`);
|
|
290
|
+
}
|
|
291
|
+
this._tokenFileLast = now.access_token;
|
|
292
|
+
return { access_token: now.access_token };
|
|
293
|
+
}
|
|
179
294
|
const auth = config.getAuth();
|
|
180
295
|
|
|
181
296
|
// Resident agents authenticate via a signed challenge, not a refresh
|
|
@@ -199,14 +314,24 @@ export function createAuth(config) {
|
|
|
199
314
|
// 2026-08-24: header -> no_refresh_token, body -> invalid_refresh_token.
|
|
200
315
|
// The header is kept because it costs nothing and other deployments may
|
|
201
316
|
// read it.
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
317
|
+
// QUOXON-FW12: a timeout or network error says nothing about the token.
|
|
318
|
+
// It is TRANSIENT (tokens kept, caller backs off and retries), never
|
|
319
|
+
// "signed out". 2026-10-14 02:50: a slow auth service signed the owner out
|
|
320
|
+
// of a session the server still held active.
|
|
321
|
+
let res;
|
|
322
|
+
try {
|
|
323
|
+
res = await fetch(`${baseUrl}/api/auth/refresh`, {
|
|
324
|
+
method: 'POST',
|
|
325
|
+
headers: {
|
|
326
|
+
'Content-Type': 'application/json',
|
|
327
|
+
'Authorization': `Bearer ${auth.refresh_token}`,
|
|
328
|
+
},
|
|
329
|
+
body: JSON.stringify({ refresh_token: auth.refresh_token }),
|
|
330
|
+
signal: AbortSignal.timeout(15000),
|
|
331
|
+
});
|
|
332
|
+
} catch (e) {
|
|
333
|
+
throw new QuoxTransientAuthError(`Quox server is slow or unreachable (${e?.name === 'TimeoutError' ? 'refresh timed out' : (e?.cause?.code || e?.message || 'network error')}). Stored session KEPT; retrying.`);
|
|
334
|
+
}
|
|
210
335
|
|
|
211
336
|
let data = null;
|
|
212
337
|
try { data = await res.json(); } catch { /* non-JSON error body */ }
|
|
@@ -221,7 +346,7 @@ export function createAuth(config) {
|
|
|
221
346
|
// fault into a lost session, which is exactly how the header/body bug
|
|
222
347
|
// above cost three re-logins in one night before it was found.
|
|
223
348
|
if ((data && data.error) === 'no_refresh_token') {
|
|
224
|
-
throw new
|
|
349
|
+
throw new QuoxTransientAuthError(
|
|
225
350
|
'The auth service received no refresh token, so it could not tell whether yours is valid. '
|
|
226
351
|
+ 'Stored session KEPT. This is a client/transport fault, not an expiry — retry, and report it if it persists.'
|
|
227
352
|
);
|
|
@@ -254,18 +379,25 @@ export function createAuth(config) {
|
|
|
254
379
|
healthy = false;
|
|
255
380
|
}
|
|
256
381
|
if (!healthy) {
|
|
257
|
-
throw new
|
|
382
|
+
throw new QuoxTransientAuthError(
|
|
258
383
|
`Auth service is not healthy right now (refresh returned ${res.status} but /health did not answer OK). ` +
|
|
259
384
|
`Stored session KEPT — this is very likely a restart or deploy in progress. Retry in a moment.`
|
|
260
385
|
);
|
|
261
386
|
}
|
|
262
387
|
config.clearAuth({ refresh: true });
|
|
263
|
-
|
|
388
|
+
const dead = new QuoxAuthError(
|
|
264
389
|
`Session expired (refresh rejected: ${(data && data.error) || res.status}). Please login again.`
|
|
265
390
|
+ residentProfileHint(config, auth?.org_id),
|
|
266
391
|
);
|
|
392
|
+
// Machine-readable, so the TUI can say WHY (idle_timeout | max_session, from the server's session policy).
|
|
393
|
+
dead.code = 'session_expired';
|
|
394
|
+
// A revoke carries no `reason`; keep the error so the card can still say why (SIGNOUT-WIPE).
|
|
395
|
+
dead.reason = (data && data.reason) || ((data && data.error) === 'session_revoked' ? 'session_revoked' : null);
|
|
396
|
+
throw dead;
|
|
267
397
|
}
|
|
268
|
-
|
|
398
|
+
// 5xx / 429 / anything not a definitive 401/403: the token was never judged.
|
|
399
|
+
const ra = Number(res.headers?.get?.('retry-after'));
|
|
400
|
+
throw new QuoxTransientAuthError(`Token refresh failed (${res.status}); stored session kept. Retry, or login again if this persists.`, Number.isFinite(ra) && ra > 0 ? ra * 1000 : null);
|
|
269
401
|
}
|
|
270
402
|
|
|
271
403
|
auth.access_token = data.access_token;
|
|
@@ -279,10 +411,20 @@ export function createAuth(config) {
|
|
|
279
411
|
},
|
|
280
412
|
|
|
281
413
|
getTokens() {
|
|
414
|
+
const tokenFile = tokenFilePath();
|
|
415
|
+
if (tokenFile) {
|
|
416
|
+
const t = readTokenFile(tokenFile);
|
|
417
|
+
this._tokenFileLast = t.access_token;
|
|
418
|
+
return t;
|
|
419
|
+
}
|
|
282
420
|
return config.getAuth();
|
|
283
421
|
},
|
|
284
422
|
|
|
285
423
|
isLoggedIn() {
|
|
424
|
+
const tokenFile = tokenFilePath();
|
|
425
|
+
if (tokenFile) {
|
|
426
|
+
try { readTokenFile(tokenFile); return true; } catch { return false; }
|
|
427
|
+
}
|
|
286
428
|
// Precedence, matching client.js: a stored USER token is what we
|
|
287
429
|
// actually authenticate with, so it decides this answer. A configured
|
|
288
430
|
// QUOX_SERVICE_KEY still counts as authenticated on its own, but only
|
|
@@ -300,6 +442,15 @@ export function createAuth(config) {
|
|
|
300
442
|
},
|
|
301
443
|
|
|
302
444
|
isExpired() {
|
|
445
|
+
const tokenFile = tokenFilePath();
|
|
446
|
+
if (tokenFile) {
|
|
447
|
+
try {
|
|
448
|
+
const { expires_at } = readTokenFile(tokenFile);
|
|
449
|
+
return expires_at ? Date.now() > expires_at : false;
|
|
450
|
+
} catch {
|
|
451
|
+
return true;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
303
454
|
// A forced service key is not a locally-tracked JWT: never "expired".
|
|
304
455
|
if (process.env.QUOX_FORCE_SERVICE_KEY && process.env.QUOX_SERVICE_KEY) return false;
|
|
305
456
|
// A stored user token is the credential in play (see isLoggedIn), so
|
package/lib/core/callerSource.js
CHANGED
|
@@ -22,7 +22,7 @@ import { join, basename } from 'node:path';
|
|
|
22
22
|
export const APPROVAL_GATED_TOOLS = new Set([
|
|
23
23
|
'loop_halt', 'slot_assign', 'bob_dispatch_build', 'email_send', 'send_notification',
|
|
24
24
|
'remote_ssh', 'ssh_exec', 'db_query', 'alerts_manage', 'backup_run',
|
|
25
|
-
'keeper_backup_create', 'deploy_template',
|
|
25
|
+
'keeper_backup_create', 'deploy_template',
|
|
26
26
|
'system_admin', 'proxmox_vm_action', 'calendar_event_create',
|
|
27
27
|
]);
|
|
28
28
|
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KEEPER-REEXEC: which quox build is installed, read from files only (cheap enough to check on
|
|
3
|
+
* every keeper control poll, no git or child process). A git checkout (the dev box runs quox from
|
|
4
|
+
* a detached worktree) reports its HEAD sha; an npm install reports `v<version>`. null = unknown,
|
|
5
|
+
* and an unknown build never triggers a re-exec or a takeover.
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync, existsSync, statSync } from 'node:fs';
|
|
8
|
+
import { dirname, join, resolve, isAbsolute } from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
|
|
11
|
+
const SHA_RE = /^[0-9a-f]{40,64}$/;
|
|
12
|
+
const read = (p) => { try { return readFileSync(p, 'utf-8').trim(); } catch { return null; } };
|
|
13
|
+
|
|
14
|
+
function gitDirFor(root) {
|
|
15
|
+
const dotGit = join(root, '.git');
|
|
16
|
+
if (!existsSync(dotGit)) return null;
|
|
17
|
+
try {
|
|
18
|
+
if (statSync(dotGit).isDirectory()) return dotGit;
|
|
19
|
+
} catch { return null; }
|
|
20
|
+
const m = /^gitdir:\s*(.+)$/m.exec(read(dotGit) || '');
|
|
21
|
+
if (!m) return null;
|
|
22
|
+
return isAbsolute(m[1]) ? m[1] : resolve(root, m[1]);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function resolveRef(gitDir, ref) {
|
|
26
|
+
const loose = read(join(gitDir, ref));
|
|
27
|
+
if (loose && SHA_RE.test(loose)) return loose;
|
|
28
|
+
const commonRel = read(join(gitDir, 'commondir'));
|
|
29
|
+
const common = commonRel ? (isAbsolute(commonRel) ? commonRel : resolve(gitDir, commonRel)) : null;
|
|
30
|
+
if (common) {
|
|
31
|
+
const c = read(join(common, ref));
|
|
32
|
+
if (c && SHA_RE.test(c)) return c;
|
|
33
|
+
}
|
|
34
|
+
for (const d of [gitDir, common].filter(Boolean)) {
|
|
35
|
+
const packed = read(join(d, 'packed-refs')) || '';
|
|
36
|
+
for (const line of packed.split('\n')) {
|
|
37
|
+
const [sha, name] = line.trim().split(/\s+/);
|
|
38
|
+
if (name === ref && SHA_RE.test(sha)) return sha;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** cliDir = the CLI package dir (holds package.json); its parent may be the git root. */
|
|
45
|
+
export function readInstalledBuild(cliDir) {
|
|
46
|
+
try {
|
|
47
|
+
for (const root of [cliDir, dirname(cliDir)]) {
|
|
48
|
+
const gd = gitDirFor(root);
|
|
49
|
+
if (!gd) continue;
|
|
50
|
+
const head = read(join(gd, 'HEAD'));
|
|
51
|
+
if (!head) continue;
|
|
52
|
+
if (SHA_RE.test(head)) return head;
|
|
53
|
+
const m = /^ref:\s*(\S+)$/.exec(head);
|
|
54
|
+
if (m) { const sha = resolveRef(gd, m[1]); if (sha) return sha; }
|
|
55
|
+
}
|
|
56
|
+
const pkg = JSON.parse(readFileSync(join(cliDir, 'package.json'), 'utf-8'));
|
|
57
|
+
return pkg && pkg.version ? `v${pkg.version}` : null;
|
|
58
|
+
} catch { return null; }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The CLI package this module was loaded from (cli/lib/core -> cli). */
|
|
62
|
+
export const CLI_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
package/lib/core/client.js
CHANGED
|
@@ -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 { joinServiceUrl, isLocalServiceTarget } from './config.js';
|
|
15
16
|
|
|
16
17
|
const RETRY_STATUSES = new Set([429, 502, 503]);
|
|
17
18
|
const MAX_RETRIES = 3;
|
|
@@ -33,8 +34,9 @@ export function describeNetworkError(service, baseUrl, err) {
|
|
|
33
34
|
const code = err?.cause?.code || err?.code;
|
|
34
35
|
let msg = `Could not connect to ${service} service at ${baseUrl}: ${err.message}`;
|
|
35
36
|
if (code && TLS_TRUST_CODES.has(code)) {
|
|
36
|
-
msg += `\n TLS: certificate not trusted (${code}).
|
|
37
|
-
+ `\n
|
|
37
|
+
msg += `\n TLS: certificate not trusted (${code}). A new QuoxCORE uses its own self-signed certificate; trust that file`
|
|
38
|
+
+ `\n (on the server, by default ~/quox-install/quox/ssl/quox.crt; copy it here if the server is another machine):`
|
|
39
|
+
+ `\n export NODE_EXTRA_CA_CERTS=<path to quox.crt>`
|
|
38
40
|
+ `\n Never disable TLS verification (NODE_TLS_REJECT_UNAUTHORIZED=0) instead.`;
|
|
39
41
|
} else if (code === 'ERR_SSL_WRONG_VERSION_NUMBER' || code === 'EPROTO') {
|
|
40
42
|
// quox#532 defect 1: the profile says https:// but the service answered
|
|
@@ -113,6 +115,14 @@ export function createClient(config, auth) {
|
|
|
113
115
|
// never tried again for the rest of its life). A cooldown keeps the
|
|
114
116
|
// don't-hammer-auth property without the death sentence.
|
|
115
117
|
const REFRESH_RETRY_COOLDOWN_MS = 60 * 1000;
|
|
118
|
+
// quox#1375: a profile with nothing to refresh with (the collector's quox_cli hop writes only the caller's access
|
|
119
|
+
// token) must surface the route's own 401, not "Session expired... No refresh token", which hid the real cause.
|
|
120
|
+
// A token-file principal re-mints its own file and a resident re-auths by signed challenge, so both still refresh.
|
|
121
|
+
const canRefresh = () => {
|
|
122
|
+
if (process.env.QUOX_TOKEN_FILE) return true;
|
|
123
|
+
const a = typeof config.getAuth === 'function' ? config.getAuth() : null;
|
|
124
|
+
return !!(a && (a.refresh_token || a.kind === 'resident'));
|
|
125
|
+
};
|
|
116
126
|
let refreshFailedAt = 0;
|
|
117
127
|
const correlationId = generateCorrelationId();
|
|
118
128
|
|
|
@@ -128,10 +138,13 @@ export function createClient(config, auth) {
|
|
|
128
138
|
noAuth = false,
|
|
129
139
|
noActAs = false,
|
|
130
140
|
sensitive = false,
|
|
141
|
+
// HYPERION #23: an outside-caller request (trigger webhook-fire) also drops the caller labels
|
|
142
|
+
// (X-Quox-Session / X-Quox-Profile): the receiving route may store every header in an org-readable record.
|
|
143
|
+
anonymous = false,
|
|
131
144
|
} = options;
|
|
132
145
|
|
|
133
146
|
const baseUrl = config.getServiceUrl(service);
|
|
134
|
-
const url =
|
|
147
|
+
const url = joinServiceUrl(baseUrl, path);
|
|
135
148
|
|
|
136
149
|
if (query) {
|
|
137
150
|
for (const [key, value] of Object.entries(query)) {
|
|
@@ -142,7 +155,7 @@ export function createClient(config, auth) {
|
|
|
142
155
|
const fetchHeaders = {
|
|
143
156
|
'X-Quox-Correlation': correlationId,
|
|
144
157
|
'X-Quox-Client': 'cli',
|
|
145
|
-
...callerContextHeaders(config),
|
|
158
|
+
...(anonymous ? {} : callerContextHeaders(config)),
|
|
146
159
|
...extraHeaders,
|
|
147
160
|
};
|
|
148
161
|
|
|
@@ -177,7 +190,7 @@ export function createClient(config, auth) {
|
|
|
177
190
|
let usingServiceKey = false;
|
|
178
191
|
// quox#772: opt-in acting identity (--as / QUOX_AS). Replaces the bearer
|
|
179
192
|
// with the agent's attach token; throws (no request sent) if not attached.
|
|
180
|
-
const acting = !noAuth && !noActAs ? await resolveActing() : { name: null };
|
|
193
|
+
const acting = !noAuth && !noActAs ? await resolveActing({ explicitProfile: Boolean(config._explicitProfile) }) : { name: null };
|
|
181
194
|
const actingAs = acting.name;
|
|
182
195
|
if (actingAs) {
|
|
183
196
|
const heartbeat = {
|
|
@@ -195,7 +208,10 @@ export function createClient(config, auth) {
|
|
|
195
208
|
usingServiceKey = true;
|
|
196
209
|
} else if (tokens?.access_token) {
|
|
197
210
|
fetchHeaders['Authorization'] = `Bearer ${tokens.access_token}`;
|
|
198
|
-
} else if (serviceKey) {
|
|
211
|
+
} else if (serviceKey && isLocalServiceTarget(url.href)) {
|
|
212
|
+
// QUOXON-Z2H lane R: the implicit fallback is THIS box's key; never
|
|
213
|
+
// offer it to another host (a remote profile with no login). A caller
|
|
214
|
+
// that owns a remote key says so with QUOX_FORCE_SERVICE_KEY.
|
|
199
215
|
fetchHeaders['X-Service-Key'] = serviceKey;
|
|
200
216
|
usingServiceKey = true;
|
|
201
217
|
}
|
|
@@ -218,6 +234,10 @@ export function createClient(config, auth) {
|
|
|
218
234
|
}
|
|
219
235
|
|
|
220
236
|
let lastError;
|
|
237
|
+
let staleReloaded = false;
|
|
238
|
+
// REL-424: one refresh per request, on whichever attempt the 401 lands. A token that expires while the
|
|
239
|
+
// client sleeps between 503 retries gets its 401 on attempt 1+, and gating refresh on attempt 0 surfaced it.
|
|
240
|
+
let refreshedThisRequest = false;
|
|
221
241
|
|
|
222
242
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
223
243
|
try {
|
|
@@ -255,10 +275,32 @@ export function createClient(config, auth) {
|
|
|
255
275
|
// profile over what is really just "wrong token for this route".
|
|
256
276
|
// In that case, skip the refresh and fall through to the normal
|
|
257
277
|
// error handling below so the original 401/403 is surfaced as-is.
|
|
258
|
-
|
|
278
|
+
let looksExpired = typeof auth.isExpired === 'function' ? auth.isExpired() : true;
|
|
279
|
+
// The SERVER saying "invalid or expired token" outranks the local clock. expires_at is
|
|
280
|
+
// stamped after the refresh response lands, so for a moment the server rejects a token
|
|
281
|
+
// the client still thinks is good. 2026-10-14 07:32:03: three windows hit that gap
|
|
282
|
+
// together, skipped the refresh and declared the profile signed out. Refresh is safe
|
|
283
|
+
// here: a dead refresh token is still judged by auth.refresh() itself.
|
|
284
|
+
if (res.status === 401 && !looksExpired && !noAuth && !actingAs && !usingServiceKey) {
|
|
285
|
+
try { looksExpired = (await res.clone().json())?.error === 'invalid_token'; } catch { /* non-JSON body */ }
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Stale sign-in: a sibling window or `quox login` may have written a newer token for this profile.
|
|
289
|
+
// Adopt it and retry this request ONCE before any refresh (which would spend our stale refresh token).
|
|
290
|
+
if (res.status === 401 && !noAuth && !actingAs && !usingServiceKey && !staleReloaded
|
|
291
|
+
&& typeof config.reloadAuth === 'function' && config.reloadAuth()) {
|
|
292
|
+
staleReloaded = true;
|
|
293
|
+
const fresh = auth.getTokens?.();
|
|
294
|
+
if (fresh?.access_token) {
|
|
295
|
+
fetchHeaders['Authorization'] = `Bearer ${fresh.access_token}`;
|
|
296
|
+
attempt--; // this retry is free: it does not spend the caller's retry budget
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
259
300
|
|
|
260
301
|
const refreshCoolingDown = refreshFailedAt && (Date.now() - refreshFailedAt < REFRESH_RETRY_COOLDOWN_MS);
|
|
261
|
-
if (res.status === 401 && !noAuth && !actingAs &&
|
|
302
|
+
if (res.status === 401 && !noAuth && !actingAs && !refreshedThisRequest && !refreshCoolingDown && looksExpired && canRefresh()) {
|
|
303
|
+
refreshedThisRequest = true;
|
|
262
304
|
try {
|
|
263
305
|
if (!refreshing) {
|
|
264
306
|
const serviceUrl = config.getServiceUrl('auth');
|
|
@@ -288,16 +330,25 @@ export function createClient(config, auth) {
|
|
|
288
330
|
if (refreshErr && refreshErr.transient) {
|
|
289
331
|
// Auth is only temporarily unavailable (429 / restart): no
|
|
290
332
|
// cooldown, and keep the type so a listener can back off.
|
|
291
|
-
throw new QuoxTransientAuthError(`
|
|
333
|
+
throw new QuoxTransientAuthError(`Quox server is slow or unreachable, retrying. ${refreshErr.message}`, refreshErr.retryAfterMs ?? null);
|
|
292
334
|
}
|
|
293
335
|
refreshFailedAt = Date.now();
|
|
294
336
|
const why = refreshErr?.message ? ` ${refreshErr.message}` : '';
|
|
295
|
-
|
|
337
|
+
const dead = new QuoxAuthError(`Session expired. Run \`quox login\` to re-authenticate.${why}`);
|
|
338
|
+
if (refreshErr?.code) dead.code = refreshErr.code;
|
|
339
|
+
if (refreshErr?.reason) dead.reason = refreshErr.reason;
|
|
340
|
+
throw dead;
|
|
296
341
|
}
|
|
297
342
|
}
|
|
298
343
|
|
|
299
344
|
// Retry transient errors
|
|
345
|
+
// quox#882: the server can say a failure won't clear by itself (a downstream refusing
|
|
346
|
+
// connections): body retryable:false skips the backoff retries.
|
|
347
|
+
let noRetry = false;
|
|
300
348
|
if (RETRY_STATUSES.has(res.status) && attempt < retries) {
|
|
349
|
+
try { noRetry = (await res.clone().json())?.retryable === false; } catch { /* not JSON */ }
|
|
350
|
+
}
|
|
351
|
+
if (RETRY_STATUSES.has(res.status) && attempt < retries && !noRetry) {
|
|
301
352
|
const delay = RETRY_DELAYS[attempt] || 9000;
|
|
302
353
|
if (isDebug) {
|
|
303
354
|
console.error(` Retrying in ${delay}ms (attempt ${attempt + 1}/${retries})...`);
|
|
@@ -315,7 +366,8 @@ export function createClient(config, auth) {
|
|
|
315
366
|
try {
|
|
316
367
|
data = await res.json();
|
|
317
368
|
} catch {
|
|
318
|
-
|
|
369
|
+
// CLI-ERRORS: a proxy error page, not a Quox body; named so the renderer can say so
|
|
370
|
+
data = { error: 'non_json_response', message: res.statusText };
|
|
319
371
|
}
|
|
320
372
|
// A permission error while we silently fell back to the SERVICE
|
|
321
373
|
// KEY is almost never a permissions problem: the service key
|
|
@@ -327,7 +379,10 @@ export function createClient(config, auth) {
|
|
|
327
379
|
// theory about who could grant it. He had held instance-admin
|
|
328
380
|
// since March. His session had simply expired and the CLI had
|
|
329
381
|
// quietly downgraded the request to a key with no user attached.
|
|
330
|
-
|
|
382
|
+
// CLI-ERRORS: a body with only a prose `error` ("Agent not found") leads with that, never
|
|
383
|
+
// "Request failed with status N" (the real reason used to sit on the dim Status line).
|
|
384
|
+
const proseError = !data.message && typeof data.error === 'string' && /\s/.test(data.error);
|
|
385
|
+
let message = data.message || (proseError ? data.error : `Request failed with status ${res.status}`);
|
|
331
386
|
// quox#617: surface the server's machine-readable `reason`. A vault
|
|
332
387
|
// update returns 403 with `reason: 'mfa_required'` for an MFA-enabled
|
|
333
388
|
// owner (a step-up gate) vs `reason: 'role_<r>_lacks_capability_<cap>'`
|
|
@@ -344,18 +399,38 @@ export function createClient(config, auth) {
|
|
|
344
399
|
+ `The service key has no user identity, so anything gated on WHO you are will refuse it regardless of your real permissions. `
|
|
345
400
|
+ `Run \`quox login\` (or \`quox -p <profile> login\`) and retry before treating this as a permissions problem.`;
|
|
346
401
|
}
|
|
402
|
+
// #1012: the paid backends (QuoxPlan, QuoxSEO, QLarity, QuoxSignal) are not part of the public
|
|
403
|
+
// install, so a licensed customer got a bare 502 from the collector proxy. This client cannot
|
|
404
|
+
// tell "not installed" from "installed but down", so it says both, and exits 1 (not 8).
|
|
405
|
+
const pluginDown = (res.status === 502 || res.status === 503) && typeof data.error === 'string'
|
|
406
|
+
&& /^(QuoxPlan|QuoxSEO|QLarity|QuoxSignal) service unavailable$/i.exec(data.error);
|
|
407
|
+
if (pluginDown) {
|
|
408
|
+
const err = new QuoxApiError(
|
|
409
|
+
res.status,
|
|
410
|
+
'PLUGIN_SERVICE_UNAVAILABLE',
|
|
411
|
+
`${pluginDown[1]} isn't reachable on this server. It isn't part of the public install (it ships separately), `
|
|
412
|
+
+ 'or it is installed but down: run `quox health`. Nothing was changed.',
|
|
413
|
+
data.detail || data.details,
|
|
414
|
+
data
|
|
415
|
+
);
|
|
416
|
+
err.exitCode = 1;
|
|
417
|
+
throw err;
|
|
418
|
+
}
|
|
347
419
|
// LL-3d: carry the WHOLE body, not just message/detail. Endpoints
|
|
348
420
|
// that answer with structured context (the licensing 409's
|
|
349
421
|
// bound_instance_name / bound[] / max_servers, the 402's plugin)
|
|
350
422
|
// had it flattened away here, so command handlers could only print
|
|
351
423
|
// "Request failed with status 409".
|
|
352
|
-
|
|
424
|
+
const apiErr = new QuoxApiError(
|
|
353
425
|
res.status,
|
|
354
426
|
data.error || 'api_error',
|
|
355
427
|
message,
|
|
356
428
|
data.detail || data.details,
|
|
357
429
|
data
|
|
358
430
|
);
|
|
431
|
+
if (proseError) apiErr.reasonFromError = true;
|
|
432
|
+
if (usingServiceKey && (res.status === 403 || res.status === 401)) apiErr.serviceKeyFallback = true;
|
|
433
|
+
throw apiErr;
|
|
359
434
|
}
|
|
360
435
|
|
|
361
436
|
// Handle empty responses (204 No Content)
|
|
@@ -431,7 +506,7 @@ export function createClient(config, auth) {
|
|
|
431
506
|
} = options;
|
|
432
507
|
|
|
433
508
|
const baseUrl = config.getServiceUrl(service);
|
|
434
|
-
const url =
|
|
509
|
+
const url = joinServiceUrl(baseUrl, path);
|
|
435
510
|
|
|
436
511
|
if (query) {
|
|
437
512
|
for (const [key, value] of Object.entries(query)) {
|
|
@@ -449,7 +524,7 @@ export function createClient(config, auth) {
|
|
|
449
524
|
// Same precedence as request() above, including the QUOX_FORCE_SERVICE_KEY
|
|
450
525
|
// override for daemons that own their key and must not borrow the shared
|
|
451
526
|
// config's stored session.
|
|
452
|
-
const streamActingR = await resolveActing();
|
|
527
|
+
const streamActingR = await resolveActing({ explicitProfile: Boolean(config._explicitProfile) });
|
|
453
528
|
const streamActing = streamActingR.name;
|
|
454
529
|
const streamTokens = auth.getTokens();
|
|
455
530
|
let streamServiceKey = null;
|
|
@@ -462,7 +537,7 @@ export function createClient(config, auth) {
|
|
|
462
537
|
fetchHeaders['X-Service-Key'] = streamServiceKey;
|
|
463
538
|
} else if (streamTokens?.access_token) {
|
|
464
539
|
fetchHeaders['Authorization'] = `Bearer ${streamTokens.access_token}`;
|
|
465
|
-
} else if (process.env.QUOX_SERVICE_KEY) {
|
|
540
|
+
} else if (process.env.QUOX_SERVICE_KEY && isLocalServiceTarget(url.href)) {
|
|
466
541
|
streamServiceKey = process.env.QUOX_SERVICE_KEY;
|
|
467
542
|
fetchHeaders['X-Service-Key'] = streamServiceKey;
|
|
468
543
|
}
|
|
@@ -472,22 +547,61 @@ export function createClient(config, auth) {
|
|
|
472
547
|
console.error(`-> ${streamBody !== undefined ? 'POST' : 'GET'}(stream) ${url.toString()}`);
|
|
473
548
|
}
|
|
474
549
|
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
550
|
+
const openStream = async () => {
|
|
551
|
+
try {
|
|
552
|
+
return await fetch(url.toString(), {
|
|
553
|
+
method: streamBody !== undefined ? 'POST' : 'GET',
|
|
554
|
+
headers: fetchHeaders,
|
|
555
|
+
...(streamBody !== undefined ? { body: JSON.stringify(streamBody) } : {}),
|
|
556
|
+
signal: externalSignal,
|
|
557
|
+
// Same redirect protection as the non-streaming path above: never let
|
|
558
|
+
// a 3xx resend X-Service-Key to a host we did not choose.
|
|
559
|
+
...(streamServiceKey ? { redirect: 'manual' } : {}),
|
|
560
|
+
});
|
|
561
|
+
} catch (err) {
|
|
562
|
+
throw new QuoxNetworkError(
|
|
563
|
+
describeNetworkError(service, baseUrl, err),
|
|
564
|
+
err
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
};
|
|
568
|
+
let res = await openStream();
|
|
569
|
+
|
|
570
|
+
// 2026-10-16: request() refreshed an expired session on a 401, the stream (the chat path) did not,
|
|
571
|
+
// so a chat turn right after the access token expired read "Not signed in" on a signed-in profile.
|
|
572
|
+
// Same rule as request(): one refresh for a user token that looks expired (locally, or the server
|
|
573
|
+
// says invalid_token), shared with any refresh already in flight, then one retry.
|
|
574
|
+
const usesUserToken = !streamActing && !streamServiceKey && !!streamTokens?.access_token;
|
|
575
|
+
if (res.status === 401 && usesUserToken && typeof config.reloadAuth === 'function' && config.reloadAuth()) {
|
|
576
|
+
// Stale sign-in: a sibling window or `quox login` wrote a newer token. Adopt it, retry once.
|
|
577
|
+
const fresh = auth.getTokens?.();
|
|
578
|
+
if (fresh?.access_token) { fetchHeaders['Authorization'] = `Bearer ${fresh.access_token}`; res = await openStream(); }
|
|
579
|
+
}
|
|
580
|
+
if (res.status === 401 && usesUserToken) {
|
|
581
|
+
let looksExpired = typeof auth.isExpired === 'function' ? auth.isExpired() : true;
|
|
582
|
+
if (!looksExpired) {
|
|
583
|
+
try { looksExpired = (await res.clone().json())?.error === 'invalid_token'; } catch { /* non-JSON body */ }
|
|
584
|
+
}
|
|
585
|
+
const refreshCoolingDown = refreshFailedAt && (Date.now() - refreshFailedAt < REFRESH_RETRY_COOLDOWN_MS);
|
|
586
|
+
if (looksExpired && !refreshCoolingDown && canRefresh()) {
|
|
587
|
+
try {
|
|
588
|
+
if (!refreshing) {
|
|
589
|
+
refreshing = auth.refresh(config.getServiceUrl('auth')).finally(() => { refreshing = null; });
|
|
590
|
+
}
|
|
591
|
+
await refreshing;
|
|
592
|
+
} catch (refreshErr) {
|
|
593
|
+
if (refreshErr && refreshErr.transient) {
|
|
594
|
+
throw new QuoxTransientAuthError(`Quox server is slow or unreachable, retry shortly. ${refreshErr.message}`, refreshErr.retryAfterMs ?? null);
|
|
595
|
+
}
|
|
596
|
+
refreshFailedAt = Date.now();
|
|
597
|
+
const dead = new QuoxAuthError(`Session expired. Run \`quox login\` to re-authenticate.${refreshErr?.message ? ` ${refreshErr.message}` : ''}`);
|
|
598
|
+
dead.status = 401;
|
|
599
|
+
throw dead;
|
|
600
|
+
}
|
|
601
|
+
const fresh = auth.getTokens();
|
|
602
|
+
if (fresh?.access_token) fetchHeaders['Authorization'] = `Bearer ${fresh.access_token}`;
|
|
603
|
+
res = await openStream();
|
|
604
|
+
}
|
|
491
605
|
}
|
|
492
606
|
|
|
493
607
|
if (!res.ok) {
|
|
@@ -495,15 +609,18 @@ export function createClient(config, auth) {
|
|
|
495
609
|
try {
|
|
496
610
|
data = await res.json();
|
|
497
611
|
} catch {
|
|
498
|
-
data = { error: '
|
|
612
|
+
data = { error: 'non_json_response', message: res.statusText };
|
|
499
613
|
}
|
|
500
|
-
|
|
614
|
+
const proseError = !data.message && typeof data.error === 'string' && /\s/.test(data.error);
|
|
615
|
+
const streamErr = new QuoxApiError(
|
|
501
616
|
res.status,
|
|
502
617
|
data.error || 'api_error',
|
|
503
|
-
data.message || `Stream request failed with status ${res.status}
|
|
618
|
+
data.message || (proseError ? data.error : `Stream request failed with status ${res.status}`),
|
|
504
619
|
data.detail || data.details,
|
|
505
620
|
data
|
|
506
621
|
);
|
|
622
|
+
if (proseError) streamErr.reasonFromError = true;
|
|
623
|
+
throw streamErr;
|
|
507
624
|
}
|
|
508
625
|
|
|
509
626
|
if (!res.body) {
|