quoxcli 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +110 -0
- package/README.md +2 -2
- package/bin/quox.js +19 -1
- package/bin/quoxon.js +6 -1
- package/lib/api/endpoints/admin.js +7 -0
- package/lib/api/endpoints/agent-attach.js +7 -0
- package/lib/api/endpoints/compliance.js +25 -0
- package/lib/api/endpoints/governance.js +7 -0
- package/lib/api/endpoints/inbox.js +6 -0
- package/lib/api/endpoints/inboxq.js +58 -0
- package/lib/api/endpoints/index.js +4 -0
- package/lib/api/endpoints/lucid.js +17 -0
- package/lib/api/endpoints/mcp.js +35 -0
- package/lib/api/endpoints/mon.js +122 -0
- package/lib/api/endpoints/notesq.js +2 -0
- package/lib/api/endpoints/quoxplan.js +51 -0
- package/lib/api/endpoints/tools.js +13 -0
- package/lib/api/endpoints/v1.js +13 -0
- package/lib/commands/actions.js +51 -13
- package/lib/commands/admin.js +35 -0
- package/lib/commands/agentAttach.js +15 -0
- package/lib/commands/approvals.js +193 -3
- package/lib/commands/ask.js +1 -1
- package/lib/commands/auth.js +64 -11
- package/lib/commands/backup.js +11 -4
- package/lib/commands/capabilities.js +3 -2
- package/lib/commands/chat.js +15 -0
- package/lib/commands/claude.js +4 -1
- package/lib/commands/compliance.js +109 -0
- package/lib/commands/connect.js +69 -0
- package/lib/commands/contacts.js +2 -0
- package/lib/commands/dev.js +6 -8
- package/lib/commands/doctor.js +22 -3
- package/lib/commands/evidence.js +2 -1
- package/lib/commands/gripe.js +24 -13
- package/lib/commands/inbox.js +37 -3
- package/lib/commands/inboxq.js +147 -0
- package/lib/commands/kuma.js +78 -5
- package/lib/commands/licenceLinkView.js +311 -0
- package/lib/commands/linkedin.js +19 -0
- package/lib/commands/loop.js +30 -3
- package/lib/commands/lucid.js +7 -0
- package/lib/commands/lucidGoals.js +389 -0
- package/lib/commands/mail.js +427 -25
- package/lib/commands/mcp.js +190 -0
- package/lib/commands/memory.js +68 -4
- package/lib/commands/memorySync.js +158 -0
- package/lib/commands/mon.js +69 -144
- package/lib/commands/monitoring.js +68 -0
- package/lib/commands/notes.js +29 -7
- package/lib/commands/notesSeal.js +617 -0
- package/lib/commands/objective.js +5 -2
- package/lib/commands/offload.js +52 -12
- package/lib/commands/plan.js +487 -52
- package/lib/commands/provider.js +29 -8
- package/lib/commands/qlarity.js +32 -1
- package/lib/commands/run.js +2 -1
- package/lib/commands/server.js +37 -0
- package/lib/commands/setup.js +127 -75
- package/lib/commands/skills.js +4 -1
- 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/volt.js +6 -1
- package/lib/commands/walkthrough.js +14 -2
- package/lib/commands/watch.js +11 -2
- package/lib/commands/wazuh.js +880 -0
- package/lib/commands/wazuhIntake.js +358 -0
- package/lib/commands/wazuhReport.js +192 -0
- package/lib/commands/workflow.js +47 -4
- package/lib/commands/x.js +50 -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 +27 -1
- package/lib/core/commandRegistrars.js +9 -0
- package/lib/core/config.js +42 -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/governedSession.js +135 -5
- package/lib/core/inboxResolution.js +3 -2
- package/lib/core/loginGuard.js +73 -0
- package/lib/core/offload.js +120 -0
- package/lib/core/org-override.js +5 -1
- package/lib/core/output.js +30 -5
- package/lib/core/profileTrust.js +182 -0
- package/lib/core/quoxglyph.js +22 -1
- package/lib/core/router.js +14 -0
- package/lib/core/selfUpdate.js +2 -0
- package/lib/core/stepUpPrompt.js +99 -0
- package/lib/core/version.js +1 -1
- package/lib/core/walkthrough.js +9 -5
- package/lib/dragon/select.js +2 -2
- package/lib/quoxon/EXTENSIONS.md +142 -19
- package/lib/quoxon/api.js +189 -34
- package/lib/quoxon/app.js +1277 -188
- package/lib/quoxon/approvalBatch.js +117 -0
- package/lib/quoxon/approvalFacts.js +56 -2
- package/lib/quoxon/approvalSentence.js +8 -4
- package/lib/quoxon/approvalsView.js +72 -12
- package/lib/quoxon/ask.js +4 -2
- package/lib/quoxon/autoOpen.js +94 -0
- package/lib/quoxon/backgroundWork.js +59 -0
- package/lib/quoxon/bang.js +8 -2
- package/lib/quoxon/capabilities.js +7 -2
- package/lib/quoxon/commandShort.js +8 -1
- package/lib/quoxon/commands.js +42 -12
- package/lib/quoxon/connect.js +5 -3
- package/lib/quoxon/deckLazy.js +58 -0
- package/lib/quoxon/delegationDragon.js +49 -0
- package/lib/quoxon/destructive.js +35 -0
- package/lib/quoxon/dnd.js +118 -0
- package/lib/quoxon/dragonCompanion.js +37 -7
- package/lib/quoxon/dragonMoments.js +78 -0
- package/lib/quoxon/echo.js +1 -0
- package/lib/quoxon/events.js +55 -8
- package/lib/quoxon/extensions.js +400 -38
- package/lib/quoxon/foreignText.js +49 -0
- package/lib/quoxon/gripeQueue.js +135 -0
- package/lib/quoxon/inboxView.js +125 -22
- package/lib/quoxon/keys.js +2 -2
- package/lib/quoxon/launcher.js +2 -1
- package/lib/quoxon/loopCard.js +7 -2
- package/lib/quoxon/loopCards.js +17 -8
- package/lib/quoxon/loopStream.js +20 -9
- package/lib/quoxon/mailView.js +71 -0
- package/lib/quoxon/main.js +27 -6
- package/lib/quoxon/markdown.js +1 -1
- package/lib/quoxon/model.js +15 -0
- package/lib/quoxon/neverAnswered.js +20 -0
- package/lib/quoxon/objects/away.js +43 -5
- package/lib/quoxon/onboarding/connectServer.js +162 -0
- package/lib/quoxon/onboarding/copy.js +6 -0
- package/lib/quoxon/onboarding/dockerOffer.js +3 -1
- package/lib/quoxon/onboarding/index.js +5 -43
- package/lib/quoxon/onboarding/probe.js +20 -1
- package/lib/quoxon/onboarding/view.js +16 -3
- package/lib/quoxon/orgSwitch.js +22 -0
- package/lib/quoxon/print.js +2 -0
- package/lib/quoxon/profileSwitch.js +7 -0
- package/lib/quoxon/promptHistory.js +3 -1
- package/lib/quoxon/render.js +68 -16
- package/lib/quoxon/resend.js +14 -0
- package/lib/quoxon/roster.js +10 -5
- package/lib/quoxon/rosterSessions.js +44 -0
- package/lib/quoxon/rows.js +3 -1
- package/lib/quoxon/screenContext.js +1 -1
- package/lib/quoxon/secretShape.js +88 -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 +48 -11
- package/lib/quoxon/slash/broadcastRun.js +24 -7
- package/lib/quoxon/slash/cliLeaves.json +98 -1
- package/lib/quoxon/slash/dashboard.js +2 -0
- package/lib/quoxon/slash/hatWalk.js +1 -1
- package/lib/quoxon/slash/listView.js +2 -1
- package/lib/quoxon/slash/palette.js +63 -1
- package/lib/quoxon/slash/paletteController.js +15 -5
- package/lib/quoxon/slash/restart.js +9 -4
- package/lib/quoxon/slash/stepUp.js +5 -5
- package/lib/quoxon/slash/stepUpCmds.js +74 -1
- package/lib/quoxon/slashSpinner.js +35 -0
- package/lib/quoxon/slotAttach.js +8 -0
- package/lib/quoxon/slotChat.js +24 -3
- package/lib/quoxon/slotFormat.js +1 -1
- package/lib/quoxon/slotOutbox.js +113 -0
- package/lib/quoxon/slotRun.js +2 -0
- package/lib/quoxon/suggestCommand.js +1 -1
- package/lib/quoxon/term.js +25 -1
- package/lib/quoxon/transcript.js +2 -1
- package/lib/quoxon/webPty.js +15 -0
- package/lib/quoxon/welcome.js +1 -1
- package/lib/quoxon/whatsnew.js +1 -1
- package/lib/quoxon/whatsnew.jsonl +42 -0
- package/lib/render/planBrief.js +614 -0
- package/lib/server/installFlow.js +168 -0
- package/lib/server/status.js +15 -1
- package/lib/toolOutputShape.js +61 -3
- package/package.json +3 -2
- package/vendor/dragonlab/SOURCE +3 -3
- 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/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/integration/companions-v1/characters.mjs +20 -0
- package/vendor/dragonlab/integration/companions-v1/quoxperticus.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/view.mjs +1 -1
- package/vendor/dragonlab/integration/companions-v1/vulcan.mjs +2 -0
package/lib/core/offload.js
CHANGED
|
@@ -501,6 +501,89 @@ export async function workerStatus(deps) {
|
|
|
501
501
|
};
|
|
502
502
|
}
|
|
503
503
|
|
|
504
|
+
// 2026-11-07: three worker leases expired overnight with no warning and the nightly suite died on the human-lease
|
|
505
|
+
// error. Every offload command now warns inside this window, and status/leases list every configured worker.
|
|
506
|
+
export const LEASE_WARN_H = 12;
|
|
507
|
+
const STATE_FILE_RE = /^offload(?:-([a-z0-9][a-z0-9-]{0,31}))?\.json$/;
|
|
508
|
+
|
|
509
|
+
/** Configured workers from the state files: '' is the shared default, then named ones sorted. */
|
|
510
|
+
export function listWorkerNames(deps) {
|
|
511
|
+
let files = [];
|
|
512
|
+
try { files = deps.fs.readdirSync(configDir(deps.home)); } catch { return []; }
|
|
513
|
+
const names = [];
|
|
514
|
+
for (const f of files) {
|
|
515
|
+
const m = STATE_FILE_RE.exec(f);
|
|
516
|
+
if (m) names.push(m[1] || '');
|
|
517
|
+
}
|
|
518
|
+
return names.sort((a, b) => (a === '' ? -1 : b === '' ? 1 : a.localeCompare(b)));
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** Lease state for an expiry timestamp: EXPIRED, EXPIRING (within LEASE_WARN_H), OK, or UNKNOWN. */
|
|
522
|
+
export function leaseInfo(expiresAt, now) {
|
|
523
|
+
const t = expiresAt ? Date.parse(expiresAt) : NaN;
|
|
524
|
+
if (!Number.isFinite(t)) return { lease: 'UNKNOWN', hours_left: null };
|
|
525
|
+
const hours = Math.round(((t - now) / 3600000) * 10) / 10;
|
|
526
|
+
if (t <= now) return { lease: 'EXPIRED', hours_left: hours };
|
|
527
|
+
return { lease: hours <= LEASE_WARN_H ? 'EXPIRING' : 'OK', hours_left: hours };
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** What a HUMAN runs to renew: there is no lease-extend verb, so lease a fresh worker (the old one is released after). */
|
|
531
|
+
export function renewCommand(name) {
|
|
532
|
+
return `quox -p <human-profile> offload --fresh${name ? ` --worker ${name}` : ''} -- true`;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const fmtLeft = (h) => (Math.abs(h) < 1 ? `${Math.round(Math.abs(h) * 60)}m` : `${Math.round(Math.abs(h))}h`);
|
|
536
|
+
export function describeLease(l) {
|
|
537
|
+
if (l.lease === 'EXPIRED') return l.hours_left == null ? 'EXPIRED (the pool no longer holds it)' : `EXPIRED ${fmtLeft(l.hours_left)} ago`;
|
|
538
|
+
if (l.lease === 'EXPIRING' || l.lease === 'OK') return `${l.lease}, expires in ${fmtLeft(l.hours_left)}`;
|
|
539
|
+
return l.lease;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function leaseEntry(name, st, live, now) {
|
|
543
|
+
const base = { worker: name || 'default', vmid: null, ip: null, lessee: null, pool_state: null, lease_expires_at: null, lease: 'NONE', hours_left: null, renew: null };
|
|
544
|
+
if (!st.worker) return base;
|
|
545
|
+
const expires = live?.lease_expires_at || st.worker.expires || null;
|
|
546
|
+
let info = leaseInfo(expires, now);
|
|
547
|
+
const pool = live === undefined ? 'unknown' : live ? live.state : 'missing';
|
|
548
|
+
// The pool is the truth when it answered: a VM it no longer holds (or holds for someone else) is gone.
|
|
549
|
+
if (live === null || (live && (live.state !== 'leased' || (st.worker.lessee && live.lessee && live.lessee !== st.worker.lessee)))) {
|
|
550
|
+
info = { lease: 'EXPIRED', hours_left: info.lease === 'EXPIRED' ? info.hours_left : null };
|
|
551
|
+
}
|
|
552
|
+
return {
|
|
553
|
+
...base, vmid: st.worker.vmid, ip: live?.ip || st.worker.ip || null, lessee: st.worker.lessee || null,
|
|
554
|
+
pool_state: pool, lease_expires_at: expires, ...info,
|
|
555
|
+
renew: info.lease === 'EXPIRED' || info.lease === 'EXPIRING' ? renewCommand(name) : null,
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Every configured worker's lease. `live: true` asks the pool once (a failure is reported, the cached expiry is
|
|
561
|
+
* used); `live: false` reads only the local state files (the pre-run warning must not add a pool call).
|
|
562
|
+
* Read-only: never leases, renews or releases.
|
|
563
|
+
*/
|
|
564
|
+
export async function allLeases(deps, { live = true } = {}) {
|
|
565
|
+
const now = deps.now();
|
|
566
|
+
let pool = null; let poolErr = null;
|
|
567
|
+
if (live) {
|
|
568
|
+
try { pool = await poolStatus(deps.rest); } catch (e) { poolErr = String(e?.message || e); }
|
|
569
|
+
}
|
|
570
|
+
const leases = listWorkerNames(deps).map((name) => {
|
|
571
|
+
const st = loadState({ ...deps, worker: name || undefined });
|
|
572
|
+
const v = pool && st.worker ? vmOf(pool, st.worker.vmid) : undefined;
|
|
573
|
+
return leaseEntry(name, st, v, now);
|
|
574
|
+
});
|
|
575
|
+
return {
|
|
576
|
+
checked_at: new Date(now).toISOString(), warn_within_h: LEASE_WARN_H, source: pool ? 'pool' : 'cache',
|
|
577
|
+
...(poolErr ? { pool_error: poolErr } : {}),
|
|
578
|
+
any_attention: leases.some((l) => l.renew), leases,
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/** One line per expired or expiring lease, naming the command a human runs. */
|
|
583
|
+
export function leaseWarnings(report) {
|
|
584
|
+
return report.leases.filter((l) => l.renew).map((l) => `warning: worker ${l.worker} (vm ${l.vmid}) lease ${l.lease === 'EXPIRED' ? describeLease(l) : `expires in ${fmtLeft(l.hours_left)} (${l.lease_expires_at})`}; leasing needs a human, who renews it with: ${l.renew}`);
|
|
585
|
+
}
|
|
586
|
+
|
|
504
587
|
/** Real side effects. */
|
|
505
588
|
export function realDeps({ rest, env = process.env, log = (m) => process.stderr.write(`offload: ${m}\n`) }) {
|
|
506
589
|
return {
|
|
@@ -531,3 +614,40 @@ export function gitToplevel(cwd) {
|
|
|
531
614
|
const r = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8' });
|
|
532
615
|
return r.status === 0 ? r.stdout.trim() : cwd;
|
|
533
616
|
}
|
|
617
|
+
|
|
618
|
+
// #1485: the git repo root for cwd, or null when cwd is not inside a repo.
|
|
619
|
+
// Unlike gitToplevel (which falls back to cwd), this reports the no-repo case
|
|
620
|
+
// so resolveSyncDir can refuse rather than sync an unbounded tree.
|
|
621
|
+
export function gitRepoRoot(cwd, { run = (c, a, o) => spawnSync(c, a, o) } = {}) {
|
|
622
|
+
const r = run('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8' });
|
|
623
|
+
return r.status === 0 ? r.stdout.trim() : null;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// #1485: resolve the ONE directory offload rsyncs to the shared worker, and
|
|
627
|
+
// refuse an unbounded one. `quox offload` from a non-repo dir used to fall
|
|
628
|
+
// back to cwd, so running it from the home directory rsynced all ~29G of $HOME
|
|
629
|
+
// (including a live token file) onto the worker and filled its disk. The sync
|
|
630
|
+
// must be a bounded git repo root, never $HOME and never an ancestor of it.
|
|
631
|
+
export function resolveSyncDir(cwd, optionDir, { home = os.homedir(), repoRoot = gitRepoRoot } = {}) {
|
|
632
|
+
const h = path.resolve(home);
|
|
633
|
+
const guardHome = (dir, what) => {
|
|
634
|
+
if (dir === h) {
|
|
635
|
+
throw new OffloadError(`offload refuses to sync your home directory (${dir}). cd into a project repo, or pass --dir <repo-root>.`);
|
|
636
|
+
}
|
|
637
|
+
if (h === dir || h.startsWith(dir + path.sep) || dir === path.parse(dir).root) {
|
|
638
|
+
throw new OffloadError(`offload refuses to sync ${what} (${dir}): it contains your home directory, so the whole tree would be copied to the shared worker. Pass --dir <repo-root>.`);
|
|
639
|
+
}
|
|
640
|
+
};
|
|
641
|
+
if (optionDir) {
|
|
642
|
+
const d = path.resolve(optionDir);
|
|
643
|
+
guardHome(d, 'the --dir path');
|
|
644
|
+
return d;
|
|
645
|
+
}
|
|
646
|
+
const root = repoRoot(cwd);
|
|
647
|
+
if (!root) {
|
|
648
|
+
throw new OffloadError(`offload must run from inside a git repository (cwd ${cwd} is not one). cd into a repo, or pass --dir <repo-root>. Refusing so your whole home directory is not synced to the shared worker.`);
|
|
649
|
+
}
|
|
650
|
+
const d = path.resolve(root);
|
|
651
|
+
guardHome(d, 'the resolved repo root');
|
|
652
|
+
return d;
|
|
653
|
+
}
|
package/lib/core/org-override.js
CHANGED
|
@@ -20,6 +20,10 @@ import { QuoxValidationError } from './errors.js';
|
|
|
20
20
|
// Commands where --org legitimately means something other than "act in".
|
|
21
21
|
export const EXEMPT = new Set(['login', 'logout', 'signup', 'org switch', 'invites list', 'invites accept']);
|
|
22
22
|
|
|
23
|
+
// Verbs that mint a same-user token for the named org themselves (core/crossOrgApprovals.clientForTarget), so --org
|
|
24
|
+
// naming another org is honoured on that org, not refused. Membership is enforced server-side by the mint.
|
|
25
|
+
export const CROSS_ORG = new Set(['approvals list', 'approvals approve', 'approvals deny', 'approvals walkthrough']);
|
|
26
|
+
|
|
23
27
|
export function commandPath(cmd) {
|
|
24
28
|
const parts = [];
|
|
25
29
|
for (let c = cmd; c && c.parent; c = c.parent) parts.unshift(c.name());
|
|
@@ -35,7 +39,7 @@ export function commandPath(cmd) {
|
|
|
35
39
|
* @throws {QuoxValidationError} when the override differs from the session org
|
|
36
40
|
*/
|
|
37
41
|
export function assertOrgOverrideSafe({ override, authData, hasUserToken, command = '' }) {
|
|
38
|
-
if (!override || !hasUserToken || EXEMPT.has(command)) return;
|
|
42
|
+
if (!override || !hasUserToken || EXEMPT.has(command) || CROSS_ORG.has(command)) return;
|
|
39
43
|
const want = String(override).toLowerCase();
|
|
40
44
|
const sid = authData?.org_id || null;
|
|
41
45
|
const known = [sid, authData?.org_slug, authData?.org_name]
|
package/lib/core/output.js
CHANGED
|
@@ -12,7 +12,8 @@ import Table from 'cli-table3';
|
|
|
12
12
|
* Format and print tabular data
|
|
13
13
|
*
|
|
14
14
|
* @param {Object} result - { columns, headers, rows } or array of objects
|
|
15
|
-
* @param {Object} options - { format, title, emptyMessage, full } (full: no 60-char cell truncation in table mode
|
|
15
|
+
* @param {Object} options - { format, title, emptyMessage, full, fit } (full: no 60-char cell truncation in table mode;
|
|
16
|
+
* fit: { width, flex } opt-in, shortens the `flex` column with an ellipsis so each table line fits `width`)
|
|
16
17
|
*/
|
|
17
18
|
export function formatOutput(result, options = {}) {
|
|
18
19
|
const format = options.format || getOutputFormat();
|
|
@@ -45,7 +46,7 @@ export function formatOutput(result, options = {}) {
|
|
|
45
46
|
formatCsv(rows, columns, headers);
|
|
46
47
|
break;
|
|
47
48
|
case 'quiet':
|
|
48
|
-
formatQuiet(rows, columns);
|
|
49
|
+
formatQuiet(rows, options.quietColumn ? [options.quietColumn] : columns);
|
|
49
50
|
break;
|
|
50
51
|
default:
|
|
51
52
|
formatTable(rows, columns, headers, options);
|
|
@@ -167,9 +168,9 @@ function formatTable(rows, columns, headers, options) {
|
|
|
167
168
|
},
|
|
168
169
|
});
|
|
169
170
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
171
|
+
const cells = rows.map(row => columns.map(col => formatValue(row[col], options.full === true)));
|
|
172
|
+
if (options.fit) fitCells(cells, columns, headers, options.fit);
|
|
173
|
+
for (const r of cells) table.push(r);
|
|
173
174
|
|
|
174
175
|
console.log(table.toString());
|
|
175
176
|
|
|
@@ -179,6 +180,30 @@ function formatTable(rows, columns, headers, options) {
|
|
|
179
180
|
}
|
|
180
181
|
}
|
|
181
182
|
|
|
183
|
+
// eslint-disable-next-line no-control-regex
|
|
184
|
+
const ANSI = /\x1b\[[0-9;]*m/g;
|
|
185
|
+
const visibleLength = (s) => [...String(s).replace(ANSI, '')].length;
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Opt-in width fitting (fit: { width, flex }). cli-table3 here pads each cell by 1 on both sides and joins
|
|
189
|
+
* cells with a 2-space middle, so a line is sum(w + 2) + 2 * (n - 1). When that passes `width`, the `flex`
|
|
190
|
+
* column is cut to the remaining room with an ellipsis instead of letting the terminal wrap the row
|
|
191
|
+
* (owner 2026-11-07: wrapped `quox actions list` rows in the 73-column TUI).
|
|
192
|
+
*/
|
|
193
|
+
function fitCells(cells, columns, headers, { width, flex } = {}) {
|
|
194
|
+
const w = Number(width);
|
|
195
|
+
const fi = columns.indexOf(flex);
|
|
196
|
+
if (!(w > 0) || fi < 0) return;
|
|
197
|
+
const widths = columns.map((_, i) => Math.max(visibleLength(headers[i] ?? ''), ...cells.map(r => visibleLength(r[i]))));
|
|
198
|
+
const total = widths.reduce((a, b) => a + b + 2, 0) + 2 * (columns.length - 1);
|
|
199
|
+
if (total <= w) return;
|
|
200
|
+
const room = Math.max(visibleLength(headers[fi] ?? ''), 8, widths[fi] - (total - w));
|
|
201
|
+
for (const r of cells) {
|
|
202
|
+
const plain = String(r[fi]).replace(ANSI, '');
|
|
203
|
+
if ([...plain].length > room) r[fi] = [...plain].slice(0, room - 1).join('').trimEnd() + '…';
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
182
207
|
function formatCsv(rows, columns, headers) {
|
|
183
208
|
console.log(headers.map(csvEscape).join(','));
|
|
184
209
|
for (const row of rows) {
|
|
@@ -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';
|
package/lib/core/selfUpdate.js
CHANGED
|
@@ -20,6 +20,7 @@ import os from 'node:os';
|
|
|
20
20
|
import { RUNNING_ROOT, findOnPath } from '../quoxon/slash/restart.js';
|
|
21
21
|
import { cmpSemver } from '../server/install.js';
|
|
22
22
|
import { askText } from '../quoxon/ask.js';
|
|
23
|
+
import { isWebPty, SHELL_REFUSED } from '../quoxon/webPty.js';
|
|
23
24
|
|
|
24
25
|
export const NPM_PACKAGE = 'quoxcli';
|
|
25
26
|
export const NOTES_MAX = 5;
|
|
@@ -146,6 +147,7 @@ export async function checkUpdate(info, { deps = {} } = {}) {
|
|
|
146
147
|
|
|
147
148
|
/** Run the update with the terminal handed over (prompts, sudo). -> { ok, code } or { ok:false, error } */
|
|
148
149
|
export function runUpdate(plan, { spawn = spawnSync, env = process.env } = {}) {
|
|
150
|
+
if (isWebPty(env)) return { ok: false, error: SHELL_REFUSED }; // QUOXON web PTY: no npm/sudo on the server
|
|
149
151
|
const r = spawn(plan.command.cmd, plan.command.args, { stdio: 'inherit', env });
|
|
150
152
|
if (r.error) return { ok: false, error: `${r.error.code || 'failed'}: ${r.error.message}` };
|
|
151
153
|
const code = r.status ?? (r.signal ? 128 + (os.constants.signals[r.signal] || 0) : 1);
|
|
@@ -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.6.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/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;
|