quoxcli 0.3.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +61 -0
- package/README.md +2 -2
- package/bin/quox.js +19 -1
- package/bin/quoxon.js +6 -1
- package/lib/api/endpoints/actions.js +15 -0
- package/lib/api/endpoints/admin.js +27 -0
- package/lib/api/endpoints/agent-attach.js +7 -0
- package/lib/api/endpoints/blog.js +16 -0
- package/lib/api/endpoints/chat.js +7 -0
- package/lib/api/endpoints/compliance.js +25 -0
- package/lib/api/endpoints/contactsq.js +4 -0
- package/lib/api/endpoints/devServerRegistry.js +25 -0
- package/lib/api/endpoints/engagement.js +26 -0
- package/lib/api/endpoints/evidence.js +20 -0
- package/lib/api/endpoints/hat.js +14 -0
- package/lib/api/endpoints/inbox.js +6 -0
- package/lib/api/endpoints/inboxq.js +19 -0
- package/lib/api/endpoints/index.js +14 -0
- package/lib/api/endpoints/loops.js +26 -0
- package/lib/api/endpoints/mon.js +295 -0
- package/lib/api/endpoints/objectives.js +6 -0
- package/lib/api/endpoints/quoxseo.js +6 -0
- package/lib/api/endpoints/server.js +21 -0
- package/lib/api/endpoints/setup.js +6 -0
- package/lib/api/endpoints/tools.js +13 -0
- package/lib/api/endpoints/v1.js +47 -0
- package/lib/commands/actions.js +81 -11
- package/lib/commands/admin.js +35 -0
- package/lib/commands/agent.js +11 -4
- package/lib/commands/agentAttach.js +61 -7
- package/lib/commands/approvals.js +198 -3
- package/lib/commands/ask.js +4 -2
- package/lib/commands/auth.js +64 -11
- package/lib/commands/backup.js +11 -4
- package/lib/commands/blog.js +110 -0
- package/lib/commands/capabilities.js +3 -2
- package/lib/commands/chat.js +142 -28
- package/lib/commands/chatCompanion.js +27 -0
- package/lib/commands/chatState.js +45 -0
- package/lib/commands/claude.js +7 -33
- package/lib/commands/compliance.js +109 -0
- package/lib/commands/connect.js +69 -0
- package/lib/commands/contacts.js +85 -4
- package/lib/commands/contactsSendAt.js +21 -0
- package/lib/commands/dev.js +6 -8
- package/lib/commands/doctor.js +22 -3
- package/lib/commands/engagement.js +90 -0
- package/lib/commands/evidence.js +155 -0
- package/lib/commands/ext.js +23 -0
- package/lib/commands/fleet.js +25 -0
- package/lib/commands/gripe.js +24 -13
- package/lib/commands/hat.js +76 -0
- package/lib/commands/image.js +68 -0
- package/lib/commands/inbox.js +37 -3
- package/lib/commands/inboxq.js +147 -0
- package/lib/commands/kuma.js +1123 -0
- package/lib/commands/kumaSla.js +174 -0
- package/lib/commands/licence.js +39 -1
- package/lib/commands/licenceLinkView.js +311 -0
- package/lib/commands/linkedin.js +19 -0
- package/lib/commands/loop.js +318 -0
- package/lib/commands/lucid.js +1 -0
- package/lib/commands/mail.js +161 -20
- package/lib/commands/memory.js +68 -4
- package/lib/commands/memorySync.js +158 -0
- package/lib/commands/mon.js +55 -144
- package/lib/commands/notes.js +29 -7
- package/lib/commands/notesSeal.js +259 -0
- package/lib/commands/objective.js +53 -3
- package/lib/commands/offload.js +2 -3
- package/lib/commands/org.js +21 -4
- package/lib/commands/plan.js +17 -0
- package/lib/commands/provider.js +87 -8
- package/lib/commands/qlarity.js +32 -1
- package/lib/commands/quoxseo.js +31 -0
- package/lib/commands/research.js +79 -0
- package/lib/commands/rows.js +9 -0
- package/lib/commands/run.js +20 -2
- package/lib/commands/scope.js +2 -2
- package/lib/commands/server.js +150 -0
- package/lib/commands/setup.js +127 -75
- package/lib/commands/shortcut.js +41 -0
- package/lib/commands/skills.js +24 -5
- package/lib/commands/slot.js +49 -9
- package/lib/commands/status.js +92 -44
- package/lib/commands/tool.js +30 -0
- package/lib/commands/vault.js +6 -3
- package/lib/commands/vm.js +2 -2
- package/lib/commands/walkthrough.js +14 -2
- package/lib/commands/watch.js +11 -2
- package/lib/commands/wazuh.js +824 -0
- package/lib/commands/wazuhIntake.js +317 -0
- package/lib/commands/wazuhReport.js +192 -0
- package/lib/commands/workflow.js +47 -4
- package/lib/commands/x.js +50 -0
- package/lib/commands/youtube.js +172 -0
- package/lib/core/actionItems.js +115 -0
- package/lib/core/agentAlias.js +2 -0
- package/lib/core/agentFamilies.js +7 -0
- package/lib/core/announce.js +86 -0
- package/lib/core/auth.js +20 -2
- package/lib/core/callMe.js +118 -0
- package/lib/core/client.js +37 -3
- package/lib/core/commandRegistrars.js +17 -0
- package/lib/core/config.js +29 -3
- package/lib/core/confirm.js +68 -7
- package/lib/core/crossOrgApprovals.js +69 -0
- package/lib/core/errors.js +52 -1
- package/lib/core/findingReconcile.js +90 -0
- package/lib/core/global-flags.js +1 -1
- package/lib/core/governedSession.js +136 -8
- package/lib/core/inboxResolution.js +3 -2
- package/lib/core/liveSessionsReport.js +1 -1
- package/lib/core/loginGuard.js +73 -0
- package/lib/core/loopGlyph.js +97 -0
- package/lib/core/offload.js +73 -7
- package/lib/core/org-override.js +5 -1
- package/lib/core/output.js +8 -8
- package/lib/core/profileTrust.js +182 -0
- package/lib/core/quoxglyph.js +22 -1
- package/lib/core/router.js +14 -0
- package/lib/core/sessionHeartbeat.js +12 -8
- package/lib/core/shortcut.js +83 -0
- package/lib/core/stepUpPrompt.js +99 -0
- package/lib/core/version.js +1 -1
- package/lib/core/vm.js +5 -0
- package/lib/core/walkthrough.js +9 -5
- package/lib/dragon/companionView.js +34 -0
- package/lib/dragon/frames.js +118 -0
- package/lib/dragon/jobs.js +123 -0
- package/lib/dragon/renderWorker.js +15 -0
- package/lib/dragon/select.js +72 -0
- package/lib/evidence/bundleExport.js +47 -0
- package/lib/evidence/bundleVerify.js +77 -0
- package/lib/quoxon/EXTENSIONS.md +146 -0
- package/lib/quoxon/api.js +364 -27
- package/lib/quoxon/app.js +1629 -183
- package/lib/quoxon/approvalBatch.js +117 -0
- package/lib/quoxon/approvalFacts.js +64 -8
- package/lib/quoxon/approvalSentence.js +48 -0
- package/lib/quoxon/approvalsView.js +124 -7
- package/lib/quoxon/autoOpen.js +94 -0
- package/lib/quoxon/backgroundWork.js +59 -0
- package/lib/quoxon/bang.js +1 -1
- package/lib/quoxon/capabilities.js +7 -2
- package/lib/quoxon/commandShort.js +22 -3
- package/lib/quoxon/commands.js +110 -15
- package/lib/quoxon/connect.js +5 -3
- package/lib/quoxon/countdownLine.js +10 -1
- package/lib/quoxon/deck.js +30 -0
- package/lib/quoxon/deckLazy.js +58 -0
- package/lib/quoxon/demo.autoplay.js +1 -1
- package/lib/quoxon/demo.js +3 -1
- package/lib/quoxon/destructive.js +35 -0
- package/lib/quoxon/dividerAocl.js +4 -3
- package/lib/quoxon/dividerRule.js +3 -3
- package/lib/quoxon/dnd.js +71 -0
- package/lib/quoxon/dragonCompanion.js +285 -0
- package/lib/quoxon/dragonModes.js +1 -0
- package/lib/quoxon/dragonMoments.js +160 -0
- package/lib/quoxon/dragonReads.js +68 -0
- package/lib/quoxon/dragonView.js +2 -2
- package/lib/quoxon/echo.js +1 -0
- package/lib/quoxon/events.js +67 -10
- package/lib/quoxon/extensions.js +636 -0
- package/lib/quoxon/gripeQueue.js +135 -0
- package/lib/quoxon/inboxView.js +182 -0
- package/lib/quoxon/installCli.js +79 -0
- package/lib/quoxon/keys.js +4 -4
- package/lib/quoxon/loopCard.js +460 -0
- package/lib/quoxon/loopCards.js +189 -0
- package/lib/quoxon/loopStream.js +98 -0
- package/lib/quoxon/mailView.js +71 -0
- package/lib/quoxon/main.js +29 -5
- package/lib/quoxon/markdown.js +1 -1
- package/lib/quoxon/model.js +15 -0
- package/lib/quoxon/mouse.js +12 -1
- package/lib/quoxon/neverAnswered.js +20 -0
- package/lib/quoxon/objectiveOffer.js +82 -0
- package/lib/quoxon/objects/away.js +43 -5
- package/lib/quoxon/objects/index.js +22 -4
- package/lib/quoxon/objects/model.js +33 -0
- package/lib/quoxon/objects/proof.js +12 -4
- package/lib/quoxon/onboarding/connectServer.js +162 -0
- package/lib/quoxon/onboarding/copy.js +6 -0
- package/lib/quoxon/onboarding/index.js +5 -43
- package/lib/quoxon/onboarding/probe.js +20 -1
- package/lib/quoxon/onboarding/shortcutOffer.js +28 -0
- package/lib/quoxon/onboarding/view.js +16 -3
- package/lib/quoxon/orgSwitch.js +49 -1
- package/lib/quoxon/planSteps.js +51 -0
- package/lib/quoxon/print.js +8 -3
- package/lib/quoxon/profileSwitch.js +7 -0
- package/lib/quoxon/promptHistory.js +3 -1
- package/lib/quoxon/qdragonFly.js +7 -6
- package/lib/quoxon/qmotion.js +17 -0
- package/lib/quoxon/render.js +187 -41
- package/lib/quoxon/roster.js +36 -17
- package/lib/quoxon/rosterSessions.js +44 -0
- package/lib/quoxon/rows.js +244 -1
- package/lib/quoxon/screenContext.js +1 -1
- package/lib/quoxon/secretShape.js +86 -0
- package/lib/quoxon/serverChip.js +42 -0
- package/lib/quoxon/session.js +10 -0
- package/lib/quoxon/slash/agents.js +2 -1
- package/lib/quoxon/slash/argSources.js +9 -2
- package/lib/quoxon/slash/args.js +4 -2
- package/lib/quoxon/slash/broadcast.js +18 -5
- package/lib/quoxon/slash/broadcastRun.js +13 -2
- package/lib/quoxon/slash/cliCommand.js +42 -1
- package/lib/quoxon/slash/cliLeaves.json +139 -2
- package/lib/quoxon/slash/evidenceCmd.js +34 -0
- package/lib/quoxon/slash/hatWalk.js +1 -1
- package/lib/quoxon/slash/palette.js +23 -2
- package/lib/quoxon/slash/paletteController.js +10 -4
- package/lib/quoxon/slash/qpeCmd.js +35 -0
- package/lib/quoxon/slash/restart.js +31 -4
- package/lib/quoxon/slash/resume.js +18 -1
- package/lib/quoxon/slash/stepUp.js +5 -5
- package/lib/quoxon/slash/stepUpCmds.js +74 -1
- package/lib/quoxon/slash/todoCmd.js +122 -0
- package/lib/quoxon/slashSpinner.js +35 -0
- package/lib/quoxon/slotAttach.js +8 -0
- package/lib/quoxon/slotChat.js +17 -0
- package/lib/quoxon/slotFormat.js +1 -1
- package/lib/quoxon/slotOutbox.js +113 -0
- package/lib/quoxon/suggestCommand.js +34 -3
- package/lib/quoxon/term.js +25 -1
- package/lib/quoxon/toolCards.js +30 -0
- package/lib/quoxon/transcript.js +2 -1
- package/lib/quoxon/welcome.js +1 -1
- package/lib/quoxon/whatsnew.js +1 -1
- package/lib/quoxon/whatsnew.jsonl +81 -0
- package/lib/quoxon/windowOrg.js +11 -1
- package/lib/server/devServers.js +137 -0
- package/lib/server/installFlow.js +168 -0
- package/lib/server/status.js +15 -1
- package/lib/toolOutputShape.js +55 -0
- package/package.json +4 -2
- package/vendor/dragonlab/SOURCE +4 -0
- package/vendor/dragonlab/design/cipher/v1/cipher-master.svg +46 -0
- package/vendor/dragonlab/design/cipher/v2/character.mjs +49 -0
- package/vendor/dragonlab/design/cipher/v2/review.mjs +33 -0
- package/vendor/dragonlab/design/cipher/v2/watch.mjs +56 -0
- package/vendor/dragonlab/design/drago/v1/character.mjs +19 -0
- package/vendor/dragonlab/design/drago/v1/drago-master.svg +56 -0
- package/vendor/dragonlab/design/drago/v2/character.mjs +87 -0
- package/vendor/dragonlab/design/drago/v4/character.mjs +67 -0
- package/vendor/dragonlab/design/drago/v4/profile.mjs +35 -0
- package/vendor/dragonlab/design/drago/v4/review.mjs +33 -0
- package/vendor/dragonlab/design/drago/v4/watch.mjs +56 -0
- package/vendor/dragonlab/design/hyperion/v1/hyperion-master.svg +56 -0
- package/vendor/dragonlab/design/hyperion/v2/character.mjs +79 -0
- package/vendor/dragonlab/design/hyperion/v2/review.mjs +33 -0
- package/vendor/dragonlab/design/hyperion/v2/watch.mjs +56 -0
- package/vendor/dragonlab/design/lucid/recovery/arrival.mjs +22 -0
- package/vendor/dragonlab/design/lucid/recovery/character.mjs +106 -0
- package/vendor/dragonlab/design/lucid/recovery/layout.mjs +50 -0
- package/vendor/dragonlab/design/lucid/recovery/lucid-master.svg +78 -0
- package/vendor/dragonlab/design/lucid/recovery/motion.mjs +42 -0
- package/vendor/dragonlab/design/lucid/recovery/render.mjs +121 -0
- package/vendor/dragonlab/design/lucid/recovery/review.mjs +68 -0
- package/vendor/dragonlab/design/lucid/recovery/verify.mjs +84 -0
- package/vendor/dragonlab/design/lucid/recovery/watch.mjs +56 -0
- package/vendor/dragonlab/design/quoxperticus/v2/quoxperticus-master.svg +59 -0
- package/vendor/dragonlab/design/quoxperticus/v3/character.mjs +43 -0
- package/vendor/dragonlab/design/quoxperticus/v4/character.mjs +57 -0
- package/vendor/dragonlab/design/quoxperticus/v4/review.mjs +35 -0
- package/vendor/dragonlab/design/quoxperticus/v4/watch.mjs +56 -0
- package/vendor/dragonlab/design/seer/v1/seer-master.svg +53 -0
- package/vendor/dragonlab/design/seer/v2/character.mjs +88 -0
- package/vendor/dragonlab/design/seer/v3/character.mjs +33 -0
- package/vendor/dragonlab/design/seer/v4/character.mjs +66 -0
- package/vendor/dragonlab/design/seer/v4/review.mjs +33 -0
- package/vendor/dragonlab/design/seer/v4/watch.mjs +56 -0
- package/vendor/dragonlab/design/stage3/companion-review.mjs +28 -0
- package/vendor/dragonlab/design/stage3/sentinel-art.js +39 -0
- package/vendor/dragonlab/design/stage3/sentinel-braille.js +216 -0
- package/vendor/dragonlab/design/stage3/sentinel-character.js +257 -0
- package/vendor/dragonlab/design/stage3/sentinel-icon.js +103 -0
- package/vendor/dragonlab/design/stage3/sentinel-warden-eyes.js +95 -0
- package/vendor/dragonlab/design/stage3/svg-coverage.js +148 -0
- package/vendor/dragonlab/design/vulcan/v1/character.mjs +34 -0
- package/vendor/dragonlab/design/vulcan/v1/vulcan-master.svg +57 -0
- package/vendor/dragonlab/design/vulcan/v2/character.mjs +39 -0
- package/vendor/dragonlab/design/vulcan/v3/character.mjs +41 -0
- package/vendor/dragonlab/design/vulcan/v3/review.mjs +35 -0
- package/vendor/dragonlab/design/vulcan/v3/watch.mjs +56 -0
- package/vendor/dragonlab/factory/svg-rig-contour.mjs +64 -0
- package/vendor/dragonlab/factory/svg-rig.mjs +63 -0
- package/vendor/dragonlab/integration/companions-v1/characters.mjs +108 -0
- package/vendor/dragonlab/integration/companions-v1/cipher.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/companion.mjs +63 -0
- package/vendor/dragonlab/integration/companions-v1/drago.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/hyperion.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/lucid.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/quoxperticus.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/seer.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/sentinel.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/view.mjs +28 -0
- package/vendor/dragonlab/integration/companions-v1/vulcan.mjs +2 -0
- package/vendor/dragonlab/integration/hyperion-v3/activity.mjs +76 -0
- package/vendor/dragonlab/lib/power-effects.js +294 -0
- package/vendor/dragonlab/lib/power-registry.js +74 -0
- package/vendor/dragonlab/vendor/quox-dragon/qart.js +348 -0
- package/vendor/dragonlab/vendor/quox-dragon/qeyes.js +213 -0
package/lib/commands/agent.js
CHANGED
|
@@ -8,6 +8,7 @@ import { resolveOrgId } from './helpers.js';
|
|
|
8
8
|
import { resolveEndpoint } from '../api/endpoints.js';
|
|
9
9
|
import { QuoxApiError } from '../core/errors.js';
|
|
10
10
|
import { loadBundledAssistantsRoster } from '../core/agentAlias.js';
|
|
11
|
+
import { AGENT_FAMILY_IDS } from '../core/agentFamilies.js';
|
|
11
12
|
import { registerAgentAttachCommands } from './agentAttach.js';
|
|
12
13
|
|
|
13
14
|
// quox#1169: the prompt, domains and parent a user gives their own agent. Auth reads
|
|
@@ -64,6 +65,7 @@ export function registerAgentCommands(program, ctx) {
|
|
|
64
65
|
.option('--level <level>', 'filter by level (0-4)')
|
|
65
66
|
.option('--source <source>', 'registry | org | all', 'all')
|
|
66
67
|
.option('--plugin <id>', 'only agents that require this plugin')
|
|
68
|
+
.option('--family <id>', `only agents in this family (${AGENT_FAMILY_IDS.join(', ')})`)
|
|
67
69
|
.option('--search <text>', 'substring match on id, name or description')
|
|
68
70
|
.action(async (options) => {
|
|
69
71
|
auth.requireAuth();
|
|
@@ -71,6 +73,9 @@ export function registerAgentCommands(program, ctx) {
|
|
|
71
73
|
if (!['registry', 'org', 'all'].includes(source)) {
|
|
72
74
|
throw new Error(`--source must be registry, org or all (got "${options.source}")`);
|
|
73
75
|
}
|
|
76
|
+
if (options.family !== undefined && !AGENT_FAMILY_IDS.includes(options.family)) {
|
|
77
|
+
throw new Error(`unknown family "${options.family}". Valid families: ${AGENT_FAMILY_IDS.join(', ')}`);
|
|
78
|
+
}
|
|
74
79
|
const notes = [];
|
|
75
80
|
|
|
76
81
|
// Registered specialists (server roster; bundled roster if the server lacks the route).
|
|
@@ -84,7 +89,7 @@ export function registerAgentCommands(program, ctx) {
|
|
|
84
89
|
} catch (err) {
|
|
85
90
|
notes.push(`note: server registry unavailable (${err.message}); showing the bundled roster, which may be out of date.`);
|
|
86
91
|
registry = loadBundledAssistantsRoster().map(r => ({
|
|
87
|
-
agent_id: r.id, name: r.name || r.id, level: null, requires_plugin: null, tool_count: null, source: 'registry',
|
|
92
|
+
agent_id: r.id, name: r.name || r.id, level: null, requires_plugin: null, family: r.family || null, tool_count: null, source: 'registry',
|
|
88
93
|
}));
|
|
89
94
|
}
|
|
90
95
|
}
|
|
@@ -113,7 +118,7 @@ export function registerAgentCommands(program, ctx) {
|
|
|
113
118
|
const key = o.agent_id || o.id;
|
|
114
119
|
const reg = merged.get(key);
|
|
115
120
|
merged.set(key, reg
|
|
116
|
-
? { ...reg, ...o, requires_plugin: reg.requires_plugin, description: reg.description ?? o.description, tool_count: o.tool_count ?? reg.tool_count, source: 'org+registry' }
|
|
121
|
+
? { ...reg, ...o, requires_plugin: reg.requires_plugin, family: reg.family ?? null, description: reg.description ?? o.description, tool_count: o.tool_count ?? reg.tool_count, source: 'org+registry' }
|
|
117
122
|
: o);
|
|
118
123
|
}
|
|
119
124
|
|
|
@@ -121,6 +126,7 @@ export function registerAgentCommands(program, ctx) {
|
|
|
121
126
|
if (options.level !== undefined) all = all.filter(r => String(r.level) === String(options.level));
|
|
122
127
|
if (options.status) all = all.filter(r => r.status === options.status);
|
|
123
128
|
if (options.plugin) all = all.filter(r => r.requires_plugin === options.plugin);
|
|
129
|
+
if (options.family) all = all.filter(r => r.family === options.family);
|
|
124
130
|
if (options.search) {
|
|
125
131
|
const q = options.search.toLowerCase();
|
|
126
132
|
all = all.filter(r => [r.agent_id, r.name, r.description].some(v => String(v ?? '').toLowerCase().includes(q)));
|
|
@@ -130,10 +136,11 @@ export function registerAgentCommands(program, ctx) {
|
|
|
130
136
|
for (const n of notes) console.error(n);
|
|
131
137
|
const dash = (v) => (v === null || v === undefined || v === '' ? '-' : v);
|
|
132
138
|
formatOutput({
|
|
133
|
-
columns: ['agent_id', 'name', 'source', 'level', 'state', 'tool_count'],
|
|
134
|
-
headers: ['Agent ID', 'Name', 'Source', 'Level', 'Status/Plugin', 'Tools'],
|
|
139
|
+
columns: ['agent_id', 'name', 'family', 'source', 'level', 'state', 'tool_count'],
|
|
140
|
+
headers: ['Agent ID', 'Name', 'Family', 'Source', 'Level', 'Status/Plugin', 'Tools'],
|
|
135
141
|
rows: all.map(r => ({
|
|
136
142
|
...r,
|
|
143
|
+
family: dash(r.family),
|
|
137
144
|
level: dash(r.level),
|
|
138
145
|
state: r.status ? r.status : (r.requires_plugin ? `plugin:${r.requires_plugin}` : '-'),
|
|
139
146
|
tool_count: dash(r.tool_count),
|
|
@@ -435,6 +435,38 @@ export function execResultFailed(data) {
|
|
|
435
435
|
return negative(data.success) || negative(data.ok);
|
|
436
436
|
}
|
|
437
437
|
|
|
438
|
+
/**
|
|
439
|
+
* A governed tool that was held for a human returns { success:false, pending:true, approvalId }. That is not a
|
|
440
|
+
* failure: the approval is filed and waiting. Returns the approval id, or null when the result is not pending.
|
|
441
|
+
*/
|
|
442
|
+
export function execResultPending(data) {
|
|
443
|
+
if (!data || typeof data !== 'object') return null;
|
|
444
|
+
const src = data.pending === true ? data : (data.result && typeof data.result === 'object' && data.result.pending === true ? data.result : null);
|
|
445
|
+
if (!src) return null;
|
|
446
|
+
return src.approvalId || src.approval_id || (src._approval && src._approval.id) || 'unknown';
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* A governed tool held by the policy layer answers HTTP 403 with {pending:true, approvalId}: the approval IS filed.
|
|
451
|
+
* Returns that body when an API error carries one (so the caller renders awaiting approval), else null.
|
|
452
|
+
*/
|
|
453
|
+
export function pendingBodyOfError(err) {
|
|
454
|
+
const body = err && err.body && typeof err.body === 'object' ? err.body : null;
|
|
455
|
+
return body && execResultPending(body) ? body : null;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Is this 401/403 really about the attach token? 401 is an unauthenticated bearer. A 403 counts only when the server
|
|
460
|
+
* says so (attach/lease/token in its code or message); any other 403 is the server's own refusal and is shown verbatim.
|
|
461
|
+
*/
|
|
462
|
+
export function isAttachTokenError(err) {
|
|
463
|
+
const status = err && err.status;
|
|
464
|
+
if (status === 401) return true;
|
|
465
|
+
if (status !== 403) return false;
|
|
466
|
+
const text = [err && err.code, err && err.body && err.body.error, err && err.body && err.body.error_code, err && err.message].filter(Boolean).join(' ');
|
|
467
|
+
return /attach|lease|token/i.test(text);
|
|
468
|
+
}
|
|
469
|
+
|
|
438
470
|
/** The first human-readable error string a failed tool result carries, or a default. */
|
|
439
471
|
function execResultError(data) {
|
|
440
472
|
return (data && (data.error || data.error_code || data.reason || data.message)) || 'tool reported failure';
|
|
@@ -1290,6 +1322,21 @@ export function registerAgentAttachCommands(agent, ctx, program) {
|
|
|
1290
1322
|
if (program.opts().output === 'json') formatDetail(data, { format: 'json' });
|
|
1291
1323
|
});
|
|
1292
1324
|
|
|
1325
|
+
agent
|
|
1326
|
+
.command('attaches')
|
|
1327
|
+
.description('List every live agent attach in the org (agent, splinter, session, lease, heartbeat, expiry)')
|
|
1328
|
+
.action(async () => {
|
|
1329
|
+
auth.requireAuth();
|
|
1330
|
+
const ep = resolveEndpoint('agent-attach.list');
|
|
1331
|
+
const data = await client.get(ep.path, { service: ep.service });
|
|
1332
|
+
const rows = Array.isArray(data?.attaches) ? data.attaches : [];
|
|
1333
|
+
if (program.opts().output === 'json') return formatDetail(data, { format: 'json' });
|
|
1334
|
+
if (!rows.length) return printInfo('No live agent attaches in this org.');
|
|
1335
|
+
for (const a of rows) {
|
|
1336
|
+
printInfo(`${a.agent_id} ${a.splinter || 'S1'} · ${a.session_label || '(no label)'} · lease ${a.lease_id} · heartbeat ${fmtAge(a.last_heartbeat_at)} · expires ${a.expires_at || '?'}`);
|
|
1337
|
+
}
|
|
1338
|
+
});
|
|
1339
|
+
|
|
1293
1340
|
agent
|
|
1294
1341
|
.command('attach-heartbeat')
|
|
1295
1342
|
.description("Heartbeat this session's attach lease, rotating the stored token if one is issued")
|
|
@@ -1343,13 +1390,17 @@ export function registerAgentAttachCommands(agent, ctx, program) {
|
|
|
1343
1390
|
});
|
|
1344
1391
|
} catch (err) {
|
|
1345
1392
|
const status = err instanceof QuoxApiError ? err.status : 'network error';
|
|
1346
|
-
|
|
1393
|
+
const held = err instanceof QuoxApiError ? pendingBodyOfError(err) : null;
|
|
1394
|
+
if (held) {
|
|
1395
|
+
data = held; // the policy layer filed an approval: that is a result, not an error
|
|
1396
|
+
} else if (err instanceof QuoxApiError && isAttachTokenError(err)) {
|
|
1347
1397
|
throw new Error(
|
|
1348
1398
|
`POST ${ep.path} failed (${status}): the attach token for "${agentId}" is no longer valid `
|
|
1349
1399
|
+ `(expired or revoked lease). Re-attach with \`quox agent attach ${agentId}\`.`
|
|
1350
1400
|
);
|
|
1401
|
+
} else {
|
|
1402
|
+
throw new Error(`POST ${ep.path} failed (${status}): ${err.message}`);
|
|
1351
1403
|
}
|
|
1352
|
-
throw new Error(`POST ${ep.path} failed (${status}): ${err.message}`);
|
|
1353
1404
|
}
|
|
1354
1405
|
|
|
1355
1406
|
// finding (autonomous loop): the glyph must reflect the TOOL result, not
|
|
@@ -1358,14 +1409,17 @@ export function registerAgentAttachCommands(agent, ctx, program) {
|
|
|
1358
1409
|
// agent read failures as passes ([error-body-is-not-data] at the CLI
|
|
1359
1410
|
// surface). Set a non-zero exit code too so a scripted / feedback-loop
|
|
1360
1411
|
// caller can't mistake it for a pass.
|
|
1361
|
-
const
|
|
1412
|
+
const pendingId = execResultPending(data);
|
|
1413
|
+
const toolOk = pendingId ? true : !execResultFailed(data);
|
|
1362
1414
|
// Skip the announce line under --json/-o json: a scripted caller parses
|
|
1363
1415
|
// stdout as a single JSON document, and the glyph line would break that.
|
|
1364
1416
|
if (program.opts().output !== 'json') {
|
|
1365
|
-
printEvent(line('quox', `exec ${options.tool}`,
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1417
|
+
printEvent(line('quox', `exec ${options.tool}`, pendingId
|
|
1418
|
+
? { result: `${agentId} · awaiting approval ${pendingId}`, status: 'busy' }
|
|
1419
|
+
: {
|
|
1420
|
+
result: toolOk ? agentId : `${agentId} · ${execResultError(data)}`,
|
|
1421
|
+
status: toolOk ? 'ok' : 'fail',
|
|
1422
|
+
}));
|
|
1369
1423
|
}
|
|
1370
1424
|
formatDetail(data, { format: program.opts().output, title: `Tool result: ${options.tool}` });
|
|
1371
1425
|
if (!toolOk) process.exitCode = 1;
|
|
@@ -8,9 +8,23 @@
|
|
|
8
8
|
import { resolveEndpoint } from '../api/endpoints.js';
|
|
9
9
|
|
|
10
10
|
import { attachWalkthrough } from './walkthrough.js';
|
|
11
|
+
import { resolveOrgId } from './helpers.js';
|
|
12
|
+
import { confirmInteractive, askTyped } from '../core/confirm.js';
|
|
13
|
+
import { QuoxUserError } from '../core/errors.js';
|
|
14
|
+
import { announce, announceFailure, announceOnFail } from '../core/announce.js';
|
|
15
|
+
import { withStepUp } from '../core/stepUpPrompt.js';
|
|
16
|
+
import { formatOutput, printSuccess } from '../core/output.js';
|
|
17
|
+
import { needsPin } from '../quoxon/approvalsView.js';
|
|
18
|
+
import { describeInboxItem, approvalSourceLabel } from '../quoxon/events.js';
|
|
19
|
+
import { runCallMe, statusLines } from '../core/callMe.js';
|
|
20
|
+
import { isDestructive, destructiveObject } from '../quoxon/destructive.js';
|
|
21
|
+
import { otherOrgsWaiting, crossOrgLines, clientForTarget } from '../core/crossOrgApprovals.js';
|
|
11
22
|
|
|
12
23
|
export function registerApprovalsCommands(program, ctx) {
|
|
13
|
-
const { client, auth } = ctx;
|
|
24
|
+
const { client: baseClient, auth, clientForOrg } = ctx;
|
|
25
|
+
// The client for the org a verb targets: --org another org (a verb in CROSS_ORG, see core/org-override.js) uses that org's own token.
|
|
26
|
+
const clientFor = (orgId) => clientForTarget({ client: baseClient, clientForOrg, sessionOrgId: auth.getOrgId(), orgId });
|
|
27
|
+
const client = baseClient; // `result`, grants and the rest stay on the session org
|
|
14
28
|
|
|
15
29
|
const approvals = program
|
|
16
30
|
.command('approvals')
|
|
@@ -34,6 +48,15 @@ export function registerApprovalsCommands(program, ctx) {
|
|
|
34
48
|
}
|
|
35
49
|
console.log(`Approval ${data.approval_id} tool: ${data.tool} status: ${data.status}`
|
|
36
50
|
+ (data.executed_at ? ` executed: ${data.executed_at}` : ''));
|
|
51
|
+
if (data.decided_by) {
|
|
52
|
+
const who = data.decided_by.name || data.decided_by.id;
|
|
53
|
+
const via = data.decided_via && data.decided_via.channel ? ` via ${data.decided_via.channel}` : '';
|
|
54
|
+
console.log(`decided by ${who} (${data.decided_by.actor_type || 'user'})${via} at ${data.decided_at || 'unknown time'}`);
|
|
55
|
+
}
|
|
56
|
+
if (data.human_decision) {
|
|
57
|
+
const hd = { yes: 'yes (a person approved it with a verified step-up PIN)', no: 'no (not human-verified: denied, withdrawn, expired, approved by an agent, or approved without a verified PIN)', pending: 'pending' }[data.human_decision] || data.human_decision;
|
|
58
|
+
console.log(`human decision: ${hd}`);
|
|
59
|
+
}
|
|
37
60
|
if (data.result === null || data.result === undefined) {
|
|
38
61
|
if (data.status === 'pending') console.log('Still pending: a human has not decided yet.');
|
|
39
62
|
else if (data.status === 'resuming') console.log('Approved, executing. Re-run shortly.');
|
|
@@ -48,6 +71,26 @@ export function registerApprovalsCommands(program, ctx) {
|
|
|
48
71
|
else console.log(JSON.stringify(r, null, 2));
|
|
49
72
|
});
|
|
50
73
|
|
|
74
|
+
// Call-me: the switch for the ApprovalsQ watcher that phones a human when a high-risk
|
|
75
|
+
// approval sits unanswered. It never approves anything; the call only asks the human.
|
|
76
|
+
approvals
|
|
77
|
+
.command('call-me [action]')
|
|
78
|
+
.description('Phone me when a high-risk approval sits unanswered: status (default), on or off (human org admin only)')
|
|
79
|
+
.option('--after <minutes>', 'ring after this many minutes unanswered (1-1440)')
|
|
80
|
+
.option('--quiet <HH:MM-HH:MM>', 'quiet hours, no calls in this window (e.g. 22:00-08:00)')
|
|
81
|
+
.action(announceOnFail('quox', 'approvals call-me', async (action, options) => {
|
|
82
|
+
auth.requireAuth();
|
|
83
|
+
const act = action || 'status';
|
|
84
|
+
if (act === 'status' && (options.after || options.quiet)) {
|
|
85
|
+
throw new QuoxUserError('--after and --quiet change the policy: use `quox approvals call-me on --after 10` (or off).', { code: 'bad_args' });
|
|
86
|
+
}
|
|
87
|
+
const { data, changed } = await runCallMe(client, { action: act, after: options.after, quiet: options.quiet });
|
|
88
|
+
if (program.opts().output === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
|
|
89
|
+
const p = (data && data.policy) || {};
|
|
90
|
+
announce('quox', 'approvals call-me', { result: `${changed ? 'set ' : ''}${p.enabled ? `ON after ${p.age_threshold_minutes} min` : 'OFF'}` });
|
|
91
|
+
for (const l of statusLines(data)) console.log(l);
|
|
92
|
+
}));
|
|
93
|
+
|
|
51
94
|
// Fleet standing grants: an org admin pre-approves ONE host + an anchored command
|
|
52
95
|
// shape + a VMID range until an expiry, for ssh_exec. Every use still lands in
|
|
53
96
|
// approvals history with an AEE envelope; anything outside the grant files HITL.
|
|
@@ -56,7 +99,7 @@ export function registerApprovalsCommands(program, ctx) {
|
|
|
56
99
|
.description('Standing grants: org-admin pre-approved fleet command lanes (ssh_exec)');
|
|
57
100
|
|
|
58
101
|
grants
|
|
59
|
-
.command('list')
|
|
102
|
+
.command('list', { isDefault: true })
|
|
60
103
|
.description('List active standing grants (--all includes revoked and expired)')
|
|
61
104
|
.option('--all', 'include revoked and expired grants')
|
|
62
105
|
.action(async (options) => {
|
|
@@ -69,7 +112,7 @@ export function registerApprovalsCommands(program, ctx) {
|
|
|
69
112
|
for (const g of rows) {
|
|
70
113
|
const state = g.revoked_at ? `revoked ${g.revoked_at}` : `expires ${g.expires_at}`;
|
|
71
114
|
const range = g.vmid_min != null ? ` vmids ${g.vmid_min}-${g.vmid_max}` : '';
|
|
72
|
-
console.log(`${g.
|
|
115
|
+
console.log(`${g.tool} on ${g.host}${range} ${g.preset || `${(g.patterns || []).length} patterns`} uses ${g.use_count} ${state} [${g.grant_id}]`);
|
|
73
116
|
console.log(` reason: ${g.reason} (by ${g.created_by})`);
|
|
74
117
|
}
|
|
75
118
|
});
|
|
@@ -102,6 +145,7 @@ export function registerApprovalsCommands(program, ctx) {
|
|
|
102
145
|
const data = await client.post(ep.path, { service: ep.service, body });
|
|
103
146
|
if (program.opts().output === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
|
|
104
147
|
const g = data.grant || {};
|
|
148
|
+
announce('approvals', `grant created ${g.tool || 'ssh_exec'}@${g.host || options.host}`, { result: g.grant_id });
|
|
105
149
|
console.log(`Created ${g.grant_id}: ${g.tool}@${g.host}${g.vmid_min != null ? ` vmids ${g.vmid_min}-${g.vmid_max}` : ''}, expires ${g.expires_at}`);
|
|
106
150
|
console.log(`Revoke any time: quox approvals grants revoke ${g.grant_id}`);
|
|
107
151
|
});
|
|
@@ -114,6 +158,157 @@ export function registerApprovalsCommands(program, ctx) {
|
|
|
114
158
|
const ep = resolveEndpoint('v1.approvals.grants.revoke', { id: grantId });
|
|
115
159
|
const data = await client.post(ep.path, { service: ep.service, body: {} });
|
|
116
160
|
if (program.opts().output === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
|
|
161
|
+
announce('approvals', `grant revoked ${grantId}`);
|
|
117
162
|
console.log(`Revoked ${data.grant?.grant_id || grantId} at ${data.grant?.revoked_at || 'now'}.`);
|
|
118
163
|
});
|
|
164
|
+
|
|
165
|
+
// ---- list | approve | deny: the verb people expect, over the SAME server path as `quox inbox respond`.
|
|
166
|
+
const OPEN_QUERY = { status: 'open', kind: 'approval', limit: 200 };
|
|
167
|
+
const listOpen = async (orgId, status, cl = client) => {
|
|
168
|
+
const ep = resolveEndpoint('inbox.list', { orgId });
|
|
169
|
+
const data = await cl.get(ep.path, { service: ep.service, query: { ...OPEN_QUERY, ...(status ? { status } : {}) } });
|
|
170
|
+
const items = (data && (data.items || data)) || [];
|
|
171
|
+
return Array.isArray(items) ? items : [];
|
|
172
|
+
};
|
|
173
|
+
const shortId = (id) => String(id).slice(-6);
|
|
174
|
+
|
|
175
|
+
async function pickApproval(orgId, given, cl = client) {
|
|
176
|
+
const want = String(given).trim().toUpperCase();
|
|
177
|
+
const items = await listOpen(orgId, undefined, cl);
|
|
178
|
+
const hits = items.filter((it) => {
|
|
179
|
+
const id = String(it.id).toUpperCase();
|
|
180
|
+
return id === want || id.endsWith(want) || String(it.aee_correlation_id || '').toUpperCase() === want;
|
|
181
|
+
});
|
|
182
|
+
const exact = hits.filter((it) => String(it.id).toUpperCase() === want);
|
|
183
|
+
if (exact.length === 1) return exact[0];
|
|
184
|
+
if (hits.length === 1) return hits[0];
|
|
185
|
+
if (hits.length > 1) {
|
|
186
|
+
throw new QuoxUserError(`"${given}" is ambiguous: it matches ${hits.length} open approvals (${hits.map((h) => shortId(h.id)).join(', ')}).`, {
|
|
187
|
+
next: 'use more of the id, or the full id from `quox approvals list`.', exitCode: 2, code: 'ambiguous_id',
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
throw new QuoxUserError(`No open approval matches "${given}" in this org.`, {
|
|
191
|
+
next: '`quox approvals list` shows what is waiting; `quox inbox get <id>` finds an answered one.', exitCode: 5, code: 'not_found',
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function summaryLine(it) {
|
|
196
|
+
const d = describeInboxItem(it);
|
|
197
|
+
const what = [d.tool, d.target && d.target !== d.tool ? d.target : null].filter(Boolean).join(' on ') || d.title || it.title || it.id;
|
|
198
|
+
const who = d.by || approvalSourceLabel(it);
|
|
199
|
+
return `[${shortId(it.id)}] ${what} (asked by ${who})`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function decide(verb, given, options) {
|
|
203
|
+
auth.requireAuth();
|
|
204
|
+
const orgId = resolveOrgId(auth, program);
|
|
205
|
+
const client = await clientFor(orgId); // another org only when --org names one
|
|
206
|
+
const it = await pickApproval(orgId, given, client);
|
|
207
|
+
const approve = verb === 'approve';
|
|
208
|
+
console.log(`${approve ? 'Approve' : 'Deny'}: ${summaryLine(it)}`);
|
|
209
|
+
if (approve) {
|
|
210
|
+
if (needsPin(it)) {
|
|
211
|
+
throw new QuoxUserError('High risk: a yes needs your step-up PIN, which a plain command cannot take.', {
|
|
212
|
+
next: 'answer it in `quox` with /approvals, or deny it here with `quox approvals deny`.', exitCode: 4, code: 'step_up_required',
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
if (isDestructive(it)) {
|
|
216
|
+
const { name } = destructiveObject(it);
|
|
217
|
+
if (!process.stdin.isTTY) {
|
|
218
|
+
throw new QuoxUserError(`This deletes "${name}": its name must be typed at a terminal. --yes does not cover it.`, {
|
|
219
|
+
next: 'run this command in an interactive terminal.', exitCode: 2, code: 'no_terminal',
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
const typed = await askTyped(`This is destructive. Type "${name}" to approve`);
|
|
223
|
+
if (String(typed ?? '').trim() !== name) {
|
|
224
|
+
throw new QuoxUserError(`What you typed did not match "${name}". Nothing was approved.`, { exitCode: 1, code: 'confirm_mismatch' });
|
|
225
|
+
}
|
|
226
|
+
} else if (!options.yes) {
|
|
227
|
+
const ok = await confirmInteractive('Approve this?', { flag: '--yes', purpose: 'approve' });
|
|
228
|
+
if (!ok) { console.log('Not approved. The request stays open.'); return; }
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const ep = resolveEndpoint('inbox.respond', { orgId, itemId: it.id });
|
|
232
|
+
try {
|
|
233
|
+
const body = { action: approve ? 'approve' : 'reject', ...(options.note ? { comment: options.note } : {}) };
|
|
234
|
+
// DS-010 / #1335: only an approve can hit a step-up sink; ask for the PIN (hidden, TTY only) and retry once.
|
|
235
|
+
if (approve) await withStepUp((headers) => client.post(ep.path, { service: ep.service, headers, body }), { client, orgId, io: ctx.stepUpIO, what: 'This approval' });
|
|
236
|
+
else await client.post(ep.path, { service: ep.service, body });
|
|
237
|
+
} catch (err) {
|
|
238
|
+
announceFailure('approvals', `${approve ? 'approve' : 'deny'} ${shortId(it.id)}`, err);
|
|
239
|
+
if (err && (err.status === 403 || err.status === 401) && !(err instanceof QuoxUserError)) {
|
|
240
|
+
const msg = err.body?.message || err.body?.error || err.message;
|
|
241
|
+
const denied = new QuoxUserError(String(msg), { exitCode: 4, code: err.body?.error || 'forbidden', status: err.status, body: err.body });
|
|
242
|
+
Object.defineProperty(denied, 'quoxAnnounced', { value: true });
|
|
243
|
+
throw denied;
|
|
244
|
+
}
|
|
245
|
+
throw err;
|
|
246
|
+
}
|
|
247
|
+
announce('approvals', `${approve ? 'approved' : 'denied'} ${shortId(it.id)}`, { result: summaryLine(it).replace(/^\[\w+\]\s*/, '').replace(/\s+\(asked by .*$/, '') });
|
|
248
|
+
printSuccess(`${approve ? 'Approved' : 'Denied'} ${shortId(it.id)}.`);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
approvals
|
|
252
|
+
.command('list')
|
|
253
|
+
.description('List approvals waiting for a human (same call as `quox inbox list`, approvals only)')
|
|
254
|
+
.option('--status <status>', 'open (default), all, or a status list')
|
|
255
|
+
.option('--limit <n>', 'max results', '50')
|
|
256
|
+
.action(async (options) => {
|
|
257
|
+
auth.requireAuth();
|
|
258
|
+
const orgId = resolveOrgId(auth, program);
|
|
259
|
+
const cl = await clientFor(orgId);
|
|
260
|
+
const ep = resolveEndpoint('inbox.list', { orgId });
|
|
261
|
+
const data = await cl.get(ep.path, { service: ep.service, query: { ...OPEN_QUERY, limit: options.limit, ...(options.status ? { status: options.status } : {}) } });
|
|
262
|
+
const items = ((data && (data.items || data)) || []).map((it) => ({ ...it, short: shortId(it.id), what: summaryLine(it).replace(/^\[\w+\]\s*/, '') }));
|
|
263
|
+
formatOutput({
|
|
264
|
+
columns: ['short', 'what', 'status', 'created_at', 'id'],
|
|
265
|
+
headers: ['ID', 'What', 'Status', 'Created', 'Full id'],
|
|
266
|
+
rows: items,
|
|
267
|
+
}, { format: program.opts().output, emptyMessage: 'No approvals are waiting.' });
|
|
268
|
+
// Waiting in the user's OTHER orgs: a count and the exact command, table output only (json stays pure data).
|
|
269
|
+
if (program.opts().output === 'table' || !program.opts().output) {
|
|
270
|
+
for (const l of crossOrgLines(await otherOrgsWaiting({ client: baseClient, currentOrgId: orgId }))) console.log(l);
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
approvals
|
|
275
|
+
.command('approve <id>')
|
|
276
|
+
.description('Approve one waiting request by id (full id or the short tail on its card). A high-risk yes needs a PIN, so use `quox`.')
|
|
277
|
+
.option('--note <text>', 'a note recorded with the decision')
|
|
278
|
+
.option('-y, --yes', 'skip the y/N question (not a destructive delete, which needs its name typed)')
|
|
279
|
+
.addHelpText('after', '\nExamples:\n quox approvals list\n quox approvals approve D05HQY\n quox approvals approve D05HQY --yes --note "checked the host"\n')
|
|
280
|
+
.action(announceOnFail('approvals', (id) => `approve ${id}`, (id, options) => decide('approve', id, options)));
|
|
281
|
+
|
|
282
|
+
approvals
|
|
283
|
+
.command('deny <id>')
|
|
284
|
+
.description('Deny one waiting request by id (full id or the short tail on its card)')
|
|
285
|
+
.option('--note <text>', 'why, recorded with the decision')
|
|
286
|
+
.addHelpText('after', '\nExamples:\n quox approvals deny FJ340S --note "wrong host"\n')
|
|
287
|
+
.action(announceOnFail('approvals', (id) => `deny ${id}`, (id, options) => decide('deny', id, options)));
|
|
288
|
+
|
|
289
|
+
approvals
|
|
290
|
+
.command('withdraw <id>')
|
|
291
|
+
.description('Take back an approval request YOU raised (full id). It is marked withdrawn, never denied; the held call cannot run; only the identity that raised it may do this')
|
|
292
|
+
.option('--note <text>', 'why, recorded with the withdrawal')
|
|
293
|
+
.addHelpText('after', '\nExamples:\n quox approvals withdraw 01KXYZ... --note "raised by mistake"\n')
|
|
294
|
+
.action(announceOnFail('approvals', (id) => `withdraw ${id}`, async (given, options) => {
|
|
295
|
+
auth.requireAuth();
|
|
296
|
+
const orgId = resolveOrgId(auth, program);
|
|
297
|
+
// The full item id or approval id goes straight to the server, which checks you are the requester. A short tail is
|
|
298
|
+
// looked up among the open approvals you can see; one you cannot see is not yours to withdraw by tail.
|
|
299
|
+
let id = String(given).trim();
|
|
300
|
+
if (id.length < 20) { const it = await pickApproval(orgId, id); id = it.id; }
|
|
301
|
+
const ep = resolveEndpoint('inbox.withdraw', { orgId, itemId: id });
|
|
302
|
+
try {
|
|
303
|
+
await client.post(ep.path, { service: ep.service, body: { ...(options.note ? { reason: options.note } : {}) } });
|
|
304
|
+
} catch (err) {
|
|
305
|
+
if (err && (err.status === 403 || err.status === 404 || err.status === 400) && !(err instanceof QuoxUserError)) {
|
|
306
|
+
const msg = err.body?.message || err.body?.error || err.message;
|
|
307
|
+
throw new QuoxUserError(String(msg), { exitCode: err.status === 403 ? 4 : 5, code: err.body?.error || 'withdraw_refused', status: err.status, body: err.body });
|
|
308
|
+
}
|
|
309
|
+
throw err;
|
|
310
|
+
}
|
|
311
|
+
announce('approvals', `withdrew ${shortId(id)}`, { result: 'withdrawn, nothing ran' });
|
|
312
|
+
printSuccess(`Withdrew ${shortId(id)}. It is marked withdrawn and cannot run.`);
|
|
313
|
+
}));
|
|
119
314
|
}
|
package/lib/commands/ask.js
CHANGED
|
@@ -96,6 +96,7 @@ export function registerAskCommands(program, { client, auth }) {
|
|
|
96
96
|
.option('--paths <files...>', 'every test file the commit edits (absolute, worktree or repo-relative: all are normalised)')
|
|
97
97
|
.option('--justify <text>', 'why it is safe, in plain words (required)')
|
|
98
98
|
.option('--why <text>', 'detail: the findings, before and after lines')
|
|
99
|
+
.option('--recommend <y|n>', 'your advice for the card: y or n (shown as the recommendation)')
|
|
99
100
|
.option('--session <name>', 'which session is asking (default: $QUOX_SESSION)')
|
|
100
101
|
.option('--slot <name>', 'your slot')
|
|
101
102
|
.option('--expires <dur>', 'how long it stays open')
|
|
@@ -105,6 +106,7 @@ export function registerAskCommands(program, { client, auth }) {
|
|
|
105
106
|
// commander lets the parent `ask` swallow flags it shares with this verb (--justify, --why, --session...), so merge both
|
|
106
107
|
const options = { ...ask.opts(), ...Object.fromEntries(Object.entries(subOptions).filter(([, v]) => v !== undefined)) };
|
|
107
108
|
if (!String(options.justify || '').trim()) { err('say why it is safe: --justify "<plain words>"'); process.exitCode = 2; return; }
|
|
109
|
+
if (!options.recommend) err('tip: add --recommend y|n so the card shows your advice');
|
|
108
110
|
const raw = [].concat(options.paths || []).flatMap((x) => String(x).split(',')).map((x) => x.trim()).filter(Boolean);
|
|
109
111
|
let root;
|
|
110
112
|
try { root = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim(); } catch { root = process.cwd(); }
|
|
@@ -114,7 +116,7 @@ export function registerAskCommands(program, { client, auth }) {
|
|
|
114
116
|
// 2026-10-19: the server truncates long titles, and the attestation names only paths found in title+body,
|
|
115
117
|
// so a 7-file ask covered 3. Every path also goes in the body.
|
|
116
118
|
const files = `Files (${paths.length}):\n${paths.map((p) => `- ${p}`).join('\n')}`;
|
|
117
|
-
await fileAsk({ ...options, title: `Approve test edits: ${paths.join(' ')}`, why: options.why ? `${files}\n\n${options.why}` : files, yn: true, highRisk: true, justify: options.justify });
|
|
119
|
+
await fileAsk({ ...options, title: `Approve test edits: ${paths.join(' ')}`, why: options.why ? `${files}\n\n${options.why}` : files, yn: true, highRisk: true, justify: options.justify, recommend: options.recommend });
|
|
118
120
|
});
|
|
119
121
|
|
|
120
122
|
ask
|
|
@@ -128,7 +130,7 @@ export function registerAskCommands(program, { client, auth }) {
|
|
|
128
130
|
const d = (await client.get(ep.path, { service: ep.service })).decision;
|
|
129
131
|
if (program.opts().output === 'json') { console.log(JSON.stringify(d, null, 2)); return; }
|
|
130
132
|
console.log(`${d.state}${d.answer?.option ? ` ${d.answer.option}` : ''}${d.answer?.note ? ` (${d.answer.note})` : ''}`);
|
|
131
|
-
if (d.state === 'expired' || d.state === 'denied') process.exitCode = EXIT_NO_ANSWER;
|
|
133
|
+
if (d.state === 'expired' || d.state === 'denied' || d.state === 'withdrawn') process.exitCode = EXIT_NO_ANSWER;
|
|
132
134
|
});
|
|
133
135
|
|
|
134
136
|
ask
|
package/lib/commands/auth.js
CHANGED
|
@@ -7,7 +7,19 @@ import { formatDetail, printSuccess, printInfo } from '../core/output.js';
|
|
|
7
7
|
import { resolveEndpoint } from '../api/endpoints.js';
|
|
8
8
|
import { loadResidentProfile, residentLogin, resolveResidentBaseUrl } from '../core/residentAuth.js';
|
|
9
9
|
import { readStoredKit, resolveMyAttach, sessionKey, myAttachState } from './agentAttach.js';
|
|
10
|
+
import { loginPrefillEmail, profileHoldingEmail, isNonDefaultProfile, sameIdentityWarning } from '../core/loginGuard.js';
|
|
10
11
|
import { resolveActing } from '../core/actingAs.js';
|
|
12
|
+
import { QuoxUserError } from '../core/errors.js';
|
|
13
|
+
|
|
14
|
+
const PASSWORD_POLICY_HINT = 'Password policy: at least 12 characters, with an upper-case letter, a lower-case letter, a digit and a symbol.';
|
|
15
|
+
|
|
16
|
+
/** whoami line for "-p/--profile given": say which agent is acting when the profile is an agent identity. */
|
|
17
|
+
export function profileActingLine({ actorType, profile, user }) {
|
|
18
|
+
if (actorType === 'agent') {
|
|
19
|
+
return ` Acting as agent ${user || 'unknown'} via profile ${profile || 'unknown'} (-p/--profile given)`;
|
|
20
|
+
}
|
|
21
|
+
return ' Acting as yourself (-p/--profile given)';
|
|
22
|
+
}
|
|
11
23
|
|
|
12
24
|
export function registerAuthCommands(program, ctx) {
|
|
13
25
|
const { config, auth, client } = ctx;
|
|
@@ -122,6 +134,7 @@ export function registerAuthCommands(program, ctx) {
|
|
|
122
134
|
type: 'input',
|
|
123
135
|
name: 'email',
|
|
124
136
|
message: 'Email:',
|
|
137
|
+
default: loginPrefillEmail(config) || undefined,
|
|
125
138
|
validate: v => v.includes('@') || 'Please enter a valid email',
|
|
126
139
|
}]);
|
|
127
140
|
email = ans.email;
|
|
@@ -138,6 +151,21 @@ export function registerAuthCommands(program, ctx) {
|
|
|
138
151
|
}
|
|
139
152
|
}
|
|
140
153
|
|
|
154
|
+
// Stop agent-only profiles quietly becoming another profile's (the owner's) identity.
|
|
155
|
+
if (isNonDefaultProfile(config)) {
|
|
156
|
+
const holder = profileHoldingEmail(config, email, config.getActiveProfile?.()?.url || null);
|
|
157
|
+
if (holder) {
|
|
158
|
+
const msg = sameIdentityWarning(email, holder, config.getActiveProfileName());
|
|
159
|
+
if (!process.stdin.isTTY) {
|
|
160
|
+
console.error(`Error: ${msg}\nRefusing in a non-interactive shell. Use the profile's own account, or run interactively and answer y.`);
|
|
161
|
+
process.exit(1);
|
|
162
|
+
}
|
|
163
|
+
const { default: inquirer } = await import('inquirer');
|
|
164
|
+
const ans = await inquirer.prompt([{ type: 'confirm', name: 'ok', message: msg.replace(/ y\/N$/, ''), default: false }]);
|
|
165
|
+
if (!ans.ok) { printInfo('Cancelled. Nothing was sent.'); return; }
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
141
169
|
const baseUrl = config.getServiceUrl('auth');
|
|
142
170
|
const ora = (await import('ora')).default;
|
|
143
171
|
const spinner = ora('Authenticating...').start();
|
|
@@ -196,6 +224,7 @@ export function registerAuthCommands(program, ctx) {
|
|
|
196
224
|
program
|
|
197
225
|
.command('whoami')
|
|
198
226
|
.description('Show current user and session info')
|
|
227
|
+
.addHelpText('after', '\nAdd --json for machine output (user, email, org, role, mfa, session, profile, actor_type, host_id).')
|
|
199
228
|
.action(async () => {
|
|
200
229
|
auth.requireAuth();
|
|
201
230
|
const data = await client.get(resolveEndpoint('auth.me.list').path, { service: resolveEndpoint('auth.me.list').service });
|
|
@@ -309,7 +338,7 @@ export function registerAuthCommands(program, ctx) {
|
|
|
309
338
|
} else if (acting.source === 'self') {
|
|
310
339
|
printInfo(' Acting as yourself (--as self)');
|
|
311
340
|
} else if (acting.source === 'profile') {
|
|
312
|
-
printInfo(
|
|
341
|
+
printInfo(profileActingLine({ actorType, profile: info.profile, user: info.user }));
|
|
313
342
|
} else if (myAttachState().state === 'ambiguous') {
|
|
314
343
|
printInfo(' Acting as yourself: this session has more than one attach (ambiguous), so no agent default applies. Pick one with --as <agent>.');
|
|
315
344
|
}
|
|
@@ -337,18 +366,42 @@ export function registerAuthCommands(program, ctx) {
|
|
|
337
366
|
.command('signup')
|
|
338
367
|
.description('Create a new user account')
|
|
339
368
|
.requiredOption('--email <email>', 'email address')
|
|
340
|
-
.
|
|
369
|
+
.option('--password <password>', 'password (prefer --password-stdin: argv is visible in process lists)')
|
|
370
|
+
.option('--password-stdin', 'read the password from stdin (one line), no secret in argv or shell history')
|
|
341
371
|
.requiredOption('--name <name>', 'display name')
|
|
342
372
|
.action(async (options) => {
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
}
|
|
373
|
+
let password = options.password;
|
|
374
|
+
if (!password && options.passwordStdin) {
|
|
375
|
+
const chunks = [];
|
|
376
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
377
|
+
password = Buffer.concat(chunks).toString('utf8').split('\n')[0].trim();
|
|
378
|
+
if (!password) throw new QuoxUserError('--password-stdin given but stdin was empty', { next: 'Pipe the password in, for example: printf %s "$PW" | quox signup --password-stdin ...' });
|
|
379
|
+
} else if (password) {
|
|
380
|
+
console.error('Warning: argv is visible to other users (process list) and lands in shell history, so a password given with --password is exposed. Prefer --password-stdin.');
|
|
381
|
+
}
|
|
382
|
+
if (!password) {
|
|
383
|
+
throw new QuoxUserError('A password is required. Use --password-stdin (reads one line from stdin) or --password <password>.');
|
|
384
|
+
}
|
|
385
|
+
const ep = resolveEndpoint('auth.signup');
|
|
386
|
+
try {
|
|
387
|
+
await client.post(ep.path, {
|
|
388
|
+
service: ep.service,
|
|
389
|
+
body: { email: options.email, password, display_name: options.name },
|
|
390
|
+
noAuth: true,
|
|
391
|
+
});
|
|
392
|
+
} catch (err) {
|
|
393
|
+
if (err?.status === 400 && err?.code === 'validation_error') {
|
|
394
|
+
const reason = err.body?.message || err.message;
|
|
395
|
+
const list = Array.isArray(err.body?.errors) ? err.body.errors.filter((e) => typeof e === 'string' && e !== reason) : [];
|
|
396
|
+
const detail = [reason, ...list].join('; ');
|
|
397
|
+
const aboutPassword = /password/i.test(detail);
|
|
398
|
+
throw new QuoxUserError(`Signup was rejected: ${detail}`, {
|
|
399
|
+
next: aboutPassword ? PASSWORD_POLICY_HINT : null,
|
|
400
|
+
code: 'validation_error', status: 400, body: err.body,
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
throw err;
|
|
404
|
+
}
|
|
352
405
|
printSuccess(`Account created for ${options.email}. Check your email for verification.`);
|
|
353
406
|
});
|
|
354
407
|
|
package/lib/commands/backup.js
CHANGED
|
@@ -37,12 +37,19 @@ export function registerBackupCommands(program, ctx) {
|
|
|
37
37
|
auth.requireAuth();
|
|
38
38
|
const orgId = resolveOrgId(auth, program);
|
|
39
39
|
const data = await client.get(resolveEndpoint('backups.list', { orgId }).path, { service: resolveEndpoint('backups.list', { orgId }).service, query: { limit: options.limit } });
|
|
40
|
+
const items = data.items || data.backups || data;
|
|
41
|
+
// Name leads (ids are for scripting: --json, -q). A failed row says why, clipped.
|
|
42
|
+
const clip = (t, n = 60) => { const x = String(t).replace(/\s+/g, ' ').trim(); return x.length > n ? `${x.slice(0, n - 1)}…` : x; };
|
|
43
|
+
const rows = (Array.isArray(items) ? items : []).map((b) => ({
|
|
44
|
+
...b,
|
|
45
|
+
status: b.status === 'failed' && b.error_message ? `failed: ${clip(b.error_message)}` : b.status,
|
|
46
|
+
}));
|
|
40
47
|
formatOutput({
|
|
41
48
|
// fleet #392: backups has name/backup_type/archive_size_bytes.
|
|
42
|
-
columns: ['
|
|
43
|
-
headers: ['
|
|
44
|
-
rows
|
|
45
|
-
}, { format: program.opts().output });
|
|
49
|
+
columns: ['name', 'backup_type', 'archive_size_bytes', 'status', 'created_at', 'id'],
|
|
50
|
+
headers: ['Name', 'Type', 'Size (bytes)', 'Status', 'Created', 'ID'],
|
|
51
|
+
rows,
|
|
52
|
+
}, { format: program.opts().output, raw: Array.isArray(items) ? items : undefined, quietColumn: 'id' });
|
|
46
53
|
});
|
|
47
54
|
|
|
48
55
|
backup
|