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