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
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ONBOARD-P1: per-profile trust for a server's own certificate (`quox connect`).
|
|
3
|
+
*
|
|
4
|
+
* A new QuoxCORE serves a self-signed edge certificate. The sanctioned fix is to trust THAT certificate, for the
|
|
5
|
+
* profile that talks to that server, and nowhere else. This module:
|
|
6
|
+
* - reads the certificate chain a server presents (a handshake only: nothing is ever sent over it, the
|
|
7
|
+
* certificate is shown to the user and trusted only after they say yes),
|
|
8
|
+
* - refuses what trusting cannot fix (expired, wrong name),
|
|
9
|
+
* - stores the PEM under ~/.config/quox/certs/<profile>.pem and records it on the profile as `caCert`,
|
|
10
|
+
* - makes later requests for that profile trust it: bin/quox.js re-runs itself once with NODE_EXTRA_CA_CERTS
|
|
11
|
+
* set to the profile's file (Node 20 has no per-request CA for the built-in fetch). The global environment
|
|
12
|
+
* is never touched, and TLS verification is never turned off.
|
|
13
|
+
*/
|
|
14
|
+
import tls from 'node:tls';
|
|
15
|
+
import https from 'node:https';
|
|
16
|
+
import { createHash, X509Certificate } from 'node:crypto';
|
|
17
|
+
import { existsSync, mkdirSync, writeFileSync, chmodSync, readFileSync, unlinkSync } from 'node:fs';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
import { spawnSync } from 'node:child_process';
|
|
20
|
+
import { getConfigDir, getConfigFile, resolveProfileFlagFromArgv } from './config.js';
|
|
21
|
+
|
|
22
|
+
const CODES_TRUST_CAN_FIX = new Set(['DEPTH_ZERO_SELF_SIGNED_CERT', 'SELF_SIGNED_CERT_IN_CHAIN', 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', 'UNABLE_TO_GET_ISSUER_CERT', 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY']);
|
|
23
|
+
export const isTrustFixable = (code) => CODES_TRUST_CAN_FIX.has(code);
|
|
24
|
+
|
|
25
|
+
const TLS_SENTENCE_CODES = new Set([...CODES_TRUST_CAN_FIX, 'CERT_HAS_EXPIRED', 'ERR_TLS_CERT_ALTNAME_INVALID']);
|
|
26
|
+
/**
|
|
27
|
+
* H4: one plain sentence for a TLS certificate failure, with the fix. undici reports all of these as a bare
|
|
28
|
+
* "fetch failed". -> null when `code` is not a certificate-trust code.
|
|
29
|
+
* A self-signed or unknown-issuer certificate is fixed by trusting it for one profile; an expired or wrong-name
|
|
30
|
+
* certificate is NOT (trusting a file would not cure it), so those say what does.
|
|
31
|
+
*/
|
|
32
|
+
export function tlsFailureSentence(code, url) {
|
|
33
|
+
if (!TLS_SENTENCE_CODES.has(code)) return null;
|
|
34
|
+
if (code === 'CERT_HAS_EXPIRED') return `${url} presents a certificate that has expired (${code}). Trusting it would not help: the server needs a new certificate.`;
|
|
35
|
+
if (code === 'ERR_TLS_CERT_ALTNAME_INVALID') return `${url} presents a certificate that does not name the address you used (${code}). Use an address the certificate names; trusting it would not help.`;
|
|
36
|
+
return `${url} presents a certificate this machine does not trust: it is self-signed or its issuer is unknown (${code}). If this is your own server, check its fingerprint and trust it for one profile only: quox connect ${url} --trust-cert`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const certDir = () => join(getConfigDir(), 'certs');
|
|
40
|
+
const safeName = (profile) => String(profile).replace(/[^A-Za-z0-9._-]/g, '_').replace(/\.{2,}/g, '_');
|
|
41
|
+
export const certPathFor = (profile) => join(certDir(), `${safeName(profile)}.pem`);
|
|
42
|
+
|
|
43
|
+
/** A short, filesystem- and shell-safe profile name derived from a server URL (203.0.113.10:3000 -> 203-0-113-10). */
|
|
44
|
+
export function profileNameForUrl(url) {
|
|
45
|
+
try { return new URL(url).hostname.replace(/[^A-Za-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'server'; } catch { return 'server'; }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const pemOf = (der) => `-----BEGIN CERTIFICATE-----\n${der.toString('base64').match(/.{1,64}/g).join('\n')}\n-----END CERTIFICATE-----\n`;
|
|
49
|
+
export const fingerprint256 = (der) => createHash('sha256').update(der).digest('hex').toUpperCase().match(/../g).join(':');
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Read the certificate chain `url` presents. -> { host, port, chain: [{ subject, issuer, validFrom, validTo, san,
|
|
53
|
+
* fingerprint256, pem, isCA, selfSigned }], leaf } . Throws a plain Error when there is no TLS answer.
|
|
54
|
+
* The handshake runs with chain validation off ONLY so the certificate can be read; the socket is destroyed
|
|
55
|
+
* before any byte of application data, so nothing is trusted and nothing is sent.
|
|
56
|
+
*/
|
|
57
|
+
export function readPeerChain(url, { timeout = 5000, connect = tls.connect } = {}) {
|
|
58
|
+
const u = new URL(url);
|
|
59
|
+
if (u.protocol !== 'https:') return Promise.reject(new Error('the address is not https://, so there is no certificate to trust'));
|
|
60
|
+
const host = u.hostname;
|
|
61
|
+
const port = Number(u.port || 443);
|
|
62
|
+
return new Promise((resolve, reject) => {
|
|
63
|
+
const isIp = /^[\d.]+$/.test(host) || host.includes(':');
|
|
64
|
+
const sock = connect({ host, port, servername: isIp ? undefined : host, rejectUnauthorized: false, timeout }, () => {
|
|
65
|
+
try {
|
|
66
|
+
const peer = sock.getPeerCertificate(true);
|
|
67
|
+
sock.destroy();
|
|
68
|
+
if (!peer || !peer.raw) return reject(new Error('the server presented no certificate'));
|
|
69
|
+
const seen = new Set();
|
|
70
|
+
const chain = [];
|
|
71
|
+
for (let c = peer; c && c.raw && !seen.has(c.fingerprint256); c = c.issuerCertificate) {
|
|
72
|
+
seen.add(c.fingerprint256);
|
|
73
|
+
let isCA = false;
|
|
74
|
+
try { isCA = Boolean(new X509Certificate(c.raw).ca); } catch { /* unreadable: not a CA */ }
|
|
75
|
+
const fp = fingerprint256(c.raw);
|
|
76
|
+
chain.push({
|
|
77
|
+
subject: c.subject?.CN || Object.values(c.subject || {}).join(' ') || '(no subject)',
|
|
78
|
+
issuer: c.issuer?.CN || Object.values(c.issuer || {}).join(' ') || '(no issuer)',
|
|
79
|
+
validFrom: c.valid_from, validTo: c.valid_to, san: c.subjectaltname || '',
|
|
80
|
+
fingerprint256: fp, pem: pemOf(c.raw), isCA,
|
|
81
|
+
selfSigned: JSON.stringify(c.subject) === JSON.stringify(c.issuer)
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
resolve({ host, port, chain, leaf: peer });
|
|
85
|
+
} catch (e) { sock.destroy(); reject(e); }
|
|
86
|
+
});
|
|
87
|
+
sock.on('error', (e) => reject(Object.assign(new Error(e.message), { code: e.code })));
|
|
88
|
+
sock.on('timeout', () => { sock.destroy(); reject(Object.assign(new Error(`no answer within ${Math.round(timeout / 1000)} s`), { code: 'TIMEOUT' })); });
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Trusting a certificate only fixes an untrusted-but-valid one. -> null when fine, else the reason it cannot be trusted. */
|
|
93
|
+
export function untrustableReason(peer, host, { now = Date.now(), checkServerIdentity = tls.checkServerIdentity } = {}) {
|
|
94
|
+
const from = Date.parse(peer.leaf?.valid_from), to = Date.parse(peer.leaf?.valid_to);
|
|
95
|
+
if (Number.isFinite(to) && to < now) return `its certificate expired on ${peer.leaf.valid_to}. Trusting it would not help; the server needs a new certificate.`;
|
|
96
|
+
if (Number.isFinite(from) && from > now) return `its certificate is not valid until ${peer.leaf.valid_from} (check this machine's clock).`;
|
|
97
|
+
const err = checkServerIdentity(host, peer.leaf);
|
|
98
|
+
if (err) return `its certificate does not name ${host} (${(peer.leaf?.subjectaltname || 'no names').replace(/IP Address:/g, '')}). Trusting it would not help; use an address the certificate names.`;
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The card the user compares with `quox server status` on the server. */
|
|
103
|
+
export function certCard(url, peer) {
|
|
104
|
+
const leaf = peer.chain[0];
|
|
105
|
+
const lines = [
|
|
106
|
+
`${url} presents this certificate:`,
|
|
107
|
+
` Subject ${leaf.subject}`,
|
|
108
|
+
` Issuer ${leaf.selfSigned ? `${leaf.issuer} (self-signed)` : leaf.issuer}`,
|
|
109
|
+
` Names ${leaf.san.replace(/IP Address:/g, 'IP:') || '(none listed)'}`,
|
|
110
|
+
` Valid ${leaf.validFrom} to ${leaf.validTo}`,
|
|
111
|
+
` SHA256 ${leaf.fingerprint256}`
|
|
112
|
+
];
|
|
113
|
+
for (const c of peer.chain.slice(1)) lines.push(` Signed by ${c.subject} SHA256 ${c.fingerprint256}`);
|
|
114
|
+
lines.push('Compare the SHA256 with the one on the server (run `quox server status` there) before you trust it.');
|
|
115
|
+
return lines.join('\n');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Write the chain's PEM for `profile` (0600 file in a 0700 directory). -> the path. */
|
|
119
|
+
export function storeProfileCert(profile, peer) {
|
|
120
|
+
const dir = certDir();
|
|
121
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
122
|
+
try { chmodSync(dir, 0o700); } catch { /* best effort */ }
|
|
123
|
+
const file = certPathFor(profile);
|
|
124
|
+
writeFileSync(file, peer.chain.map((c) => c.pem).join(''), { mode: 0o600 });
|
|
125
|
+
try { chmodSync(file, 0o600); } catch { /* best effort */ }
|
|
126
|
+
return file;
|
|
127
|
+
}
|
|
128
|
+
export function removeProfileCert(profile) { try { unlinkSync(certPathFor(profile)); } catch { /* not there */ } }
|
|
129
|
+
|
|
130
|
+
/** A fetch-shaped function that trusts exactly `ca` (a PEM string) for this one request. For probeQuoxCore. */
|
|
131
|
+
export function fetchTrusting(ca, { request = https.request } = {}) {
|
|
132
|
+
return (target, { signal, headers } = {}) => new Promise((resolve, reject) => {
|
|
133
|
+
const req = request(target, { ca, headers, signal, method: 'GET' }, (res) => {
|
|
134
|
+
const chunks = [];
|
|
135
|
+
res.on('data', (d) => chunks.push(d));
|
|
136
|
+
res.on('end', () => {
|
|
137
|
+
const body = Buffer.concat(chunks).toString('utf8');
|
|
138
|
+
resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, text: async () => body });
|
|
139
|
+
});
|
|
140
|
+
res.on('error', reject);
|
|
141
|
+
});
|
|
142
|
+
req.on('error', reject);
|
|
143
|
+
req.end();
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The CA file recorded on `profileName` in a parsed config object, if it exists on disk. */
|
|
148
|
+
export function profileCaPath(configData, profileName, { exists = existsSync } = {}) {
|
|
149
|
+
const f = configData?.profiles?.[profileName]?.caCert;
|
|
150
|
+
return typeof f === 'string' && f && exists(f) ? f : null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* bin/quox.js calls this first. When the profile this run uses has a stored server certificate (and the user has
|
|
155
|
+
* not set their own NODE_EXTRA_CA_CERTS), re-run the same command with NODE_EXTRA_CA_CERTS set to it, for this
|
|
156
|
+
* process tree only. -> an exit code when it ran the command, or null (carry on in this process).
|
|
157
|
+
*/
|
|
158
|
+
export function reexecWithProfileTrust(argv, env = process.env, {
|
|
159
|
+
readConfig = () => JSON.parse(readFileSync(getConfigFile(), 'utf8')),
|
|
160
|
+
spawn = spawnSync, exists = existsSync, execPath = process.execPath, execArgv = process.execArgv, script = process.argv[1]
|
|
161
|
+
} = {}) {
|
|
162
|
+
// A NODE_EXTRA_CA_CERTS the user set themselves always wins. One WE set (QUOX_PROFILE_TRUST_APPLIED names the file)
|
|
163
|
+
// belongs to the profile that set it: a nested `quox -p other` must not inherit it.
|
|
164
|
+
const ours = env.QUOX_PROFILE_TRUST_APPLIED && env.NODE_EXTRA_CA_CERTS === env.QUOX_PROFILE_TRUST_APPLIED;
|
|
165
|
+
if (env.NODE_EXTRA_CA_CERTS && !ours) return null;
|
|
166
|
+
let data;
|
|
167
|
+
try { data = readConfig(); } catch { return null; }
|
|
168
|
+
const name = resolveProfileFlagFromArgv(argv) || env.QUOX_PROFILE || data?.activeProfile;
|
|
169
|
+
const ca = name ? profileCaPath(data, name, { exists }) : null;
|
|
170
|
+
if (ours ? ca === env.NODE_EXTRA_CA_CERTS : !ca) return null; // already right, or nothing to apply
|
|
171
|
+
const childEnv = { ...env };
|
|
172
|
+
if (ca) { childEnv.NODE_EXTRA_CA_CERTS = ca; childEnv.QUOX_PROFILE_TRUST_APPLIED = ca; }
|
|
173
|
+
else { delete childEnv.NODE_EXTRA_CA_CERTS; delete childEnv.QUOX_PROFILE_TRUST_APPLIED; }
|
|
174
|
+
// Ctrl-C reaches the whole foreground group; the child handles it, this parent must outlive it to pass on the code.
|
|
175
|
+
const hold = () => {};
|
|
176
|
+
process.on('SIGINT', hold);
|
|
177
|
+
try {
|
|
178
|
+
const r = spawn(execPath, [...execArgv, script, ...argv], { stdio: 'inherit', env: childEnv });
|
|
179
|
+
if (typeof r.status === 'number') return r.status;
|
|
180
|
+
return r.signal === 'SIGINT' ? 130 : 1;
|
|
181
|
+
} finally { process.removeListener('SIGINT', hold); }
|
|
182
|
+
}
|
package/lib/core/quoxglyph.js
CHANGED
|
@@ -30,6 +30,7 @@ export const ICONS = {
|
|
|
30
30
|
quoxseo: '📈 QUOXSEO', reach: '💥 REACH', vulcan: '🌋 VULCAN',
|
|
31
31
|
quoxvoice: '📞 QUOXVOICE', quoxsecurity: '🛡️ QUOXSECURITY', quoxpert: '🎓 QUOXPERT',
|
|
32
32
|
quoxslots: '📍 QUOXSLOTS',
|
|
33
|
+
gripe: '📣 GRIPE',
|
|
33
34
|
quox: 'Ⓠ QUOX'
|
|
34
35
|
};
|
|
35
36
|
export const BUDGETS = { VERB_MAX: 48, RESULT_MAX: 40, LINE_MAX: 120, TREE_MAX: 6, TABLE_MAX: 6 };
|
|
@@ -74,7 +75,7 @@ export function line(surface, verb, opts = {}) {
|
|
|
74
75
|
const col = on();
|
|
75
76
|
const name = brandName(iconFor(surface), col);
|
|
76
77
|
const sep = col ? `${DIM}│${R}` : '│';
|
|
77
|
-
let tail = opts.result ? trunc(opts.result, BUDGETS.RESULT_MAX) : '';
|
|
78
|
+
let tail = opts.result ? trunc(opts.result, opts.max || BUDGETS.RESULT_MAX) : '';
|
|
78
79
|
if (opts.ms !== undefined) {
|
|
79
80
|
const d = col ? `${DIM}${opts.ms}ms${R}` : `${opts.ms}ms`;
|
|
80
81
|
tail = tail ? `${tail} ${col ? DIM + '·' + R : '·'} ${d}` : d;
|
|
@@ -85,6 +86,26 @@ export function line(surface, verb, opts = {}) {
|
|
|
85
86
|
return tail ? `${name} ${sep} ${v} ${sep} ${tail}` : `${name} ${sep} ${v}`;
|
|
86
87
|
}
|
|
87
88
|
|
|
89
|
+
const GRIPE_TITLE_MAX = 50;
|
|
90
|
+
const gripeTitle = (t) => trunc(String(t || '').split('\n')[0].trim(), GRIPE_TITLE_MAX);
|
|
91
|
+
|
|
92
|
+
/** GRIPE-1460: `📣 GRIPE │ filed #N owner/repo │ "title" ✔` for an issue URL (the URL itself goes on the next line). */
|
|
93
|
+
export function gripeFiledLine(url, title) {
|
|
94
|
+
const m = /github\.com\/([^/]+\/[^/]+)\/issues\/(\d+)/.exec(String(url || ''));
|
|
95
|
+
const verb = m ? `filed #${m[2]} ${m[1]}` : 'filed issue';
|
|
96
|
+
return line('gripe', verb, { result: `"${gripeTitle(title)}"`, status: 'ok', max: GRIPE_TITLE_MAX + 2 });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** GRIPE-1460: a gripe saved earlier and filed on retry: `📣 GRIPE │ queued gripe filed │ <url> ✔`. */
|
|
100
|
+
export function gripeQueuedFiledLine(url) {
|
|
101
|
+
return line('gripe', 'queued gripe filed', { result: String(url), status: 'ok', max: 200 });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** GRIPE-1460: `📣 GRIPE │ file "title" │ ✖ <exact error>`. */
|
|
105
|
+
export function gripeFailedLine(title, error) {
|
|
106
|
+
return line('gripe', `file "${trunc(gripeTitle(title), 30)}"`, { result: `${STATUS.fail.glyph} ${error}`, max: 400 });
|
|
107
|
+
}
|
|
108
|
+
|
|
88
109
|
/** Gradient progress meter: ▰▰▰▱ done/total (or pct when total omitted). */
|
|
89
110
|
export function meter(done, total, width = 8) {
|
|
90
111
|
const col = on();
|
package/lib/core/router.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* output is tested without a router.
|
|
9
9
|
*/
|
|
10
10
|
import { resolveEndpoint } from '../api/endpoints.js';
|
|
11
|
+
import { QuoxUserError } from './errors.js';
|
|
11
12
|
|
|
12
13
|
/** verb -> the tool it runs. All read-only (aoclClassification layer 1, autonomy FULL). */
|
|
13
14
|
export const ROUTER_TOOLS = Object.freeze({
|
|
@@ -21,6 +22,18 @@ export const ROUTER_TOOLS = Object.freeze({
|
|
|
21
22
|
inventory: 'mikrotik_discovery_inventory',
|
|
22
23
|
});
|
|
23
24
|
|
|
25
|
+
/** The tool route refused because this identity lacks the router grant: say that, keep the plumbing in detail. */
|
|
26
|
+
function notPermitted(tool, raw, path) {
|
|
27
|
+
const err = new QuoxUserError('This identity is not allowed to read the router: the router tools are not granted to it.', {
|
|
28
|
+
next: 'run it from an identity that has the router tools (`quox config profile list`), or ask an org owner or admin to grant them.',
|
|
29
|
+
code: 'not_permitted',
|
|
30
|
+
});
|
|
31
|
+
err.debug = { tool, route: `POST ${path}`, server_said: raw }; // --json and --verbose only; never the headline
|
|
32
|
+
err.toolError = raw;
|
|
33
|
+
err.tool = tool;
|
|
34
|
+
return err;
|
|
35
|
+
}
|
|
36
|
+
|
|
24
37
|
export function makeRouterExec({ client, orgId }) {
|
|
25
38
|
return async function exec(tool, input = {}) {
|
|
26
39
|
const ep = resolveEndpoint('v1.tools.execute');
|
|
@@ -28,6 +41,7 @@ export function makeRouterExec({ client, orgId }) {
|
|
|
28
41
|
if (orgId) body.org_id = orgId;
|
|
29
42
|
const data = await client.post(ep.path, { service: ep.service, body });
|
|
30
43
|
if (data && data.success === false) {
|
|
44
|
+
if (/not granted to agent/i.test(String(data.error || ''))) throw notPermitted(tool, data.error, ep.path);
|
|
31
45
|
const detail = data.detail ? ` (${data.detail})` : '';
|
|
32
46
|
const err = new Error(`${tool} via POST ${ep.path} returned success:false: ${data.error || 'unknown error'}${detail}`);
|
|
33
47
|
err.toolError = data.error || 'unknown error';
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* released on every exit path.
|
|
16
16
|
* Pure parts take injected tmux output / held slots / attach lookup.
|
|
17
17
|
*/
|
|
18
|
-
import {
|
|
18
|
+
import { spawnSync as realSpawnSync } from 'node:child_process';
|
|
19
19
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, statSync, appendFileSync, truncateSync } from 'node:fs';
|
|
20
20
|
import { homedir } from 'node:os';
|
|
21
21
|
import { join } from 'node:path';
|
|
@@ -70,9 +70,10 @@ export function buildBeat({ probe, backend = null, agent = null, slot = null, he
|
|
|
70
70
|
};
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
/** Live probe of one tmux session (exact match). null when it does not exist.
|
|
73
|
+
/** Live probe of one tmux session (exact match). null when it does not exist.
|
|
74
|
+
* `=name:` (session form): tmux 3.4 answers a bare `=name` with an empty line. */
|
|
74
75
|
export function probeTmux(name, spawnSync = realSpawnSync) {
|
|
75
|
-
const r = spawnSync('tmux', ['display-message', '-p', '-t', `=${name}
|
|
76
|
+
const r = spawnSync('tmux', ['display-message', '-p', '-t', `=${name}:`, PROBE_FORMAT], { encoding: 'utf8' });
|
|
76
77
|
if (!r || r.error || r.status !== 0) return null;
|
|
77
78
|
const p = parseProbe(r.stdout);
|
|
78
79
|
return p && p.name === name ? p : null;
|
|
@@ -177,16 +178,19 @@ export function fileLogger(name, dir = HB_DIR()) {
|
|
|
177
178
|
* Start the detached heartbeat child for a tmux session the wrapper launched.
|
|
178
179
|
* The child is this same quox binary; it survives the wrapper exiting on detach.
|
|
179
180
|
*/
|
|
180
|
-
|
|
181
|
+
// Spawned by a short-lived node that exits at once, so the heartbeat is never
|
|
182
|
+
// the wrapper's child: the wrapper blocks in spawnSync(tmux) for the session's
|
|
183
|
+
// life and could not reap it (ZOMBIES 2026-10-24, one defunct per wrapper).
|
|
184
|
+
const DETACH_LAUNCHER = "const{spawn}=require('node:child_process');const c=spawn(process.argv[1],process.argv.slice(2),{detached:true,stdio:'ignore'});c.on('error',()=>{});c.unref();";
|
|
185
|
+
|
|
186
|
+
export function startHeartbeat({ tmuxSession, backend, agent, slot }, { spawnSync = realSpawnSync, argv = process.argv, execPath = process.execPath } = {}) {
|
|
181
187
|
if (!argv[1]) return false;
|
|
182
188
|
const args = [argv[1], 'sessions', 'heartbeat', tmuxSession, '--loop'];
|
|
183
189
|
if (backend) args.push('--backend', backend);
|
|
184
190
|
if (agent) args.push('--agent', agent);
|
|
185
191
|
if (slot) args.push('--slot', slot);
|
|
186
192
|
try {
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
if (child && child.unref) child.unref();
|
|
190
|
-
return true;
|
|
193
|
+
const r = spawnSync(execPath, ['-e', DETACH_LAUNCHER, execPath, ...args], { stdio: 'ignore', timeout: 10_000 });
|
|
194
|
+
return !!r && !r.error && r.status === 0;
|
|
191
195
|
} catch { return false; }
|
|
192
196
|
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `quox shortcut add|remove` (QUOXON-Q): a short command (default `q`) that is a symlink to this quox.
|
|
3
|
+
*
|
|
4
|
+
* Rules: never shadow an existing command (a `q` already on PATH that is not ours is refused and named);
|
|
5
|
+
* a user-writable directory only (the one quox lives in, else ~/.local/bin); no sudo; no shell rc edits (a symlink
|
|
6
|
+
* works in every shell and script); idempotent; remove only ever removes a link that points at this quox.
|
|
7
|
+
* Local only: nothing is sent anywhere.
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_NAME = 'q';
|
|
15
|
+
export const NAME_OK = /^[A-Za-z][A-Za-z0-9_.-]{0,30}$/;
|
|
16
|
+
export const QUOX_ENTRY = fileURLToPath(new URL('../../bin/quox.js', import.meta.url));
|
|
17
|
+
|
|
18
|
+
const real = (p) => { try { return fs.realpathSync(p); } catch { return null; } };
|
|
19
|
+
const isExec = (p) => { try { fs.accessSync(p, fs.constants.X_OK); return fs.statSync(p).isFile(); } catch { return false; } };
|
|
20
|
+
const writable = (d) => { try { fs.accessSync(d, fs.constants.W_OK); return fs.statSync(d).isDirectory(); } catch { return false; } };
|
|
21
|
+
const pathDirs = (env) => String(env.PATH || '').split(path.delimiter).filter(Boolean);
|
|
22
|
+
|
|
23
|
+
/** `command -v name` over PATH -> the first executable path, or null. */
|
|
24
|
+
export function whichOnPath(name, env = process.env) {
|
|
25
|
+
for (const d of pathDirs(env)) { const p = path.join(d, name); if (isExec(p)) return p; }
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Where the link goes: { dir, onPath, target }. target is what `q` will point at. */
|
|
30
|
+
export function planShortcut({ env = process.env, home = os.homedir(), entry = QUOX_ENTRY } = {}) {
|
|
31
|
+
const realEntry = real(entry) || entry;
|
|
32
|
+
const quoxOnPath = whichOnPath('quox', env);
|
|
33
|
+
// The `quox` on PATH may be a wrapper script rather than a link to this entry: it is still "this quox".
|
|
34
|
+
const onPathReal = quoxOnPath;
|
|
35
|
+
const target = onPathReal || realEntry;
|
|
36
|
+
const reals = new Set([realEntry, quoxOnPath && real(quoxOnPath)].filter(Boolean));
|
|
37
|
+
const dirs = pathDirs(env).map(d => path.resolve(d));
|
|
38
|
+
const local = path.join(home, '.local', 'bin');
|
|
39
|
+
const cands = [];
|
|
40
|
+
if (onPathReal) cands.push(path.dirname(onPathReal));
|
|
41
|
+
cands.push(local);
|
|
42
|
+
const dir = cands.find(writable) || (fs.existsSync(local) ? null : local);
|
|
43
|
+
return { dir, onPath: dir ? dirs.includes(path.resolve(dir)) : false, target, realEntry, reals };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const ours = (p, reals) => { try { return fs.lstatSync(p).isSymbolicLink() && reals.has(real(p)); } catch { return false; } };
|
|
47
|
+
|
|
48
|
+
/** -> { ok, status: 'created'|'exists'|'refused'|'error', message, link?, onPath? } */
|
|
49
|
+
export function addShortcut({ name = DEFAULT_NAME, env = process.env, home = os.homedir(), entry = QUOX_ENTRY } = {}) {
|
|
50
|
+
if (!NAME_OK.test(name)) return { ok: false, status: 'error', message: `"${name}" is not a usable command name (letters, digits, _ . -, starting with a letter).` };
|
|
51
|
+
const plan = planShortcut({ env, home, entry });
|
|
52
|
+
const found = whichOnPath(name, env);
|
|
53
|
+
if (found && plan.reals.has(real(found))) return { ok: true, status: 'exists', link: found, message: `${name} already runs quox (${found}). Nothing changed.` };
|
|
54
|
+
if (found) return { ok: false, status: 'refused', message: `Not created: "${name}" already runs ${found}. Quox never shadows an existing command. Pick another name with: quox shortcut add --name <name>` };
|
|
55
|
+
if (!plan.dir) return { ok: false, status: 'error', message: 'No writable directory for the link (tried the one quox lives in and ~/.local/bin). Nothing changed.' };
|
|
56
|
+
const link = path.join(plan.dir, name);
|
|
57
|
+
if (ours(link, plan.reals)) return { ok: true, status: 'exists', link, onPath: plan.onPath, message: `${name} already points at quox (${link}).${plan.onPath ? '' : ` ${plan.dir} is not on your PATH, so ${name} will not run yet.`}` };
|
|
58
|
+
let present = false;
|
|
59
|
+
try { fs.lstatSync(link); present = true; } catch { /* free */ }
|
|
60
|
+
if (present) return { ok: false, status: 'refused', message: `Not created: ${link} already exists and is not a link to quox. Left alone.` };
|
|
61
|
+
try {
|
|
62
|
+
fs.mkdirSync(plan.dir, { recursive: true });
|
|
63
|
+
fs.symlinkSync(plan.target, link);
|
|
64
|
+
} catch (e) { return { ok: false, status: 'error', message: `Could not create ${link}: ${e.message}` }; }
|
|
65
|
+
const warn = plan.onPath ? '' : ` ${plan.dir} is not on your PATH, so ${name} will not run until you add it.`;
|
|
66
|
+
return { ok: true, status: 'created', link, onPath: plan.onPath, message: `Created ${link} -> ${plan.target}.${warn}` };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Removes the link only if it points at this quox. */
|
|
70
|
+
export function removeShortcut({ name = DEFAULT_NAME, env = process.env, home = os.homedir(), entry = QUOX_ENTRY } = {}) {
|
|
71
|
+
if (!NAME_OK.test(name)) return { ok: false, status: 'error', message: `"${name}" is not a usable command name.` };
|
|
72
|
+
const plan = planShortcut({ env, home, entry });
|
|
73
|
+
const dirs = [...new Set([...(plan.dir ? [plan.dir] : []), ...pathDirs(env), path.join(home, '.local', 'bin')])];
|
|
74
|
+
for (const d of dirs) {
|
|
75
|
+
const link = path.join(d, name);
|
|
76
|
+
if (ours(link, plan.reals)) {
|
|
77
|
+
try { fs.unlinkSync(link); } catch (e) { return { ok: false, status: 'error', message: `Could not remove ${link}: ${e.message}` }; }
|
|
78
|
+
return { ok: true, status: 'removed', link, message: `Removed ${link}.` };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const other = whichOnPath(name, env);
|
|
82
|
+
return { ok: true, status: 'none', message: other ? `Nothing removed: ${name} (${other}) is not a link to quox, so it is left alone.` : `Nothing to remove: no ${name} link to quox found.` };
|
|
83
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DS-010 / quox#1335: the CLI half of the human step-up PIN.
|
|
3
|
+
*
|
|
4
|
+
* The SERVER decides what needs a person and answers 403 step_up_required. This helper only reacts:
|
|
5
|
+
* on a terminal it asks for the PIN with hidden input, trades it for a short-lived step-up token
|
|
6
|
+
* (POST /api/auth/me/step-up, the same endpoint the Quox terminal uses) and retries the call ONCE with
|
|
7
|
+
* the token. Rules, all deliberate:
|
|
8
|
+
* - the PIN is read from a hidden prompt only. NEVER from argv, an env var or a file: an agent that can
|
|
9
|
+
* set an env var or a flag could supply it, and then the PIN proves nothing about a person
|
|
10
|
+
* - the PIN is never logged, echoed or put in an error message; the token is never stored
|
|
11
|
+
* - no terminal: refuse with a plain message saying a person must run it interactively
|
|
12
|
+
*/
|
|
13
|
+
import { QuoxUserError } from './errors.js';
|
|
14
|
+
import { resolveEndpoint } from '../api/endpoints.js';
|
|
15
|
+
|
|
16
|
+
export const PIN_RE = /^\d{6}$/;
|
|
17
|
+
export const MAX_PIN_TRIES = 3;
|
|
18
|
+
export const STEP_UP_HEADER = 'x-step-up-token';
|
|
19
|
+
const NO_PIN_NEXT = 'set one in the dashboard: Settings > Account > Approval PIN, then run this again in a terminal.';
|
|
20
|
+
|
|
21
|
+
export function isStepUpRequired(err) {
|
|
22
|
+
return Boolean(err && err.status === 403 && err.body && err.body.error === 'step_up_required');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Real IO: hidden prompt via inquirer; a terminal means a TTY on both stdin and stdout. */
|
|
26
|
+
export function defaultPinIO() {
|
|
27
|
+
return {
|
|
28
|
+
isTTY: () => Boolean(process.stdin.isTTY && process.stdout.isTTY),
|
|
29
|
+
promptPin: async (message) => {
|
|
30
|
+
const { default: inquirer } = await import('inquirer');
|
|
31
|
+
const a = await inquirer.prompt([{ type: 'password', name: 'pin', message, mask: '*' }]);
|
|
32
|
+
return a.pin;
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function nonTtyError(what) {
|
|
38
|
+
return new QuoxUserError(`${what} needs your step-up PIN, and there is no terminal to ask for it on.`, {
|
|
39
|
+
next: 'run this command yourself in an interactive terminal; it will ask for the PIN with hidden input. The PIN cannot be passed as a flag or an environment variable.',
|
|
40
|
+
exitCode: 4, code: 'step_up_required',
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Run `run(extraHeaders)`; if the server says step-up is required, get a token from the person and run it once more.
|
|
46
|
+
* @param {(headers:object)=>Promise<any>} run performs the guarded request, merging `headers` into it
|
|
47
|
+
* @param {{client:any, orgId?:string|null, scope?:string, io?:object, what?:string}} opts
|
|
48
|
+
*/
|
|
49
|
+
export async function withStepUp(run, { client, orgId = null, scope = 'approve', io = defaultPinIO(), what = 'This approval' } = {}) {
|
|
50
|
+
try {
|
|
51
|
+
return await run({});
|
|
52
|
+
} catch (err) {
|
|
53
|
+
if (!isStepUpRequired(err)) throw err;
|
|
54
|
+
if (err.body?.reason === 'non_human') {
|
|
55
|
+
throw new QuoxUserError(`${what} needs a person. This login is an agent or service identity, which can never approve it.`, {
|
|
56
|
+
next: 'a person must approve it from their own login (the dashboard, or `quox` in their terminal).', exitCode: 4, code: 'step_up_human_only',
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
if (err.body?.pin_set === false) {
|
|
60
|
+
throw new QuoxUserError(`${what} needs your step-up PIN, and you have not set one yet.`, { next: NO_PIN_NEXT, exitCode: 4, code: 'no_pin_set' });
|
|
61
|
+
}
|
|
62
|
+
if (!io.isTTY()) throw nonTtyError(what);
|
|
63
|
+
const token = await collectToken({ client, orgId, scope, io, what });
|
|
64
|
+
return run({ [STEP_UP_HEADER]: token });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function collectToken({ client, orgId, scope, io, what }) {
|
|
69
|
+
const ep = resolveEndpoint('stepup.verify');
|
|
70
|
+
for (let attempt = 1; attempt <= MAX_PIN_TRIES; attempt += 1) {
|
|
71
|
+
let pin = await io.promptPin(`${what} needs your step-up PIN (6 digits, hidden):`);
|
|
72
|
+
if (!PIN_RE.test(String(pin ?? ''))) {
|
|
73
|
+
pin = null;
|
|
74
|
+
if (attempt < MAX_PIN_TRIES) continue;
|
|
75
|
+
throw new QuoxUserError('A step-up PIN is exactly 6 digits. Nothing was approved.', { exitCode: 4, code: 'bad_pin_format' });
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const body = { pin, scopes: [scope] };
|
|
79
|
+
if (orgId) body.org_id = orgId;
|
|
80
|
+
const r = await client.post(ep.path, { service: ep.service, body });
|
|
81
|
+
pin = null;
|
|
82
|
+
if (r && r.step_up_token) return r.step_up_token;
|
|
83
|
+
throw new QuoxUserError('The server did not issue a step-up token. Nothing was approved.', { exitCode: 4, code: 'step_up_failed' });
|
|
84
|
+
} catch (e) {
|
|
85
|
+
pin = null;
|
|
86
|
+
if (e instanceof QuoxUserError) throw e;
|
|
87
|
+
const code = e?.body?.error || e?.code;
|
|
88
|
+
if (e?.status === 409 || code === 'no_pin_set') throw new QuoxUserError('You have not set a step-up PIN yet.', { next: NO_PIN_NEXT, exitCode: 4, code: 'no_pin_set' });
|
|
89
|
+
if (e?.status === 423 || code === 'pin_locked') throw new QuoxUserError('PIN entry is locked after too many wrong tries.', { next: 'wait for the lock to pass, or approve from the dashboard.', exitCode: 4, code: 'pin_locked' });
|
|
90
|
+
if (e?.status === 403 && code === 'step_up_human_only') throw new QuoxUserError(`${what} needs a person; this login is an agent identity.`, { next: 'approve from a human login.', exitCode: 4, code: 'step_up_human_only' });
|
|
91
|
+
if (e?.status === 401 || code === 'wrong_pin') {
|
|
92
|
+
if (attempt >= MAX_PIN_TRIES) throw new QuoxUserError('Wrong PIN. Nothing was approved.', { exitCode: 4, code: 'wrong_pin' });
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
throw e;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
throw new QuoxUserError('Wrong PIN. Nothing was approved.', { exitCode: 4, code: 'wrong_pin' });
|
|
99
|
+
}
|
package/lib/core/version.js
CHANGED
|
@@ -18,7 +18,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
18
18
|
import { fileURLToPath } from 'node:url';
|
|
19
19
|
import { dirname, resolve } from 'node:path';
|
|
20
20
|
|
|
21
|
-
export const BASE_VERSION = '0.
|
|
21
|
+
export const BASE_VERSION = '0.5.0';
|
|
22
22
|
|
|
23
23
|
// The CLI source root (…/cli). version.js lives at cli/lib/core/, so two
|
|
24
24
|
// levels up is the tree whose HEAD identifies the running build.
|
package/lib/core/vm.js
CHANGED
|
@@ -134,6 +134,11 @@ export async function listVms(exec, { node, type } = {}) {
|
|
|
134
134
|
* does not know it, fall back to client-side exact match over proxmox_vms + proxmox_containers.
|
|
135
135
|
* Ambiguity is an error listing candidates; never a guess.
|
|
136
136
|
*/
|
|
137
|
+
/** DEVFLEET INV-1: list rows for `quox vm list`, with each VM's Proxmox tags as one cell ('' when untagged). */
|
|
138
|
+
export function vmListRows(vms) {
|
|
139
|
+
return vms.map(({ raw, ...r }) => ({ ...r, tags: Array.isArray(raw && raw.tags) ? raw.tags.join(', ') : '' }));
|
|
140
|
+
}
|
|
141
|
+
|
|
137
142
|
export async function resolveTarget(exec, target) {
|
|
138
143
|
const t = String(target ?? '').trim();
|
|
139
144
|
if (!t) throw new Error('a VM name or vmid is required');
|
package/lib/core/walkthrough.js
CHANGED
|
@@ -3,13 +3,16 @@
|
|
|
3
3
|
* card at a time, using the same card and reply rules as the TUI (quoxon/approvalsView.js). Identity is the decision id.
|
|
4
4
|
* io = { readLine(prompt) -> Promise<string|null>, write(line), answer(item, key, note) -> Promise<void> }.
|
|
5
5
|
*/
|
|
6
|
-
import {
|
|
6
|
+
import { isDestructive } from '../quoxon/destructive.js';
|
|
7
|
+
import { isDecision, needsPin, approvalAsWalkItem, walkCard, walkReply, walkNotYesNo, walkNoCatch, walkSummary } from '../quoxon/approvalsView.js';
|
|
7
8
|
|
|
8
9
|
export async function runWalkthrough(items, io) {
|
|
9
|
-
|
|
10
|
-
|
|
10
|
+
// Questions AND policy-held approval cards (kind approval, shaped as yes/no cards); other inbox kinds are not cards.
|
|
11
|
+
const decs = (items || []).filter((it) => isDecision(it) || it?.kind === 'approval').map(approvalAsWalkItem);
|
|
12
|
+
if (!decs.length) { io.write('No question or approval is waiting for you.'); return { answered: [], skipped: [], left: [] }; }
|
|
11
13
|
const st = { answered: [], skipped: [], left: [] };
|
|
12
|
-
|
|
14
|
+
const nApp = decs.filter((d) => d.__approval).length;
|
|
15
|
+
io.write(`${decs.length - nApp ? `${decs.length - nApp} question${decs.length - nApp === 1 ? '' : 's'}` : ''}${decs.length - nApp && nApp ? ' and ' : ''}${nApp ? `${nApp} approval${nApp === 1 ? '' : 's'}` : ''} waiting for you. One at a time: a choice, then an optional note.`);
|
|
13
16
|
for (let i = 0; i < decs.length; i++) {
|
|
14
17
|
const it = decs[i]; const card = walkCard(it, i + 1, decs.length);
|
|
15
18
|
io.write(''); card.lines.forEach((l) => io.write(l));
|
|
@@ -21,7 +24,8 @@ export async function runWalkthrough(items, io) {
|
|
|
21
24
|
if (rep.notYesNo) { io.write(walkNotYesNo(it)); continue; }
|
|
22
25
|
if (rep.defer) { st.skipped.push(it.title || it.id); break; }
|
|
23
26
|
if (rep.ask) { io.write(` Asked by ${it.payload.session || 'a session'} (id ${it.id}). ${it.payload.why || 'No further reason was given.'}`); continue; }
|
|
24
|
-
if (it
|
|
27
|
+
if (needsPin(it) && rep.key !== 'n') { io.write(' High risk: a yes needs your PIN, which this walkthrough cannot take. Run `quox approvals approve <id>` (it asks for the PIN) or answer in `quox` with /approvals walkthrough, or skip it here.'); continue; }
|
|
28
|
+
if (it.__approval && rep.key === 'y' && isDestructive(it)) { io.write(' This deletes something: its name must be typed, which this walkthrough does not take. Run `quox approvals approve <id>` in a terminal, or skip it.'); continue; }
|
|
25
29
|
try { await io.answer(it, rep.key, rep.note || null); }
|
|
26
30
|
catch (e) { io.write(` Not answered (${e.message}). The question stays open.`); continue; }
|
|
27
31
|
const label = it.payload.options.find((o) => o.key === rep.key).label;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The companion view Quoxon actually uses: same contract as dragonlab's integration/companions-v1/view.mjs
|
|
3
|
+
* (initial / reduce / view -> {frame, caption, hidden, name, scene}), same reducer, same character, same scene and caption
|
|
4
|
+
* logic, but sized to the classic dragon's footprint and played from a frame cache filled by a worker (frames.js).
|
|
5
|
+
* view() never renders; it reads. `ready` resolves once the worker has loaded the art.
|
|
6
|
+
*/
|
|
7
|
+
import { pathToFileURL } from 'node:url';
|
|
8
|
+
import { createPlayhead, createWorkerBackend, companionSlot, quantise } from './frames.js';
|
|
9
|
+
|
|
10
|
+
export async function loadCompanionView(root, id, { still = false, arrival = true, mono = false, persist = true, makeBackend = createWorkerBackend, onFrame = () => {} } = {}) {
|
|
11
|
+
const url = (p) => pathToFileURL(`${root}/integration/${p}`).href;
|
|
12
|
+
const [chars, act] = await Promise.all([import(url('companions-v1/characters.mjs')), import(url('hyperion-v3/activity.mjs'))]);
|
|
13
|
+
const spec = chars.character(id);
|
|
14
|
+
const backend = await makeBackend({ root, id, still, mono });
|
|
15
|
+
const play = createPlayhead(backend, { onFirst: onFrame });
|
|
16
|
+
return {
|
|
17
|
+
id, name: spec.name, arrivalMs: spec.arrival.duration,
|
|
18
|
+
initial: act.initialActivity,
|
|
19
|
+
reduce: act.activityEvent,
|
|
20
|
+
playhead: play,
|
|
21
|
+
view(state, now, { cols = 80, rows = 24, mood = null } = {}) {
|
|
22
|
+
if (!persist && !still && now - state.started >= spec.arrival.duration && !state.approvals.length) return { frame: null, caption: null, hidden: true, name: spec.name, scene: null };
|
|
23
|
+
const slot = companionSlot(cols, Math.max(1, rows));
|
|
24
|
+
let v = chars.activityView(id, state, now, { arrival: arrival && !still });
|
|
25
|
+
// The dragon's job read found something wrong (a down route, an unhealthy host, a failed check): it acts concerned, the same scene
|
|
26
|
+
// a recorded issue gets. Never the other way round: a good read does not pick a happier scene than the activity allows.
|
|
27
|
+
if (mood === 'concerned' && !state.approvals.length && !state.issue) { const c = chars.activityView(id, { ...state, issue: true }, now, { arrival: false }); v = { ...v, scene: c.scene, time: c.time, motionTime: c.motionTime }; }
|
|
28
|
+
const frame = slot.showArt ? play.frame({ scene: v.scene, time: v.time, motionTime: v.motionTime, cols: slot.cols, rows: slot.rows }, { still }) : null;
|
|
29
|
+
return { frame, caption: v.caption, slot, name: spec.name, scene: v.scene };
|
|
30
|
+
},
|
|
31
|
+
dispose() { play.dispose(); },
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export { companionSlot, quantise };
|