quoxcli 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +2 -0
- package/bin/quox.js +99 -126
- package/lib/api/endpoints/admin.js +26 -0
- package/lib/api/endpoints/agent-schedules.js +26 -0
- package/lib/api/endpoints/agents.js +7 -0
- package/lib/api/endpoints/assistants.js +12 -0
- package/lib/api/endpoints/away.js +15 -0
- package/lib/api/endpoints/chat.js +7 -0
- package/lib/api/endpoints/contactsq.js +2 -0
- package/lib/api/endpoints/conversations.js +14 -0
- package/lib/api/endpoints/executions.js +13 -0
- package/lib/api/endpoints/fetchAllowlist.js +27 -0
- package/lib/api/endpoints/files.js +14 -0
- package/lib/api/endpoints/hat.js +30 -0
- package/lib/api/endpoints/inbox.js +20 -0
- package/lib/api/endpoints/index.js +14 -0
- package/lib/api/endpoints/memory.js +19 -0
- package/lib/api/endpoints/mind.js +13 -0
- package/lib/api/endpoints/mon.js +6 -0
- package/lib/api/endpoints/objectives.js +6 -0
- package/lib/api/endpoints/plugins.js +18 -0
- package/lib/api/endpoints/presence.js +33 -0
- package/lib/api/endpoints/quoxplan.js +7 -0
- package/lib/api/endpoints/quoxseo.js +6 -0
- package/lib/api/endpoints/router.js +17 -0
- package/lib/api/endpoints/safety.js +7 -0
- package/lib/api/endpoints/skills.js +20 -0
- package/lib/api/endpoints/stepup.js +20 -0
- package/lib/api/endpoints/teams.js +2 -2
- package/lib/api/endpoints/triggers.js +26 -0
- package/lib/api/endpoints/v1.js +86 -0
- package/lib/api/endpoints/ward.js +7 -0
- package/lib/api/endpoints/workflows.js +7 -0
- package/lib/api/endpoints/x.js +8 -0
- package/lib/commands/actions.js +61 -0
- package/lib/commands/admin.js +16 -0
- package/lib/commands/agent.js +163 -27
- package/lib/commands/agentAttach.js +94 -6
- package/lib/commands/approvals.js +73 -1
- package/lib/commands/aside.js +24 -0
- package/lib/commands/ask.js +144 -0
- package/lib/commands/assistant.js +59 -4
- package/lib/commands/auth.js +8 -2
- package/lib/commands/backup.js +8 -4
- package/lib/commands/brain.js +105 -14
- package/lib/commands/build.js +19 -5
- package/lib/commands/chat.js +57 -2
- package/lib/commands/claude.js +34 -8
- package/lib/commands/contacts.js +49 -0
- package/lib/commands/conversation.js +35 -4
- package/lib/commands/dashboard.js +43 -0
- package/lib/commands/department.js +28 -7
- package/lib/commands/devResident.js +15 -3
- package/lib/commands/devSupervisor.js +13 -1
- package/lib/commands/discord.js +75 -39
- package/lib/commands/done.js +98 -28
- package/lib/commands/engagement.js +17 -6
- package/lib/commands/fetchAllowlist.js +71 -0
- package/lib/commands/file.js +38 -1
- package/lib/commands/fleet.js +311 -4
- package/lib/commands/governance.js +12 -3
- package/lib/commands/gripe.js +44 -13
- package/lib/commands/hat.js +178 -9
- package/lib/commands/helpers.js +18 -1
- package/lib/commands/hyperion.js +58 -0
- package/lib/commands/inbox.js +74 -23
- package/lib/commands/licence.js +14 -155
- package/lib/commands/lucid.js +10 -4
- package/lib/commands/memory.js +85 -8
- package/lib/commands/mind.js +71 -0
- package/lib/commands/mon.js +69 -2
- package/lib/commands/monitoring.js +1 -1
- package/lib/commands/motion.js +2 -2
- package/lib/commands/nucleus.js +48 -17
- package/lib/commands/objective.js +11 -0
- package/lib/commands/offload.js +89 -0
- package/lib/commands/onboard.js +2 -1
- package/lib/commands/org.js +11 -1
- package/lib/commands/peek.js +28 -0
- package/lib/commands/plan.js +38 -0
- package/lib/commands/plugin.js +110 -0
- package/lib/commands/presence.js +89 -0
- package/lib/commands/provider.js +159 -0
- package/lib/commands/qlarity.js +19 -3
- package/lib/commands/quoxchat.js +13 -4
- package/lib/commands/quoxseo.js +38 -0
- package/lib/commands/reach.js +2 -1
- package/lib/commands/router.js +77 -0
- package/lib/commands/rows.js +358 -0
- package/lib/commands/run.js +64 -9
- package/lib/commands/server.js +292 -0
- package/lib/commands/setup.js +33 -7
- package/lib/commands/skills.js +48 -5
- package/lib/commands/slot.js +260 -7
- package/lib/commands/status.js +1 -1
- package/lib/commands/team.js +3 -1
- package/lib/commands/tips.js +23 -0
- package/lib/commands/tool.js +133 -45
- package/lib/commands/trace.js +12 -7
- package/lib/commands/trigger.js +95 -0
- package/lib/commands/update.js +46 -0
- package/lib/commands/vault.js +31 -8
- package/lib/commands/vm.js +237 -0
- package/lib/commands/voice.js +34 -3
- package/lib/commands/volt.js +45 -3
- package/lib/commands/walkthrough.js +32 -0
- package/lib/commands/watch.js +73 -21
- package/lib/commands/welcome.js +14 -0
- package/lib/commands/workflow.js +189 -32
- package/lib/commands/x.js +13 -4
- package/lib/core/actingAs.js +2 -2
- package/lib/core/activeSummary.js +186 -0
- package/lib/core/aside.js +54 -0
- package/lib/core/askDecision.js +60 -0
- package/lib/core/auth.js +164 -13
- package/lib/core/callerSource.js +1 -1
- package/lib/core/cliBuild.js +62 -0
- package/lib/core/client.js +144 -35
- package/lib/core/commandRegistrars.js +121 -0
- package/lib/core/config.js +142 -24
- package/lib/core/customHats.js +168 -0
- package/lib/core/doneEngineFetch.js +8 -0
- package/lib/core/errors.js +329 -20
- package/lib/core/global-flags.js +105 -5
- package/lib/core/hyperion.js +102 -0
- package/lib/core/inboxResolution.js +65 -0
- package/lib/core/lazyCommands.js +4 -1
- package/lib/core/offload.js +504 -0
- package/lib/core/org-override.js +1 -1
- package/lib/core/orgResolve.js +70 -0
- package/lib/core/output.js +4 -4
- package/lib/core/peek.js +126 -0
- package/lib/core/profileUsers.js +51 -0
- package/lib/core/promptCancel.js +18 -0
- package/lib/core/repoPath.js +11 -0
- package/lib/core/residentSeed.js +3 -1
- package/lib/core/rootOptions.js +39 -0
- package/lib/core/router.js +168 -0
- package/lib/core/selfUpdate.js +188 -0
- package/lib/core/sessionsOverview.js +64 -0
- package/lib/core/slotKeeper.js +248 -3
- package/lib/core/slotWake.js +120 -0
- package/lib/core/supervisorEngine.js +71 -2
- package/lib/core/version.js +1 -1
- package/lib/core/vm.js +361 -0
- package/lib/core/vmTui.js +86 -0
- package/lib/core/walkthrough.js +35 -0
- package/lib/hyperion/deck.js +29 -0
- package/lib/lucid/LucidCard.jsx +68 -0
- package/lib/lucid/LucidIntro.jsx +45 -58
- package/lib/lucid/cards.js +86 -0
- package/lib/lucid/markdown.js +81 -0
- package/lib/lucid/motion.js +56 -0
- package/lib/quoxon/activity.js +14 -2
- package/lib/quoxon/aocl.js +117 -0
- package/lib/quoxon/api.js +504 -25
- package/lib/quoxon/app.js +3000 -230
- package/lib/quoxon/approvalFacts.js +94 -0
- package/lib/quoxon/approvalsView.js +325 -0
- package/lib/quoxon/asideView.js +40 -0
- package/lib/quoxon/ask.js +121 -0
- package/lib/quoxon/away.js +52 -0
- package/lib/quoxon/bang.js +224 -0
- package/lib/quoxon/bulkRisk.js +17 -0
- package/lib/quoxon/capabilities.js +13 -6
- package/lib/quoxon/cells.js +53 -0
- package/lib/quoxon/commandShort.js +112 -0
- package/lib/quoxon/commands.js +363 -38
- package/lib/quoxon/connect.js +210 -23
- package/lib/quoxon/connectCard.js +19 -2
- package/lib/quoxon/consent.js +86 -0
- package/lib/quoxon/copy.js +4 -3
- package/lib/quoxon/countdownLine.js +11 -0
- package/lib/quoxon/demo.autoplay.js +29 -0
- package/lib/quoxon/demo.fixtures.js +1 -1
- package/lib/quoxon/demo.js +44 -3
- package/lib/quoxon/devObserver.js +20 -0
- package/lib/quoxon/dividerAocl.js +191 -0
- package/lib/quoxon/dividerRule.js +57 -0
- package/lib/quoxon/dragonModes.js +150 -0
- package/lib/quoxon/dragonView.js +41 -0
- package/lib/quoxon/earlyInput.js +40 -0
- package/lib/quoxon/echo.js +15 -0
- package/lib/quoxon/egg.js +2 -2
- package/lib/quoxon/events.js +244 -25
- package/lib/quoxon/feedback.js +50 -0
- package/lib/quoxon/gallery.js +21 -7
- package/lib/quoxon/handoffOffer.js +78 -0
- package/lib/quoxon/hatSwitch.js +37 -0
- package/lib/quoxon/headless.js +161 -0
- package/lib/quoxon/hostUrls.js +41 -0
- package/lib/quoxon/indicator.js +6 -2
- package/lib/quoxon/intent.js +12 -1
- package/lib/quoxon/keys.js +49 -6
- package/lib/quoxon/launcher.js +103 -0
- package/lib/quoxon/main.js +92 -160
- package/lib/quoxon/markdown.js +45 -21
- package/lib/quoxon/markdownSettle.js +19 -0
- package/lib/quoxon/matrixDomains.json +46 -0
- package/lib/quoxon/mentions.js +90 -0
- package/lib/quoxon/mind.js +103 -0
- package/lib/quoxon/mode.js +46 -0
- package/lib/quoxon/model.js +132 -26
- package/lib/quoxon/modelPrefs.js +345 -0
- package/lib/quoxon/motion/clock.js +3 -2
- package/lib/quoxon/motion/level.js +91 -0
- package/lib/quoxon/mouse.js +211 -0
- package/lib/quoxon/noai.js +57 -0
- package/lib/quoxon/objects/away.js +72 -0
- package/lib/quoxon/objects/fetch.js +17 -5
- package/lib/quoxon/objects/fleet.js +149 -0
- package/lib/quoxon/objects/health.js +186 -0
- package/lib/quoxon/objects/index.js +24 -3
- package/lib/quoxon/objects/mirror.js +137 -0
- package/lib/quoxon/objects/peek.js +17 -0
- package/lib/quoxon/objects/prove.js +130 -0
- package/lib/quoxon/objects/replay.js +125 -0
- package/lib/quoxon/objects/sitrep.js +58 -0
- package/lib/quoxon/objects/view.js +11 -0
- package/lib/quoxon/onboarding/copy.js +32 -4
- package/lib/quoxon/onboarding/credential.js +17 -4
- package/lib/quoxon/onboarding/dockerOffer.js +45 -0
- package/lib/quoxon/onboarding/failureCause.js +33 -0
- package/lib/quoxon/onboarding/finish.js +15 -0
- package/lib/quoxon/onboarding/index.js +34 -7
- package/lib/quoxon/onboarding/installRunner.js +64 -5
- package/lib/quoxon/onboarding/preflight.js +17 -3
- package/lib/quoxon/onboarding/probe.js +65 -0
- package/lib/quoxon/onboarding/view.js +76 -19
- package/lib/quoxon/orgLabel.js +10 -0
- package/lib/quoxon/orgSwitch.js +33 -0
- package/lib/quoxon/ownBroadcast.js +22 -0
- package/lib/quoxon/palette.js +43 -0
- package/lib/quoxon/print.js +9 -5
- package/lib/quoxon/profileSwitch.js +53 -0
- package/lib/quoxon/promptHistory.js +41 -0
- package/lib/quoxon/providers.js +48 -0
- package/lib/quoxon/qart.js +20 -51
- package/lib/quoxon/qdragon.js +410 -0
- package/lib/quoxon/qdragonFly.js +61 -0
- package/lib/quoxon/qdragonScenes.js +282 -0
- package/lib/quoxon/qeyes.js +213 -0
- package/lib/quoxon/qmotion.js +58 -37
- package/lib/quoxon/render.js +638 -119
- package/lib/quoxon/resend.js +72 -0
- package/lib/quoxon/roster.js +95 -9
- package/lib/quoxon/route.js +6 -5
- package/lib/quoxon/rows.js +711 -0
- package/lib/quoxon/rowsStore.js +80 -0
- package/lib/quoxon/screenContext.js +150 -0
- package/lib/quoxon/screenFrames.js +254 -0
- package/lib/quoxon/session.js +172 -2
- package/lib/quoxon/sessionDead.js +17 -0
- package/lib/quoxon/signin.js +125 -0
- package/lib/quoxon/slash/agents.js +112 -0
- package/lib/quoxon/slash/argSources.js +65 -0
- package/lib/quoxon/slash/args.js +65 -0
- package/lib/quoxon/slash/automate.js +89 -0
- package/lib/quoxon/slash/broadcast.js +140 -0
- package/lib/quoxon/slash/broadcastRun.js +108 -0
- package/lib/quoxon/slash/card.js +30 -0
- package/lib/quoxon/slash/challenge.js +114 -0
- package/lib/quoxon/slash/cliCommand.js +53 -0
- package/lib/quoxon/slash/cliLeaves.json +948 -0
- package/lib/quoxon/slash/compact.js +1 -1
- package/lib/quoxon/slash/confirm.js +30 -0
- package/lib/quoxon/slash/crystallize.js +69 -0
- package/lib/quoxon/slash/dashboard.js +136 -0
- package/lib/quoxon/slash/draft.js +54 -0
- package/lib/quoxon/slash/hatWalk.js +206 -0
- package/lib/quoxon/slash/inverses.js +77 -0
- package/lib/quoxon/slash/killer.js +24 -0
- package/lib/quoxon/slash/killerApi.js +16 -0
- package/lib/quoxon/slash/ledger.js +13 -8
- package/lib/quoxon/slash/listView.js +3 -1
- package/lib/quoxon/slash/modelCmd.js +261 -0
- package/lib/quoxon/slash/palette.js +21 -7
- package/lib/quoxon/slash/paletteController.js +117 -7
- package/lib/quoxon/slash/panels.js +21 -6
- package/lib/quoxon/slash/picker.js +20 -2
- package/lib/quoxon/slash/restart.js +71 -13
- package/lib/quoxon/slash/rowCmd.js +157 -0
- package/lib/quoxon/slash/rowWalk.js +175 -0
- package/lib/quoxon/slash/settings.js +16 -6
- package/lib/quoxon/slash/spawn.js +177 -0
- package/lib/quoxon/slash/stepUp.js +274 -0
- package/lib/quoxon/slash/stepUpCard.js +60 -0
- package/lib/quoxon/slash/stepUpCmds.js +78 -0
- package/lib/quoxon/slash/undoApi.js +38 -0
- package/lib/quoxon/slash/undoCmd.js +49 -0
- package/lib/quoxon/slash/undoWish.js +15 -0
- package/lib/quoxon/slash/watch.js +164 -0
- package/lib/quoxon/slash/wish.js +23 -0
- package/lib/quoxon/slash/wishCmd.js +32 -0
- package/lib/quoxon/slotChat.js +300 -0
- package/lib/quoxon/slotFormat.js +195 -0
- package/lib/quoxon/slotRun.js +131 -0
- package/lib/quoxon/sound/mapping.js +2 -1
- package/lib/quoxon/stray.js +22 -0
- package/lib/quoxon/suggestCommand.js +59 -0
- package/lib/quoxon/term.js +46 -11
- package/lib/quoxon/textBuffer.js +176 -2
- package/lib/quoxon/theme.js +58 -25
- package/lib/quoxon/tips.js +433 -0
- package/lib/quoxon/toolError.js +41 -0
- package/lib/quoxon/transcript.js +42 -4
- package/lib/quoxon/web/qface.js +18 -10
- package/lib/quoxon/welcome.js +1 -1
- package/lib/quoxon/whatsnew.js +136 -0
- package/lib/quoxon/whatsnew.jsonl +89 -0
- package/lib/quoxon/windowOrg.js +113 -0
- package/lib/server/diag.js +129 -0
- package/lib/server/doctor.js +71 -0
- package/lib/server/install.js +149 -0
- package/lib/server/pluginEnable.js +161 -0
- package/lib/server/skew.js +56 -0
- package/lib/server/snapshot.js +131 -0
- package/lib/server/status.js +128 -0
- package/lib/server/sys.js +65 -0
- package/lib/server/uninstall.js +63 -0
- package/lib/server/update.js +234 -0
- package/lib/toolOutputShape.js +315 -0
- package/lib/tui/views/HyperionDeck.jsx +79 -0
- package/lib/tui/views/LucidDeck.jsx +209 -217
- package/package.json +4 -1
- package/lib/core/websiteSession.js +0 -148
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* quox ask helpers: parse the option list and durations, and wait (bounded long-poll) for a human's answer.
|
|
3
|
+
* Pure of the CLI framework so the exit codes are unit-testable. Exit codes: 0 answered, 2 expired or denied.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const EXIT_ANSWERED = 0;
|
|
7
|
+
export const EXIT_NO_ANSWER = 2;
|
|
8
|
+
|
|
9
|
+
/** "a: Ship now|b: Wait|c: Drop" -> [{key,label}]. A bare "a|b" gives key=label. Throws with a usable message. */
|
|
10
|
+
export function parseOptions(text) {
|
|
11
|
+
const parts = String(text || '').split('|').map((s) => s.trim()).filter(Boolean);
|
|
12
|
+
if (parts.length < 2) throw new Error('--options needs at least two, e.g. "a: Ship now|b: Wait for review|c: Drop it" (or use --yn)');
|
|
13
|
+
return parts.map((p) => {
|
|
14
|
+
const m = /^([A-Za-z0-9]{1,8})\s*[:=]\s*(.*)$/.exec(p);
|
|
15
|
+
if (m) return { key: m[1].toLowerCase(), label: m[2].trim() || m[1] };
|
|
16
|
+
if (/^[A-Za-z0-9]{1,8}$/.test(p)) return { key: p.toLowerCase(), label: p };
|
|
17
|
+
throw new Error(`could not read option "${p}": write it as key: label, e.g. "a: Ship now"`);
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const YN_OPTIONS = [{ key: 'y', label: 'Yes' }, { key: 'n', label: 'No' }];
|
|
22
|
+
|
|
23
|
+
/** "90s" | "10m" | "2h" | "1d" | bare seconds -> seconds. */
|
|
24
|
+
export function parseDuration(text) {
|
|
25
|
+
const m = /^(\d+(?:\.\d+)?)\s*([smhd]?)$/i.exec(String(text || '').trim());
|
|
26
|
+
if (!m) throw new Error(`could not read duration "${text}": use 90s, 10m, 2h or 1d`);
|
|
27
|
+
const mult = { '': 1, s: 1, m: 60, h: 3600, d: 86400 }[m[2].toLowerCase()];
|
|
28
|
+
return Math.round(Number(m[1]) * mult);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const fmtLeft = (ms) => { const m = Math.max(0, Math.round(ms / 60000)); return m >= 120 ? `${Math.round(m / 60)}h` : m >= 1 ? `${m}m` : '<1m'; };
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Long-poll a decision until it is no longer pending.
|
|
35
|
+
* @param {{poll:(waitSeconds:number)=>Promise<object>, log:(s:string)=>void, sleep?:Function, now?:()=>number, maxWaitMs?:number|null, pollSeconds?:number}} o
|
|
36
|
+
* @returns {Promise<{code:number, stdout:string, decision:object|null}>} stdout = chosen option key (+ note line) only when answered.
|
|
37
|
+
*/
|
|
38
|
+
export async function waitForDecision({ poll, log, now = Date.now, maxWaitMs = null, pollSeconds = 25 }) {
|
|
39
|
+
const start = now();
|
|
40
|
+
let errors = 0;
|
|
41
|
+
for (;;) {
|
|
42
|
+
let d;
|
|
43
|
+
try { d = (await poll(pollSeconds)).decision; errors = 0; }
|
|
44
|
+
catch (e) {
|
|
45
|
+
if (e?.status === 404 || e?.status === 403 || e?.status === 401) throw e; // not a blip: a decision we cannot see
|
|
46
|
+
if (++errors >= 5) throw e;
|
|
47
|
+
log(`could not reach the server (${e?.message || 'failed'}), retrying (${errors}/5)`);
|
|
48
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (!d) throw new Error('the server returned no decision');
|
|
52
|
+
if (d.state === 'answered') return { code: EXIT_ANSWERED, stdout: `${d.answer?.option ?? ''}${d.answer?.note ? `\n${d.answer.note}` : ''}`, decision: d };
|
|
53
|
+
if (d.state === 'denied') { log(`denied${d.answer?.note ? `: ${d.answer.note}` : ''}`); return { code: EXIT_NO_ANSWER, stdout: '', decision: d }; }
|
|
54
|
+
if (d.state === 'expired' || d.state === 'cancelled') { log(`${d.state}, no answer${d.default ? ` (default was ${d.default}; not applied)` : ''}`); return { code: EXIT_NO_ANSWER, stdout: '', decision: d }; }
|
|
55
|
+
const elapsed = now() - start;
|
|
56
|
+
const left = d.expires_at ? Date.parse(d.expires_at) - now() : null;
|
|
57
|
+
log(`still waiting for a human (${d.session || 'session'}, ${fmtLeft(elapsed)} elapsed${left != null ? `, ${fmtLeft(left)} left` : ''})`);
|
|
58
|
+
if (maxWaitMs != null && elapsed >= maxWaitMs) { log('gave up waiting (--timeout); the question is still open: quox ask wait ' + d.id); return { code: EXIT_NO_ANSWER, stdout: '', decision: d }; }
|
|
59
|
+
}
|
|
60
|
+
}
|
package/lib/core/auth.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* Tokens stored in config profile's auth field.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { readFileSync, statSync, openSync, closeSync, unlinkSync } from 'node:fs';
|
|
8
9
|
import { QuoxAuthError, QuoxApiError, QuoxTransientAuthError } from './errors.js';
|
|
9
10
|
import { loadResidentProfile, residentLogin, resolveResidentBaseUrl } from './residentAuth.js';
|
|
10
11
|
|
|
@@ -94,11 +95,79 @@ function residentProfileHint(config, deadOrgId = null) {
|
|
|
94
95
|
}
|
|
95
96
|
}
|
|
96
97
|
|
|
98
|
+
// QUOX_TOKEN_FILE: the W4 builder principal (SDK-TEAM design v2, 2.1.3) runs
|
|
99
|
+
// on ONE agent token that an owner-side helper writes to a file and rewrites
|
|
100
|
+
// near expiry. The builder holds no refresh token, no user session and no
|
|
101
|
+
// service key, so this mode is exclusive and fails CLOSED: the file's token or
|
|
102
|
+
// nothing, never the profile, never an ambient QUOX_SERVICE_KEY. Its "refresh"
|
|
103
|
+
// is re-reading the file; it never calls the auth service and never writes or
|
|
104
|
+
// clears the profile.
|
|
105
|
+
function tokenFilePath() {
|
|
106
|
+
return process.env.QUOX_TOKEN_FILE || null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function readTokenFile(file) {
|
|
110
|
+
let st;
|
|
111
|
+
try {
|
|
112
|
+
st = statSync(file);
|
|
113
|
+
} catch {
|
|
114
|
+
throw new QuoxAuthError(`QUOX_TOKEN_FILE ${file} is not readable. The owner-side builder helper writes it; is the lease live?`);
|
|
115
|
+
}
|
|
116
|
+
if (st.mode & 0o004) {
|
|
117
|
+
throw new QuoxAuthError(`QUOX_TOKEN_FILE ${file} is readable by other users (mode ${(st.mode & 0o777).toString(8)}); refusing to use it. Expected 0640 or tighter.`);
|
|
118
|
+
}
|
|
119
|
+
const token = readFileSync(file, 'utf8').trim();
|
|
120
|
+
if (!token) throw new QuoxAuthError(`QUOX_TOKEN_FILE ${file} is empty.`);
|
|
121
|
+
return { access_token: token, expires_at: jwtExpiryMs(token), token_file: file };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Expiry only, for the client's "looks expired, try the refresh seam" signal.
|
|
125
|
+
// Not verification: the server verifies. Undecodable means no local expiry.
|
|
126
|
+
function jwtExpiryMs(token) {
|
|
127
|
+
try {
|
|
128
|
+
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString('utf8'));
|
|
129
|
+
return typeof payload.exp === 'number' ? payload.exp * 1000 : null;
|
|
130
|
+
} catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Keep the last login email on the profile (not a secret) so a signed-out session can prefill /login. */
|
|
136
|
+
export function rememberEmail(config, email) {
|
|
137
|
+
try {
|
|
138
|
+
const prof = config.getActiveProfile?.();
|
|
139
|
+
if (prof && email && prof.last_email !== email) { prof.last_email = email; config.save?.(); }
|
|
140
|
+
} catch { /* best-effort */ }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Cross-process refresh lock (wx file next to the credentials). Returns a release function. Never throws:
|
|
144
|
+
// after the wait it proceeds unlocked, because a stuck lock must not sign anyone out.
|
|
145
|
+
async function acquireRefreshLock(file, { staleMs = 20000, waitMs = 20000 } = {}) {
|
|
146
|
+
const deadline = Date.now() + waitMs;
|
|
147
|
+
for (;;) {
|
|
148
|
+
try {
|
|
149
|
+
closeSync(openSync(file, 'wx', 0o600));
|
|
150
|
+
return () => { try { unlinkSync(file); } catch { /* already gone */ } };
|
|
151
|
+
} catch (e) {
|
|
152
|
+
if (e.code !== 'EEXIST') return () => {};
|
|
153
|
+
try {
|
|
154
|
+
if (Date.now() - statSync(file).mtimeMs > staleMs) { try { unlinkSync(file); } catch { /* raced */ } continue; }
|
|
155
|
+
} catch { continue; }
|
|
156
|
+
if (Date.now() > deadline) return () => {};
|
|
157
|
+
await new Promise(r => setTimeout(r, 25));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
97
162
|
export function createAuth(config) {
|
|
98
163
|
return {
|
|
99
164
|
async login(baseUrl, email, password, orgId) {
|
|
165
|
+
if (tokenFilePath()) {
|
|
166
|
+
throw new QuoxAuthError('QUOX_TOKEN_FILE is set: this principal runs on its issued token only and cannot log in as a user.');
|
|
167
|
+
}
|
|
100
168
|
const url = `${baseUrl}/api/auth/login`;
|
|
101
|
-
|
|
169
|
+
// client: 'cli' marks this a CLI/TUI sign-in so the server gives it the longer CLI session window.
|
|
170
|
+
const body = { email, password, client: 'cli' };
|
|
102
171
|
if (orgId) body.org_id = orgId;
|
|
103
172
|
|
|
104
173
|
const res = await fetch(url, {
|
|
@@ -129,6 +198,7 @@ export function createAuth(config) {
|
|
|
129
198
|
};
|
|
130
199
|
|
|
131
200
|
config.setAuth(authData);
|
|
201
|
+
rememberEmail(config, email);
|
|
132
202
|
return { ...data, authData };
|
|
133
203
|
},
|
|
134
204
|
|
|
@@ -175,7 +245,52 @@ export function createAuth(config) {
|
|
|
175
245
|
config.clearAuth();
|
|
176
246
|
},
|
|
177
247
|
|
|
248
|
+
/**
|
|
249
|
+
* Refresh the session. Stale-sign-in safe: serialised across processes by a lock file next to the
|
|
250
|
+
* credentials, and it adopts credentials another window or `quox login` wrote since this process
|
|
251
|
+
* loaded. A fresh token on disk is reused with no server call (so two windows never spend one
|
|
252
|
+
* rotating refresh token twice); a rejected refresh re-reads disk and retries ONCE if it changed.
|
|
253
|
+
*/
|
|
178
254
|
async refresh(baseUrl) {
|
|
255
|
+
if (tokenFilePath() || typeof config.reloadAuth !== 'function' || typeof config.refreshLockFile !== 'function') {
|
|
256
|
+
return this._refreshOnce(baseUrl);
|
|
257
|
+
}
|
|
258
|
+
const lock = await acquireRefreshLock(config.refreshLockFile());
|
|
259
|
+
try {
|
|
260
|
+
if (config.reloadAuth() && config.getAuth()?.access_token && !this.isExpired()) {
|
|
261
|
+
return { access_token: config.getAuth().access_token, reused: true };
|
|
262
|
+
}
|
|
263
|
+
const sent = config.getAuth()?.refresh_token;
|
|
264
|
+
try {
|
|
265
|
+
return await this._refreshOnce(baseUrl);
|
|
266
|
+
} catch (e) {
|
|
267
|
+
if (e?.code === 'session_expired') {
|
|
268
|
+
config.reloadAuth();
|
|
269
|
+
const now = config.getAuth();
|
|
270
|
+
if (now?.refresh_token && now.refresh_token !== sent) {
|
|
271
|
+
if (!this.isExpired()) return { access_token: now.access_token, reused: true };
|
|
272
|
+
return await this._refreshOnce(baseUrl);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
throw e;
|
|
276
|
+
}
|
|
277
|
+
} finally {
|
|
278
|
+
lock();
|
|
279
|
+
}
|
|
280
|
+
},
|
|
281
|
+
|
|
282
|
+
async _refreshOnce(baseUrl) {
|
|
283
|
+
const tokenFile = tokenFilePath();
|
|
284
|
+
if (tokenFile) {
|
|
285
|
+
const before = this._tokenFileLast;
|
|
286
|
+
const now = readTokenFile(tokenFile);
|
|
287
|
+
const stillExpired = now.expires_at && Date.now() > now.expires_at;
|
|
288
|
+
if (stillExpired || (before && now.access_token === before)) {
|
|
289
|
+
throw new QuoxAuthError(`QUOX_TOKEN_FILE ${tokenFile} has not been renewed; the owner-side helper re-mints it. Not falling back to any other credential.`);
|
|
290
|
+
}
|
|
291
|
+
this._tokenFileLast = now.access_token;
|
|
292
|
+
return { access_token: now.access_token };
|
|
293
|
+
}
|
|
179
294
|
const auth = config.getAuth();
|
|
180
295
|
|
|
181
296
|
// Resident agents authenticate via a signed challenge, not a refresh
|
|
@@ -199,14 +314,24 @@ export function createAuth(config) {
|
|
|
199
314
|
// 2026-08-24: header -> no_refresh_token, body -> invalid_refresh_token.
|
|
200
315
|
// The header is kept because it costs nothing and other deployments may
|
|
201
316
|
// read it.
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
317
|
+
// QUOXON-FW12: a timeout or network error says nothing about the token.
|
|
318
|
+
// It is TRANSIENT (tokens kept, caller backs off and retries), never
|
|
319
|
+
// "signed out". 2026-10-14 02:50: a slow auth service signed the owner out
|
|
320
|
+
// of a session the server still held active.
|
|
321
|
+
let res;
|
|
322
|
+
try {
|
|
323
|
+
res = await fetch(`${baseUrl}/api/auth/refresh`, {
|
|
324
|
+
method: 'POST',
|
|
325
|
+
headers: {
|
|
326
|
+
'Content-Type': 'application/json',
|
|
327
|
+
'Authorization': `Bearer ${auth.refresh_token}`,
|
|
328
|
+
},
|
|
329
|
+
body: JSON.stringify({ refresh_token: auth.refresh_token }),
|
|
330
|
+
signal: AbortSignal.timeout(15000),
|
|
331
|
+
});
|
|
332
|
+
} catch (e) {
|
|
333
|
+
throw new QuoxTransientAuthError(`Quox server is slow or unreachable (${e?.name === 'TimeoutError' ? 'refresh timed out' : (e?.cause?.code || e?.message || 'network error')}). Stored session KEPT; retrying.`);
|
|
334
|
+
}
|
|
210
335
|
|
|
211
336
|
let data = null;
|
|
212
337
|
try { data = await res.json(); } catch { /* non-JSON error body */ }
|
|
@@ -221,7 +346,7 @@ export function createAuth(config) {
|
|
|
221
346
|
// fault into a lost session, which is exactly how the header/body bug
|
|
222
347
|
// above cost three re-logins in one night before it was found.
|
|
223
348
|
if ((data && data.error) === 'no_refresh_token') {
|
|
224
|
-
throw new
|
|
349
|
+
throw new QuoxTransientAuthError(
|
|
225
350
|
'The auth service received no refresh token, so it could not tell whether yours is valid. '
|
|
226
351
|
+ 'Stored session KEPT. This is a client/transport fault, not an expiry — retry, and report it if it persists.'
|
|
227
352
|
);
|
|
@@ -254,18 +379,25 @@ export function createAuth(config) {
|
|
|
254
379
|
healthy = false;
|
|
255
380
|
}
|
|
256
381
|
if (!healthy) {
|
|
257
|
-
throw new
|
|
382
|
+
throw new QuoxTransientAuthError(
|
|
258
383
|
`Auth service is not healthy right now (refresh returned ${res.status} but /health did not answer OK). ` +
|
|
259
384
|
`Stored session KEPT — this is very likely a restart or deploy in progress. Retry in a moment.`
|
|
260
385
|
);
|
|
261
386
|
}
|
|
262
387
|
config.clearAuth({ refresh: true });
|
|
263
|
-
|
|
388
|
+
const dead = new QuoxAuthError(
|
|
264
389
|
`Session expired (refresh rejected: ${(data && data.error) || res.status}). Please login again.`
|
|
265
390
|
+ residentProfileHint(config, auth?.org_id),
|
|
266
391
|
);
|
|
392
|
+
// Machine-readable, so the TUI can say WHY (idle_timeout | max_session, from the server's session policy).
|
|
393
|
+
dead.code = 'session_expired';
|
|
394
|
+
// A revoke carries no `reason`; keep the error so the card can still say why (SIGNOUT-WIPE).
|
|
395
|
+
dead.reason = (data && data.reason) || ((data && data.error) === 'session_revoked' ? 'session_revoked' : null);
|
|
396
|
+
throw dead;
|
|
267
397
|
}
|
|
268
|
-
|
|
398
|
+
// 5xx / 429 / anything not a definitive 401/403: the token was never judged.
|
|
399
|
+
const ra = Number(res.headers?.get?.('retry-after'));
|
|
400
|
+
throw new QuoxTransientAuthError(`Token refresh failed (${res.status}); stored session kept. Retry, or login again if this persists.`, Number.isFinite(ra) && ra > 0 ? ra * 1000 : null);
|
|
269
401
|
}
|
|
270
402
|
|
|
271
403
|
auth.access_token = data.access_token;
|
|
@@ -279,10 +411,20 @@ export function createAuth(config) {
|
|
|
279
411
|
},
|
|
280
412
|
|
|
281
413
|
getTokens() {
|
|
414
|
+
const tokenFile = tokenFilePath();
|
|
415
|
+
if (tokenFile) {
|
|
416
|
+
const t = readTokenFile(tokenFile);
|
|
417
|
+
this._tokenFileLast = t.access_token;
|
|
418
|
+
return t;
|
|
419
|
+
}
|
|
282
420
|
return config.getAuth();
|
|
283
421
|
},
|
|
284
422
|
|
|
285
423
|
isLoggedIn() {
|
|
424
|
+
const tokenFile = tokenFilePath();
|
|
425
|
+
if (tokenFile) {
|
|
426
|
+
try { readTokenFile(tokenFile); return true; } catch { return false; }
|
|
427
|
+
}
|
|
286
428
|
// Precedence, matching client.js: a stored USER token is what we
|
|
287
429
|
// actually authenticate with, so it decides this answer. A configured
|
|
288
430
|
// QUOX_SERVICE_KEY still counts as authenticated on its own, but only
|
|
@@ -300,6 +442,15 @@ export function createAuth(config) {
|
|
|
300
442
|
},
|
|
301
443
|
|
|
302
444
|
isExpired() {
|
|
445
|
+
const tokenFile = tokenFilePath();
|
|
446
|
+
if (tokenFile) {
|
|
447
|
+
try {
|
|
448
|
+
const { expires_at } = readTokenFile(tokenFile);
|
|
449
|
+
return expires_at ? Date.now() > expires_at : false;
|
|
450
|
+
} catch {
|
|
451
|
+
return true;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
303
454
|
// A forced service key is not a locally-tracked JWT: never "expired".
|
|
304
455
|
if (process.env.QUOX_FORCE_SERVICE_KEY && process.env.QUOX_SERVICE_KEY) return false;
|
|
305
456
|
// A stored user token is the credential in play (see isLoggedIn), so
|
package/lib/core/callerSource.js
CHANGED
|
@@ -22,7 +22,7 @@ import { join, basename } from 'node:path';
|
|
|
22
22
|
export const APPROVAL_GATED_TOOLS = new Set([
|
|
23
23
|
'loop_halt', 'slot_assign', 'bob_dispatch_build', 'email_send', 'send_notification',
|
|
24
24
|
'remote_ssh', 'ssh_exec', 'db_query', 'alerts_manage', 'backup_run',
|
|
25
|
-
'keeper_backup_create', 'deploy_template',
|
|
25
|
+
'keeper_backup_create', 'deploy_template',
|
|
26
26
|
'system_admin', 'proxmox_vm_action', 'calendar_event_create',
|
|
27
27
|
]);
|
|
28
28
|
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KEEPER-REEXEC: which quox build is installed, read from files only (cheap enough to check on
|
|
3
|
+
* every keeper control poll, no git or child process). A git checkout (the dev box runs quox from
|
|
4
|
+
* a detached worktree) reports its HEAD sha; an npm install reports `v<version>`. null = unknown,
|
|
5
|
+
* and an unknown build never triggers a re-exec or a takeover.
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync, existsSync, statSync } from 'node:fs';
|
|
8
|
+
import { dirname, join, resolve, isAbsolute } from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
|
|
11
|
+
const SHA_RE = /^[0-9a-f]{40,64}$/;
|
|
12
|
+
const read = (p) => { try { return readFileSync(p, 'utf-8').trim(); } catch { return null; } };
|
|
13
|
+
|
|
14
|
+
function gitDirFor(root) {
|
|
15
|
+
const dotGit = join(root, '.git');
|
|
16
|
+
if (!existsSync(dotGit)) return null;
|
|
17
|
+
try {
|
|
18
|
+
if (statSync(dotGit).isDirectory()) return dotGit;
|
|
19
|
+
} catch { return null; }
|
|
20
|
+
const m = /^gitdir:\s*(.+)$/m.exec(read(dotGit) || '');
|
|
21
|
+
if (!m) return null;
|
|
22
|
+
return isAbsolute(m[1]) ? m[1] : resolve(root, m[1]);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function resolveRef(gitDir, ref) {
|
|
26
|
+
const loose = read(join(gitDir, ref));
|
|
27
|
+
if (loose && SHA_RE.test(loose)) return loose;
|
|
28
|
+
const commonRel = read(join(gitDir, 'commondir'));
|
|
29
|
+
const common = commonRel ? (isAbsolute(commonRel) ? commonRel : resolve(gitDir, commonRel)) : null;
|
|
30
|
+
if (common) {
|
|
31
|
+
const c = read(join(common, ref));
|
|
32
|
+
if (c && SHA_RE.test(c)) return c;
|
|
33
|
+
}
|
|
34
|
+
for (const d of [gitDir, common].filter(Boolean)) {
|
|
35
|
+
const packed = read(join(d, 'packed-refs')) || '';
|
|
36
|
+
for (const line of packed.split('\n')) {
|
|
37
|
+
const [sha, name] = line.trim().split(/\s+/);
|
|
38
|
+
if (name === ref && SHA_RE.test(sha)) return sha;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** cliDir = the CLI package dir (holds package.json); its parent may be the git root. */
|
|
45
|
+
export function readInstalledBuild(cliDir) {
|
|
46
|
+
try {
|
|
47
|
+
for (const root of [cliDir, dirname(cliDir)]) {
|
|
48
|
+
const gd = gitDirFor(root);
|
|
49
|
+
if (!gd) continue;
|
|
50
|
+
const head = read(join(gd, 'HEAD'));
|
|
51
|
+
if (!head) continue;
|
|
52
|
+
if (SHA_RE.test(head)) return head;
|
|
53
|
+
const m = /^ref:\s*(\S+)$/.exec(head);
|
|
54
|
+
if (m) { const sha = resolveRef(gd, m[1]); if (sha) return sha; }
|
|
55
|
+
}
|
|
56
|
+
const pkg = JSON.parse(readFileSync(join(cliDir, 'package.json'), 'utf-8'));
|
|
57
|
+
return pkg && pkg.version ? `v${pkg.version}` : null;
|
|
58
|
+
} catch { return null; }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The CLI package this module was loaded from (cli/lib/core -> cli). */
|
|
62
|
+
export const CLI_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
|