quoxcli 0.5.0 → 0.6.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/CHANGELOG.md +65 -1
- package/lib/api/endpoints/admin.js +21 -0
- package/lib/api/endpoints/calendarq.js +8 -0
- package/lib/api/endpoints/governance.js +7 -0
- package/lib/api/endpoints/inboxq.js +39 -0
- package/lib/api/endpoints/index.js +4 -0
- package/lib/api/endpoints/lens.js +34 -0
- package/lib/api/endpoints/lucid.js +18 -0
- package/lib/api/endpoints/mcp.js +35 -0
- package/lib/api/endpoints/mon.js +6 -7
- package/lib/api/endpoints/notesq.js +2 -0
- package/lib/api/endpoints/quoxplan.js +51 -0
- package/lib/api/endpoints/v1.js +57 -0
- package/lib/commands/actions.js +51 -13
- package/lib/commands/admin.js +86 -0
- package/lib/commands/approvals.js +5 -1
- package/lib/commands/calendar.js +27 -0
- package/lib/commands/chat.js +1 -1
- package/lib/commands/contacts.js +2 -0
- package/lib/commands/fleet.js +164 -0
- package/lib/commands/fleetRecipe.js +174 -0
- package/lib/commands/lens.js +268 -0
- package/lib/commands/lucid.js +6 -0
- package/lib/commands/lucidGoals.js +423 -0
- package/lib/commands/mail.js +287 -7
- package/lib/commands/mcp.js +190 -0
- package/lib/commands/mon.js +14 -0
- package/lib/commands/monitoring.js +73 -1
- package/lib/commands/notesSeal.js +380 -22
- package/lib/commands/offload.js +51 -10
- package/lib/commands/plan.js +507 -54
- package/lib/commands/research.js +78 -0
- package/lib/commands/settings.js +7 -1
- package/lib/commands/volt.js +6 -1
- package/lib/commands/wazuh.js +74 -9
- package/lib/commands/wazuhIntake.js +55 -6
- package/lib/core/commandRegistrars.js +2 -0
- package/lib/core/config.js +14 -1
- package/lib/core/offload.js +83 -0
- package/lib/core/output.js +29 -4
- package/lib/core/selfUpdate.js +2 -0
- package/lib/core/version.js +15 -2
- package/lib/dragon/select.js +2 -2
- package/lib/quoxon/api.js +4 -0
- package/lib/quoxon/app.js +258 -49
- package/lib/quoxon/approvalsView.js +3 -1
- package/lib/quoxon/ask.js +4 -2
- package/lib/quoxon/bang.js +7 -1
- package/lib/quoxon/commandShort.js +1 -1
- package/lib/quoxon/commands.js +5 -5
- package/lib/quoxon/delegationDragon.js +49 -0
- package/lib/quoxon/dnd.js +47 -0
- package/lib/quoxon/dragonCompanion.js +24 -12
- package/lib/quoxon/events.js +12 -1
- package/lib/quoxon/extensions.js +2 -0
- package/lib/quoxon/foreignText.js +49 -0
- package/lib/quoxon/inboxView.js +5 -5
- package/lib/quoxon/launcher.js +2 -1
- package/lib/quoxon/loopCard.js +2 -1
- package/lib/quoxon/main.js +8 -2
- package/lib/quoxon/onboarding/dockerOffer.js +3 -1
- package/lib/quoxon/onboarding/view.js +1 -1
- package/lib/quoxon/render.js +5 -2
- package/lib/quoxon/resend.js +14 -0
- package/lib/quoxon/rows.js +3 -1
- package/lib/quoxon/secretShape.js +2 -0
- package/lib/quoxon/slash/broadcast.js +30 -6
- package/lib/quoxon/slash/broadcastRun.js +12 -6
- package/lib/quoxon/slash/cliLeaves.json +54 -0
- package/lib/quoxon/slash/dashboard.js +2 -0
- package/lib/quoxon/slash/listView.js +2 -1
- package/lib/quoxon/slash/palette.js +41 -0
- package/lib/quoxon/slash/paletteController.js +6 -2
- package/lib/quoxon/slash/restart.js +2 -0
- package/lib/quoxon/slotChat.js +7 -3
- package/lib/quoxon/slotRun.js +2 -0
- package/lib/quoxon/webPty.js +15 -0
- package/lib/quoxon/whatsnew.jsonl +7 -0
- package/lib/render/planBrief.js +614 -0
- package/lib/toolOutputShape.js +6 -3
- package/package.json +1 -1
- package/vendor/dragonlab/SOURCE +3 -3
- package/vendor/dragonlab/design/architect/v2/architect-master.svg +67 -0
- package/vendor/dragonlab/design/architect/v2/character.mjs +83 -0
- package/vendor/dragonlab/design/architect/v2/review.mjs +35 -0
- package/vendor/dragonlab/design/architect/v2/watch.mjs +56 -0
- package/vendor/dragonlab/design/archivist/v2/archivist-master.svg +77 -0
- package/vendor/dragonlab/design/archivist/v3/character.mjs +56 -0
- package/vendor/dragonlab/design/archivist/v4/character.mjs +46 -0
- package/vendor/dragonlab/design/archivist/v4/review.mjs +35 -0
- package/vendor/dragonlab/design/archivist/v4/watch.mjs +56 -0
- package/vendor/dragonlab/design/argus/v2/character.mjs +69 -0
- package/vendor/dragonlab/design/argus/v3/argus-master.svg +68 -0
- package/vendor/dragonlab/design/argus/v3/character.mjs +63 -0
- package/vendor/dragonlab/design/argus/v3/review.mjs +35 -0
- package/vendor/dragonlab/design/argus/v3/watch.mjs +56 -0
- package/vendor/dragonlab/design/daedalus/v2/character.mjs +49 -0
- package/vendor/dragonlab/design/daedalus/v3/daedalus-master.svg +65 -0
- package/vendor/dragonlab/design/daedalus/v4/character.mjs +47 -0
- package/vendor/dragonlab/design/daedalus/v5/character.mjs +66 -0
- package/vendor/dragonlab/design/daedalus/v6/character.mjs +32 -0
- package/vendor/dragonlab/design/daedalus/v6/review.mjs +35 -0
- package/vendor/dragonlab/design/daedalus/v6/watch.mjs +56 -0
- package/vendor/dragonlab/design/janus/v1/janus-master.svg +70 -0
- package/vendor/dragonlab/design/janus/v2/character.mjs +56 -0
- package/vendor/dragonlab/design/janus/v3/character.mjs +7 -0
- package/vendor/dragonlab/design/janus/v4/character.mjs +150 -0
- package/vendor/dragonlab/design/janus/v5/character.mjs +55 -0
- package/vendor/dragonlab/design/janus/v5/review.mjs +35 -0
- package/vendor/dragonlab/design/janus/v5/watch.mjs +56 -0
- package/vendor/dragonlab/design/roady/v1/roady-master.svg +67 -0
- package/vendor/dragonlab/design/roady/v2/character.mjs +69 -0
- package/vendor/dragonlab/design/roady/v3/character.mjs +91 -0
- package/vendor/dragonlab/design/roady/v3/review.mjs +35 -0
- package/vendor/dragonlab/design/roady/v3/watch.mjs +56 -0
- package/vendor/dragonlab/design/weaver/v3/weaver-master.svg +57 -0
- package/vendor/dragonlab/design/weaver/v4/character.mjs +56 -0
- package/vendor/dragonlab/design/weaver/v5/character.mjs +52 -0
- package/vendor/dragonlab/design/weaver/v6/character.mjs +88 -0
- package/vendor/dragonlab/design/weaver/v6/review.mjs +35 -0
- package/vendor/dragonlab/design/weaver/v6/watch.mjs +56 -0
- package/vendor/dragonlab/integration/companions-v1/architect.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/archivist.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/argus.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/characters.mjs +86 -0
- package/vendor/dragonlab/integration/companions-v1/daedalus.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/janus.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/roady.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/view.mjs +4 -1
- package/vendor/dragonlab/integration/companions-v1/weaver.mjs +2 -0
package/lib/commands/research.js
CHANGED
|
@@ -5,9 +5,20 @@
|
|
|
5
5
|
* Stored as the org setting RESEARCH_TRUSTED_DOMAINS through the SAME governed settings path as
|
|
6
6
|
* `quox settings set` (PATCH /api/v1/platform/settings/flags/:key, human org admin, witnessed there).
|
|
7
7
|
* Unset or empty means the built-in default list, which add/remove seed from so a first edit never drops it.
|
|
8
|
+
*
|
|
9
|
+
* quox research ask "<question>" [--save [--collection <name>]]
|
|
10
|
+
*
|
|
11
|
+
* Asks the research agent (registry id `perplexity`, the agent holding the governed web_search/web_read
|
|
12
|
+
* tools) through the SAME governed chat-turn route as `quox chat --agent perplexity --no-stream`
|
|
13
|
+
* (POST /chat/claude, Prefer: respond-async, polled at /chat/turns/:id). --save writes the answer and its
|
|
14
|
+
* sources as a NotesQ note through the same create route as `quox notes new`.
|
|
15
|
+
* Sources are the URLs the agent cited in its answer: the chat route returns tool names, not tool results,
|
|
16
|
+
* so a URL the agent read but did not cite is not shown, and none is ever invented.
|
|
8
17
|
*/
|
|
9
18
|
|
|
10
19
|
import { resolveEndpoint } from '../api/endpoints.js';
|
|
20
|
+
import { QuoxError } from '../core/errors.js';
|
|
21
|
+
import { sendOneShot } from './chat.js';
|
|
11
22
|
|
|
12
23
|
const KEY = 'RESEARCH_TRUSTED_DOMAINS';
|
|
13
24
|
|
|
@@ -41,6 +52,25 @@ async function write(client, list) {
|
|
|
41
52
|
return client.patch(ep.path, { service: ep.service, body: { value: list.join(',') } });
|
|
42
53
|
}
|
|
43
54
|
|
|
55
|
+
export const RESEARCH_AGENT = 'perplexity';
|
|
56
|
+
|
|
57
|
+
/** URLs cited in an answer (bare or markdown links), trailing punctuation trimmed, first-seen order, deduped. */
|
|
58
|
+
export function extractSources(text) {
|
|
59
|
+
const seen = new Set();
|
|
60
|
+
for (const m of String(text || '').matchAll(/https?:\/\/[^\s<>()\[\]"'`]+(?:\([^\s()]*\)[^\s<>()\[\]"'`]*)*/g)) {
|
|
61
|
+
const url = m[0].replace(/[.,;:!?*_]+$/, '');
|
|
62
|
+
if (url.length > 'https://'.length) seen.add(url);
|
|
63
|
+
}
|
|
64
|
+
return [...seen];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function noteMarkdown({ question, answer, sources, toolsUsed, agent }) {
|
|
68
|
+
const title = question.replace(/\s+/g, ' ').trim().slice(0, 120);
|
|
69
|
+
const src = sources.length ? sources.map((u) => `- ${u}`).join('\n') : '_No sources were returned for this answer: the agent cited no URL._';
|
|
70
|
+
return `# ${title}\n\n**Question:** ${question}\n\n${answer.trim()}\n\n## Sources\n\n_URLs cited in the answer. The research route does not report which pages were actually read; check the answer text._\n\n${src}\n\n---\n` +
|
|
71
|
+
`Asked with \`quox research ask\` (agent ${agent}) on ${new Date().toISOString()}. Tools called: ${toolsUsed.length ? toolsUsed.join(', ') : 'none'}.\n`;
|
|
72
|
+
}
|
|
73
|
+
|
|
44
74
|
export function registerResearchCommands(program, ctx) {
|
|
45
75
|
const { client } = ctx;
|
|
46
76
|
const r = program.command('research').description('Governed web research settings');
|
|
@@ -76,4 +106,52 @@ export function registerResearchCommands(program, ctx) {
|
|
|
76
106
|
await write(client, next);
|
|
77
107
|
process.stdout.write(`removed ${dom} (${next.length} entries, org setting)\n`);
|
|
78
108
|
});
|
|
109
|
+
|
|
110
|
+
r.command('ask <question...>')
|
|
111
|
+
.description('Ask the research agent a question; prints the answer and the sources it cited (--save keeps it as a NotesQ note)')
|
|
112
|
+
.option('--save', 'save the answer and sources as a NotesQ note')
|
|
113
|
+
.option('--collection <name>', 'NotesQ collection for --save', 'research')
|
|
114
|
+
.action(async (words, options) => {
|
|
115
|
+
if (ctx.auth && ctx.auth.requireAuth) ctx.auth.requireAuth();
|
|
116
|
+
const question = words.join(' ').trim();
|
|
117
|
+
if (!question) throw new QuoxError('Ask a question: quox research ask "<question>"', 2);
|
|
118
|
+
const g = program.opts();
|
|
119
|
+
const json = g.output === 'json' || g.json === true;
|
|
120
|
+
|
|
121
|
+
const { data, sessionId } = await sendOneShot(client, { message: question, agent: RESEARCH_AGENT });
|
|
122
|
+
const raw = data && (data.result ?? data.response ?? data.text);
|
|
123
|
+
const answer = typeof raw === 'string' ? raw : '';
|
|
124
|
+
if (!answer.trim()) throw new QuoxError(`The research agent returned no answer${data && data.error ? ` (${data.error})` : ''}.`, 1);
|
|
125
|
+
const toolsUsed = Array.isArray(data.tools_used) ? [...new Set(data.tools_used.map((t) => (typeof t === 'string' ? t : t && (t.name || t.tool))).filter(Boolean))] : [];
|
|
126
|
+
const agent = data.agent_used || RESEARCH_AGENT;
|
|
127
|
+
const sources = extractSources(answer);
|
|
128
|
+
|
|
129
|
+
let note = null;
|
|
130
|
+
if (options.save) {
|
|
131
|
+
const ep = resolveEndpoint('notesq.notes.create');
|
|
132
|
+
const saved = await client.post(ep.path, {
|
|
133
|
+
service: ep.service,
|
|
134
|
+
body: { scope: 'org', markdown: noteMarkdown({ question, answer, sources, toolsUsed, agent }), properties: { collection: options.collection, tags: ['research'] } },
|
|
135
|
+
});
|
|
136
|
+
note = saved && saved.note;
|
|
137
|
+
if (!note || !note.id) throw new QuoxError('NotesQ returned no note id: the answer was not saved.', 1);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (json) {
|
|
141
|
+
console.log(JSON.stringify({ question, agent, answer, sources, tools_used: toolsUsed, conversation_id: sessionId || null, note }, null, 2));
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
console.log(answer.trim());
|
|
145
|
+
console.log('');
|
|
146
|
+
if (sources.length) {
|
|
147
|
+
console.log(`Sources (${sources.length} URLs cited in the answer; the route does not report which pages were actually read):`);
|
|
148
|
+
sources.forEach((u, i) => console.log(` ${i + 1}. ${u}`));
|
|
149
|
+
} else {
|
|
150
|
+
console.log('Sources: none. The agent cited no URL in its answer.');
|
|
151
|
+
}
|
|
152
|
+
const ranSearch = toolsUsed.includes('web_search');
|
|
153
|
+
console.log(`Tools called: ${toolsUsed.length ? toolsUsed.join(', ') : 'none'}${ranSearch ? ' (called, not necessarily successful: read the answer)' : ' (web_search did not run on this turn)'}`);
|
|
154
|
+
if (agent !== RESEARCH_AGENT) console.log(`Answered by: ${agent}`);
|
|
155
|
+
if (note) console.log(`Saved note ${note.id} in collection ${options.collection}.`);
|
|
156
|
+
});
|
|
79
157
|
}
|
package/lib/commands/settings.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import { formatOutput, formatDetail } from '../core/output.js';
|
|
13
13
|
import { line as glyphLine, printEvent } from '../core/quoxglyph.js';
|
|
14
14
|
import { resolveEndpoint } from '../api/endpoints.js';
|
|
15
|
+
import { withStepUp } from '../core/stepUpPrompt.js';
|
|
15
16
|
|
|
16
17
|
async function fetchFlags(client) {
|
|
17
18
|
const ep = resolveEndpoint('v1.settings.list');
|
|
@@ -59,7 +60,12 @@ export function registerSettingsCommands(program, ctx) {
|
|
|
59
60
|
.action(async (key, value) => {
|
|
60
61
|
auth.requireAuth();
|
|
61
62
|
const ep = resolveEndpoint('v1.settings.set', { key });
|
|
62
|
-
|
|
63
|
+
// VULCAN: weakening an approval control (STEP_UP_ENFORCE off) needs a step-up PIN; the server
|
|
64
|
+
// decides, and withStepUp asks for the PIN on a hidden prompt and retries once (scope override).
|
|
65
|
+
const data = await withStepUp(
|
|
66
|
+
(headers) => client.patch(ep.path, { service: ep.service, headers, body: { value } }),
|
|
67
|
+
{ client, orgId: auth.getOrgId?.(program.opts().org) || null, scope: 'override', io: ctx.stepUpIO, what: `Changing ${key}` },
|
|
68
|
+
);
|
|
63
69
|
if (program.opts().output === 'json') {
|
|
64
70
|
console.log(JSON.stringify(data, null, 2));
|
|
65
71
|
return;
|
package/lib/commands/volt.js
CHANGED
|
@@ -511,7 +511,12 @@ export function registerVoltCommands(program, ctx = {}) {
|
|
|
511
511
|
console.log(` Agents: ${(m.agents_involved || []).join(', ') || 'none'}`);
|
|
512
512
|
console.log(` Policies: ${(m.policy_ids_evaluated || []).length} evaluated`);
|
|
513
513
|
console.log(` HITL: ${m.hitl_decisions || 0} decisions`);
|
|
514
|
-
|
|
514
|
+
// Signatures live beside the manifest (fetched bundles: `signatures[]`;
|
|
515
|
+
// stored rows: `signature`); collector manifests never set m.signed.
|
|
516
|
+
const sigCount = Array.isArray(parsed.signatures) && parsed.signatures.length > 0
|
|
517
|
+
? parsed.signatures.length
|
|
518
|
+
: (parsed.signature ? 1 : (m.signed ? 1 : 0));
|
|
519
|
+
console.log(` Signed: ${sigCount > 0 ? `yes (${sigCount} signature${sigCount === 1 ? '' : 's'}, not checked here; run quox volt verify)` : 'no'}`);
|
|
515
520
|
console.log(` Redacted: ${m.redactions_present ? 'yes' : 'no'}`);
|
|
516
521
|
console.log(` Created: ${m.created_ts || 'N/A'}`);
|
|
517
522
|
console.log(` Started: ${m.started_at || 'N/A'}`);
|
package/lib/commands/wazuh.js
CHANGED
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
* the verb, endpoint and status. It is never rendered as "0 agents". `status` exits 3 when the manager is unhealthy.
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
|
+
import fs from 'fs';
|
|
31
32
|
import { resolveEndpoint } from '../api/endpoints.js';
|
|
32
33
|
import { line as glyphLine } from '../core/quoxglyph.js';
|
|
33
34
|
import { resolveOrgId } from './helpers.js';
|
|
@@ -209,8 +210,10 @@ export function attachWazuhVerbs(wazuh, program, ctx, { writes = true } = {}) {
|
|
|
209
210
|
const a = await allAgents('status');
|
|
210
211
|
const counts = countStates(a.items);
|
|
211
212
|
if (a.total > a.items.length) counts.total = a.total;
|
|
213
|
+
// WAZ-11: agent 000 is the manager's own agent; coverage leaves it out, so status says when its count includes it.
|
|
214
|
+
const includesManager = a.items.some((x) => String(x && x.id) === '000');
|
|
212
215
|
if (jsonOut()) {
|
|
213
|
-
console.log(JSON.stringify({ healthy, daemons, stopped_core: brokenCore, agents: counts }, null, 2));
|
|
216
|
+
console.log(JSON.stringify({ healthy, daemons, stopped_core: brokenCore, agents: { ...counts, includes_manager: includesManager, excluding_manager: includesManager ? counts.total - 1 : counts.total } }, null, 2));
|
|
214
217
|
} else {
|
|
215
218
|
head('status', `manager ${healthy ? 'healthy' : 'unhealthy'} · ${plural(counts.total, 'agent')}`, healthy ? 'ok' : 'fail');
|
|
216
219
|
console.log(`Manager ${healthy ? 'healthy' : 'unhealthy'}: ${running.length} running${stopped.length ? `, ${stopped.length} stopped` : ''}`);
|
|
@@ -218,6 +221,7 @@ export function attachWazuhVerbs(wazuh, program, ctx, { writes = true } = {}) {
|
|
|
218
221
|
console.log(pad(stopped.map((n) => [` ${n}`, OPTIONAL_DAEMONS.has(n) ? `${daemons[n]} (optional)` : String(daemons[n])])).join('\n'));
|
|
219
222
|
}
|
|
220
223
|
console.log(`Agents: ${countText(counts)}${a.total > a.items.length ? ` (counted the first ${a.items.length} of ${a.total})` : ''}`);
|
|
224
|
+
if (includesManager) console.log(`${plural(counts.total, 'agent')} (incl. the manager, agent 000); ${plural(counts.total - 1, 'agent')} excluding it, as quox wazuh coverage counts.`);
|
|
221
225
|
if (!healthy) console.log(`Not running: ${brokenCore.join(', ')}. Check the manager host's service logs.`);
|
|
222
226
|
}
|
|
223
227
|
if (!healthy) process.exitCode = 3;
|
|
@@ -376,6 +380,15 @@ export function attachWazuhVerbs(wazuh, program, ctx, { writes = true } = {}) {
|
|
|
376
380
|
head('coverage', s.covered === null || s.covered === undefined ? 'fleet unknown' : `${s.covered} of ${s.fleet_hosts} covered`,
|
|
377
381
|
degraded || s.missing_agent > 0 || s.disconnected > 0 ? 'warn' : 'ok');
|
|
378
382
|
console.log([n(s.fleet_hosts, 'fleet hosts'), `${s.wazuh_agents} Wazuh agents`, n(s.covered, 'covered'), n(s.missing_agent, 'missing agent'), `${s.disconnected} disconnected`, n(s.no_vuln_inventory, 'without vulnerability inventory')].join(', '));
|
|
383
|
+
// WAZ-11 (audit batch 5, N4): an unknown fleet-side number always says why, on stdout beside the summary (the JSON
|
|
384
|
+
// carries the reason as fleet_note / fleet_error; the human line used to print "unknown" with no reason).
|
|
385
|
+
if (s.fleet_hosts === null || s.fleet_hosts === undefined || s.covered === null || s.covered === undefined) {
|
|
386
|
+
const why = data.fleet_note
|
|
387
|
+
|| (data.fleet_error === 'fleet_not_permitted' ? 'this organisation is not permitted to compare against the server fleet on this Quox instance (fleet_not_permitted)' : '')
|
|
388
|
+
|| (data.fleet_error ? `the fleet list could not be read (${data.fleet_error})` : '')
|
|
389
|
+
|| 'the collector gave no reason';
|
|
390
|
+
console.log(`Fleet hosts and covered are unknown: ${why}`);
|
|
391
|
+
}
|
|
379
392
|
if (data.fleet_error) console.error(`Fleet list could not be read (${data.fleet_error}); fleet-side numbers are unknown, not zero.`);
|
|
380
393
|
if (data.vuln_error) console.error(`Vulnerability inventory could not be read (${data.vuln_error}); that section is unknown, not empty.`);
|
|
381
394
|
const section = (title, list, cols, row) => {
|
|
@@ -432,11 +445,13 @@ export function attachWazuhVerbs(wazuh, program, ctx, { writes = true } = {}) {
|
|
|
432
445
|
const { ep, data } = await getRoute('vulns', 'mon.wazuh.vulnerabilities.list', query);
|
|
433
446
|
if (!data || data.success === false || !Array.isArray(data.data)) throw new WazFail(`quox wazuh vulns: ${ep.method} ${ep.path} returned an error body: ${(data && (data.message || data.error)) || 'no vulnerabilities array'}`);
|
|
434
447
|
const list = data.data;
|
|
435
|
-
if (jsonOut()) { console.log(JSON.stringify({ vulnerabilities: list, total: data.total ?? list.length }, null, 2)); return; }
|
|
448
|
+
if (jsonOut()) { console.log(JSON.stringify({ vulnerabilities: list, total: data.total ?? list.length, total_is_lower_bound: data.total_is_lower_bound === true }, null, 2)); return; }
|
|
436
449
|
const bySev = {};
|
|
437
450
|
for (const v of list) { const s = (v.vulnerability && v.vulnerability.severity) || 'Unknown'; bySev[s] = (bySev[s] || 0) + 1; }
|
|
438
451
|
const sevText = ['Critical', 'High', 'Medium', 'Low', 'Unknown'].filter((s) => bySev[s]).map((s) => `${bySev[s]} ${s}`).join(' · ');
|
|
439
|
-
|
|
452
|
+
// WAZ-11: an indexer-capped total is a lower bound ("10,000+"), never shown as an exact count.
|
|
453
|
+
const totalText = data.total_is_lower_bound === true ? `${Number(data.total).toLocaleString('en-GB')}+` : data.total;
|
|
454
|
+
head('vulns', `${list.length === 0 ? 'none' : sevText}${(data.total ?? 0) > list.length ? ` (showing ${list.length} of ${totalText})` : ''}`, bySev.Critical ? 'warn' : 'ok');
|
|
440
455
|
if (!list.length) { console.log('No vulnerabilities.'); return; }
|
|
441
456
|
const rows = [['SEVERITY', 'SCORE', 'CVE', 'AGENT', 'PACKAGE']];
|
|
442
457
|
for (const v of list) rows.push([(v.vulnerability && v.vulnerability.severity) || '-', (v.vulnerability && v.vulnerability.score && v.vulnerability.score.base) ?? '-', (v.vulnerability && v.vulnerability.id) || '-', (v.agent && v.agent.name) || '-', v.package ? `${v.package.name || ''} ${v.package.version || ''}`.trim() : '-']);
|
|
@@ -728,15 +743,44 @@ export function attachWazuhVerbs(wazuh, program, ctx, { writes = true } = {}) {
|
|
|
728
743
|
// ---------------------------------------------------------------------------------------------------------------
|
|
729
744
|
|
|
730
745
|
async function hiddenPrompt(message) {
|
|
731
|
-
if (!process.stdin.isTTY) throw new WazFail(`quox wazuh connect: ${message} needs a terminal (the password is typed hidden, never passed as a flag
|
|
746
|
+
if (!process.stdin.isTTY) throw new WazFail(`quox wazuh connect: ${message} needs a terminal (the password is typed hidden, never passed as a flag); without a terminal pipe it in with --password-stdin / --indexer-password-stdin`);
|
|
732
747
|
const { default: inquirer } = await import('inquirer');
|
|
733
748
|
const ans = await inquirer.prompt([{ type: 'password', name: 'v', message, mask: '*', validate: (v) => (v && v.length > 0) || 'required' }]);
|
|
734
749
|
return ans.v;
|
|
735
750
|
}
|
|
736
751
|
|
|
752
|
+
/** All of stdin as text (quox#1588 --password-stdin). Never echoed. */
|
|
753
|
+
async function readAllStdin() {
|
|
754
|
+
const chunks = [];
|
|
755
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
756
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
const CA_MAX_BYTES = 64 * 1024;
|
|
760
|
+
/**
|
|
761
|
+
* quox#1588: read the indexer CA for --indexer-ca. A public certificate, but the file is still never printed: errors name
|
|
762
|
+
* the path only. Must hold at least one PEM CERTIFICATE block and NO private key (a bundle with a key is refused, so a key
|
|
763
|
+
* never lands in the vault entry by mistake).
|
|
764
|
+
*/
|
|
765
|
+
function readIndexerCa(file) {
|
|
766
|
+
let text;
|
|
767
|
+
try {
|
|
768
|
+
const st = fs.statSync(file);
|
|
769
|
+
if (!st.isFile()) throw new Error('not a file');
|
|
770
|
+
if (st.size > CA_MAX_BYTES) throw new Error(`larger than ${CA_MAX_BYTES} bytes`);
|
|
771
|
+
text = fs.readFileSync(file, 'utf8');
|
|
772
|
+
} catch (err) {
|
|
773
|
+
throw new WazFail(`quox wazuh connect: --indexer-ca ${file} could not be read (${err && err.code ? err.code : (err && err.message) || 'error'})`);
|
|
774
|
+
}
|
|
775
|
+
if (/-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(text)) throw new WazFail(`quox wazuh connect: --indexer-ca ${file} contains a private key; pass the CA certificate only (nothing was saved)`);
|
|
776
|
+
if (!/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/.test(text)) throw new WazFail(`quox wazuh connect: --indexer-ca ${file} is not a PEM certificate (expected -----BEGIN CERTIFICATE-----)`);
|
|
777
|
+
return text;
|
|
778
|
+
}
|
|
779
|
+
|
|
737
780
|
export function attachWazuhConnect(wazuh, program, ctx) {
|
|
738
781
|
const { client, auth } = ctx;
|
|
739
782
|
const promptSecret = ctx.promptSecret || hiddenPrompt;
|
|
783
|
+
const readStdin = ctx.readStdin || readAllStdin;
|
|
740
784
|
async function importInstanceEnv() {
|
|
741
785
|
const ep = resolveEndpoint('mon.wazuh.credential.import-instance-env');
|
|
742
786
|
let res;
|
|
@@ -765,19 +809,22 @@ export function attachWazuhConnect(wazuh, program, ctx) {
|
|
|
765
809
|
}
|
|
766
810
|
|
|
767
811
|
wazuh.command('connect')
|
|
768
|
-
.description('Connect this organisation to its Wazuh: stores the API (and optional indexer) credential in the vault, then runs status. Admin only. Passwords are prompted, never flags.')
|
|
812
|
+
.description('Connect this organisation to its Wazuh: stores the API (and optional indexer) credential in the vault, then runs status. Admin only. Passwords are prompted (or piped with --password-stdin), never flags.')
|
|
769
813
|
.option('--from-instance-env', 'import the collector\'s own WAZUH_* environment into this org\'s vault (instance superadmin who is also an org admin; no prompts, no other flags)')
|
|
770
814
|
.option('--url <url>', 'Wazuh manager API URL, e.g. https://wazuh-host:55000')
|
|
771
815
|
.option('--user <user>', 'Wazuh API username, e.g. wazuh-wui')
|
|
772
816
|
.option('--indexer-url <url>', 'Wazuh indexer URL, e.g. https://wazuh-host:9200')
|
|
773
817
|
.option('--indexer-user <user>', 'Wazuh indexer username (a password is then prompted)')
|
|
774
818
|
.option('--transport <mode>', 'indexer transport: auto|direct|host', 'auto')
|
|
775
|
-
.
|
|
819
|
+
.option('--indexer-ca <file>', 'PEM CA certificate file for a self-signed indexer; stored as indexer_ca in the vault entry')
|
|
820
|
+
.option('--password-stdin', 'read the Wazuh API password from stdin (line 1) instead of the hidden prompt; never argv, never echoed')
|
|
821
|
+
.option('--indexer-password-stdin', 'read the indexer password from stdin (the next line after the API password, if both) instead of the prompt; needs --indexer-user')
|
|
822
|
+
.addHelpText('after', '\nExample: quox wazuh connect --url https://wazuh.example:55000 --user wazuh-wui --indexer-user admin --transport host\nNo terminal: printf \'%s\\n%s\\n\' "$API_PW" "$IDX_PW" | quox wazuh connect --url https://wazuh.example:55000 --user wazuh-wui --indexer-user admin --indexer-ca root-ca.pem --password-stdin --indexer-password-stdin')
|
|
776
823
|
.action(async (options) => {
|
|
777
824
|
try {
|
|
778
825
|
auth.requireAuth();
|
|
779
826
|
if (options.fromInstanceEnv) {
|
|
780
|
-
if (options.url || options.user || options.indexerUrl || options.indexerUser) throw new WazFail('quox wazuh connect: --from-instance-env cannot be combined with --url, --user, --indexer-url or --indexer-
|
|
827
|
+
if (options.url || options.user || options.indexerUrl || options.indexerUser || options.indexerCa || options.passwordStdin || options.indexerPasswordStdin) throw new WazFail('quox wazuh connect: --from-instance-env cannot be combined with --url, --user, --indexer-url, --indexer-user, --indexer-ca, --password-stdin or --indexer-password-stdin');
|
|
781
828
|
await importInstanceEnv();
|
|
782
829
|
return;
|
|
783
830
|
}
|
|
@@ -786,6 +833,23 @@ export function attachWazuhConnect(wazuh, program, ctx) {
|
|
|
786
833
|
if (!/^https:\/\/[^\s/]+/.test(options.url)) throw new WazFail('quox wazuh connect: --url must be an https:// URL (the credential is sent to it)');
|
|
787
834
|
if (options.indexerUrl && !/^https:\/\/[^\s/]+/.test(options.indexerUrl)) throw new WazFail('quox wazuh connect: --indexer-url must be an https:// URL');
|
|
788
835
|
if (!['auto', 'direct', 'host'].includes(options.transport)) throw new WazFail('quox wazuh connect: --transport must be auto, direct or host');
|
|
836
|
+
if (options.indexerPasswordStdin && !options.indexerUser) throw new WazFail('quox wazuh connect: --indexer-password-stdin needs --indexer-user <user>');
|
|
837
|
+
// quox#1588: the CA file is read and checked BEFORE anything is prompted or saved.
|
|
838
|
+
const indexerCa = options.indexerCa ? readIndexerCa(String(options.indexerCa)) : null;
|
|
839
|
+
// quox#1588: stdin is read once; line 1 = API password (--password-stdin), the next = indexer password.
|
|
840
|
+
let stdinLines = null;
|
|
841
|
+
if (options.passwordStdin || options.indexerPasswordStdin) {
|
|
842
|
+
const text = String((await readStdin()) || '');
|
|
843
|
+
stdinLines = text.split(/\r?\n/).map((l) => l.trim());
|
|
844
|
+
if (!stdinLines.some(Boolean)) throw new WazFail('quox wazuh connect: --password-stdin / --indexer-password-stdin given but stdin was empty');
|
|
845
|
+
}
|
|
846
|
+
const fromStdin = (what) => {
|
|
847
|
+
const v = stdinLines.shift();
|
|
848
|
+
if (!v) throw new WazFail(`quox wazuh connect: stdin had no ${what} line (send the API password on line 1 and the indexer password on line 2 when both stdin flags are set)`);
|
|
849
|
+
return v;
|
|
850
|
+
};
|
|
851
|
+
const apiPassword = options.passwordStdin ? fromStdin('API password') : null;
|
|
852
|
+
const indexerPassword = options.indexerPasswordStdin ? fromStdin('indexer password') : null;
|
|
789
853
|
const orgId = resolveOrgId(auth, program);
|
|
790
854
|
const listEp = resolveEndpoint('vault.list', { orgId });
|
|
791
855
|
let existing;
|
|
@@ -795,12 +859,13 @@ export function attachWazuhConnect(wazuh, program, ctx) {
|
|
|
795
859
|
} catch (err) { throw failFrom('connect', listEp.method, listEp.path, err); }
|
|
796
860
|
|
|
797
861
|
const credentials = { base_url: options.url, username: options.user };
|
|
798
|
-
credentials.password = await promptSecret(`Wazuh API password for ${options.user}`);
|
|
862
|
+
credentials.password = apiPassword || await promptSecret(`Wazuh API password for ${options.user}`);
|
|
799
863
|
if (options.indexerUser) {
|
|
800
864
|
credentials.indexer_username = options.indexerUser;
|
|
801
|
-
credentials.indexer_password = await promptSecret(`Wazuh indexer password for ${options.indexerUser}`);
|
|
865
|
+
credentials.indexer_password = indexerPassword || await promptSecret(`Wazuh indexer password for ${options.indexerUser}`);
|
|
802
866
|
}
|
|
803
867
|
if (options.indexerUrl) credentials.indexer_url = options.indexerUrl;
|
|
868
|
+
if (indexerCa) credentials.indexer_ca = indexerCa;
|
|
804
869
|
credentials.indexer_transport = options.transport;
|
|
805
870
|
|
|
806
871
|
const verb = existing ? 'update' : 'create';
|
|
@@ -25,11 +25,19 @@ import { relDuration } from './kuma.js';
|
|
|
25
25
|
|
|
26
26
|
const RUNBOOK = 'docs/wazuh/custom-quox-integration.md';
|
|
27
27
|
|
|
28
|
+
// WAZ-11: ARGUS's routing proposal from the verdict: what it proposes and the command a person runs to accept it.
|
|
29
|
+
function proposalCell(p) {
|
|
30
|
+
if (!p || typeof p !== 'object' || !p.proposed_action) return 'none';
|
|
31
|
+
return `${p.proposed_action}${p.verdict ? ` (ARGUS verdict ${p.verdict})` : ''}${p.command ? `: ${p.command}` : ''}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
28
34
|
// WAZ P7: compact context flags. A source that could not answer shows as SRC?, never as "nothing".
|
|
35
|
+
// WAZ-11: a source the org never set up (context.not_configured, e.g. no Uptime Kuma) is left out, not SRC? on every row.
|
|
29
36
|
const UNKNOWN_FLAG = { maintenance: 'MAINT?', kuma: 'KUMA?', deploys: 'DEPLOY?' };
|
|
30
37
|
export function contextFlags(ctx) {
|
|
31
38
|
if (!ctx || typeof ctx !== 'object') return '-';
|
|
32
|
-
const
|
|
39
|
+
const skip = new Set(Array.isArray(ctx.not_configured) ? ctx.not_configured : []);
|
|
40
|
+
const parts = [...(ctx.flags || []), ...(ctx.unavailable || []).filter((s) => !skip.has(s)).map((s) => UNKNOWN_FLAG[s] || `${String(s).toUpperCase()}?`)];
|
|
33
41
|
return parts.length ? parts.join(' ') : '-';
|
|
34
42
|
}
|
|
35
43
|
|
|
@@ -210,6 +218,10 @@ export function registerWazuhIntake(wazuh, { client, auth, jsonOut, guard, WazFa
|
|
|
210
218
|
}
|
|
211
219
|
|
|
212
220
|
// ---- incidents ----
|
|
221
|
+
// WAZ P11: the governance incident a level >= notify-level Wazuh incident opened (shared lifecycle: status, assignee, tickets).
|
|
222
|
+
const governanceCell = (i) => (i.governance_incident_id
|
|
223
|
+
? `${i.governance_incident_id} (${i.governance_incident && i.governance_incident.status ? i.governance_incident.status : 'status unavailable'})`
|
|
224
|
+
: '-');
|
|
213
225
|
const ago = (iso) => { const t = Date.parse(iso); return Number.isFinite(t) ? relDuration(Date.now() - t).replace(/ /g, '') : (iso || '-'); };
|
|
214
226
|
|
|
215
227
|
const incidents = example(wazuh.command('incidents')
|
|
@@ -223,16 +235,42 @@ export function registerWazuhIntake(wazuh, { client, auth, jsonOut, guard, WazFa
|
|
|
223
235
|
if (bad(data) || !Array.isArray(data.incidents)) throw errBody('incidents', ep, data, 'response had no incidents array');
|
|
224
236
|
if (jsonOut()) { console.log(JSON.stringify(data, null, 2)); return; }
|
|
225
237
|
const openN = data.incidents.filter((i) => i.open).length;
|
|
226
|
-
|
|
227
|
-
|
|
238
|
+
// WAZ-11: governance incidents still open/investigating after their Wazuh side went quiet (additive; the same
|
|
239
|
+
// section ARGUS's wazuh_incidents returns, so the CLI and chat count the same).
|
|
240
|
+
const q = data.quiet_still_open && typeof data.quiet_still_open === 'object' ? data.quiet_still_open : null;
|
|
241
|
+
const qRows = q && Array.isArray(q.incidents) ? q.incidents : [];
|
|
242
|
+
const qUnknown = !!(q && q.count === null);
|
|
243
|
+
const qN = qRows.length;
|
|
244
|
+
const qHead = qUnknown ? ' · gov-open: unknown' : (qN ? ` · ${qN} gov-open (quiet)` : '');
|
|
245
|
+
head('incidents', `${data.incidents.length} shown · ${openN} open${qHead}`, openN || qN || qUnknown ? 'warn' : 'ok');
|
|
246
|
+
const printQuiet = () => {
|
|
247
|
+
if (qUnknown) { console.log(`\nstill open in governance: unknown (${q.error || 'the collector could not read it'})`); return; }
|
|
248
|
+
if (!qN) return;
|
|
249
|
+
// WAZ-11 (audit batch 5, NEW-D a): VERDICT, ARGUS's triage verdict of the Wazuh incident ('-' = not triaged).
|
|
250
|
+
const qr = [['GOVERNANCE', 'STATUS', 'SEVERITY', 'ASSIGNED', 'WAZUH', 'CLOSED AS', 'CLOSED', 'AGENT', 'RULE', 'VERDICT']];
|
|
251
|
+
for (const g of qRows) {
|
|
252
|
+
qr.push([g.id, g.status, g.severity, g.assigned_to || '-', g.wazuh_incident_id || '-', g.wazuh_close_reason || '-', ago(g.wazuh_closed_at),
|
|
253
|
+
g.agent_name || g.agent_id || '-', g.rule_id || '-', g.verdict || '-']);
|
|
254
|
+
}
|
|
255
|
+
console.log(`\n${q.label || 'Wazuh went quiet; still open in governance'} (${qN}):`);
|
|
256
|
+
console.log(pad(qr).join('\n'));
|
|
257
|
+
console.log('Resolve or claim: quox monitoring incidents claim <id> | quox monitoring incidents status <id> resolved --resolution "<what was done>"');
|
|
258
|
+
};
|
|
259
|
+
if (!data.incidents.length) { printQuiet(); return; }
|
|
228
260
|
// WAZ P7: CONTEXT (MAINT / KUMA-DOWN / DEPLOYED, SRC? when a source could not answer) when the collector sent it.
|
|
229
261
|
const withCtx = data.incidents.some((i) => i.context);
|
|
230
|
-
|
|
262
|
+
// WAZ P11: GOVERNANCE (the linked governance incident id + its status) when any incident is linked.
|
|
263
|
+
const withGov = data.incidents.some((i) => i.governance_incident_id);
|
|
264
|
+
// WAZ P11 1c: VERDICT (ARGUS's rule-based first pass: noise / fix_needed / security_event) when any incident has one.
|
|
265
|
+
const withVerdict = data.incidents.some((i) => i.triage && i.triage.verdict);
|
|
266
|
+
const rows = [['ID', 'STATE', 'LEVEL', 'AGENT', 'RULE', 'DESCRIPTION', 'COUNT', 'LAST SEEN', 'MITRE', ...(withCtx ? ['CONTEXT'] : []), ...(withGov ? ['GOVERNANCE'] : []), ...(withVerdict ? ['VERDICT'] : [])]];
|
|
231
267
|
for (const i of data.incidents) {
|
|
232
268
|
rows.push([i.id, i.open ? 'open' : `closed${i.close_reason === 'quiet' ? ' (quiet)' : ''}`, i.level, i.agent_name || i.agent_id, i.rule_id,
|
|
233
|
-
String(i.rule_description || '-').slice(0, 60), i.count ?? '-', ago(i.last_seen), (i.mitre_ids || []).join(',') || '-', ...(withCtx ? [contextFlags(i.context)] : [])
|
|
269
|
+
String(i.rule_description || '-').slice(0, 60), i.count ?? '-', ago(i.last_seen), (i.mitre_ids || []).join(',') || '-', ...(withCtx ? [contextFlags(i.context)] : []),
|
|
270
|
+
...(withGov ? [governanceCell(i)] : []), ...(withVerdict ? [(i.triage && i.triage.verdict) || '-'] : [])]);
|
|
234
271
|
}
|
|
235
272
|
console.log(pad(rows).join('\n'));
|
|
273
|
+
printQuiet();
|
|
236
274
|
})), 'quox wazuh incidents --open --since 24h');
|
|
237
275
|
|
|
238
276
|
example(incidents.command('show')
|
|
@@ -256,12 +294,23 @@ export function registerWazuhIntake(wazuh, { client, auth, jsonOut, guard, WazFa
|
|
|
256
294
|
? `no: suppressed, host was in a Kuma maintenance window (${(i.suppressed.monitors || []).join(', ') || 'monitor'})`
|
|
257
295
|
: 'no (digest only)')],
|
|
258
296
|
['Log', i.log_snippet || '-'],
|
|
297
|
+
['Governance', governanceCell(i)],
|
|
298
|
+
['Verdict', i.triage && i.triage.verdict ? `${i.triage.verdict} (ARGUS rule-based first pass, no model)` : 'not triaged (set when a governance incident is opened)'],
|
|
299
|
+
...(i.triage && Array.isArray(i.triage.reasons) && i.triage.reasons.length ? [['Why', i.triage.reasons.join('; ')]] : []),
|
|
300
|
+
['Proposal', proposalCell(i.proposal)],
|
|
259
301
|
...contextRows(i.context),
|
|
260
302
|
];
|
|
261
303
|
const dg = i.diagnosis;
|
|
262
304
|
rows.push(['Diagnosis', dg ? `${dg.status} (${dg.trigger || dg.mode || 'manual'}) ${dg.summary || ''}`.trim() : `not run (quox wazuh incidents diagnose ${i.id})`]);
|
|
263
305
|
const es = i.escalation;
|
|
264
|
-
|
|
306
|
+
// WAZ-11 (audit batch 5, N3): two different escalations, labelled apart. Escalated = sent to QuoxSecurity as a
|
|
307
|
+
// finding; Unowned = the governance incident's own flag (escalated because nobody claimed it).
|
|
308
|
+
rows.push(['Escalated', es && es.finding_id ? `finding ${es.finding_id} in engagement ${es.engagement_id}${es.engagement_name ? ` (${es.engagement_name})` : ''}: sent to QuoxSecurity (${es.finding_id})` : `not sent to QuoxSecurity (quox wazuh incidents escalate ${i.id})`]);
|
|
309
|
+
if (typeof i.governance_unowned_escalated === 'boolean') {
|
|
310
|
+
rows.push(['Unowned', i.governance_unowned_escalated
|
|
311
|
+
? `unowned-escalated (nobody claimed ${i.governance_incident_id} in time: inbox item + LUCID ask; claim it: quox monitoring incidents claim ${i.governance_incident_id})`
|
|
312
|
+
: 'not unowned-escalated']);
|
|
313
|
+
}
|
|
265
314
|
if (!i.open) rows.push(['Closed', `${i.closed_at}${i.closed_by ? ` by ${i.closed_by}` : ''}${i.close_reason ? `: ${i.close_reason}` : ''}`]);
|
|
266
315
|
console.log(pad(rows).join('\n'));
|
|
267
316
|
if (dg) printChecks(dg, pad);
|
|
@@ -56,6 +56,7 @@ export const COMMAND_REGISTRARS = [
|
|
|
56
56
|
['../commands/file.js', 'registerFileCommands'],
|
|
57
57
|
['../commands/evidence.js', 'registerEvidenceCommands'],
|
|
58
58
|
['../commands/admin.js', 'registerAdminCommands'],
|
|
59
|
+
['../commands/mcp.js', 'registerMcpCommands'],
|
|
59
60
|
['../commands/aee.js', 'registerAeeCommands'],
|
|
60
61
|
['../commands/integration.js', 'registerIntegrationCommands'],
|
|
61
62
|
['../commands/consent.js', 'registerConsentCommands'],
|
|
@@ -114,6 +115,7 @@ export const COMMAND_REGISTRARS = [
|
|
|
114
115
|
['../commands/awareness.js', 'registerAwarenessCommands'],
|
|
115
116
|
['../commands/mind.js', 'registerMindCommands'],
|
|
116
117
|
['../commands/lucid.js', 'registerLucidCommands'],
|
|
118
|
+
['../commands/lens.js', 'registerLensCommands'],
|
|
117
119
|
['../commands/hyperion.js', 'registerHyperionCommands'],
|
|
118
120
|
['../commands/tg.js', 'registerTgCommands'],
|
|
119
121
|
['../commands/dev.js', 'registerDevCommands'],
|
package/lib/core/config.js
CHANGED
|
@@ -41,6 +41,15 @@ function acquireLock(lockFile) {
|
|
|
41
41
|
function releaseLock(lockFile) { try { unlinkSync(lockFile); } catch { /* already gone */ } }
|
|
42
42
|
const J = (v) => JSON.stringify(v === undefined ? null : v);
|
|
43
43
|
|
|
44
|
+
function envTokenExpiryMs(token) {
|
|
45
|
+
try {
|
|
46
|
+
const payload = JSON.parse(Buffer.from(String(token).split('.')[1], 'base64url').toString('utf8'));
|
|
47
|
+
return typeof payload.exp === 'number' ? payload.exp * 1000 : null;
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
44
53
|
const DEFAULT_CONFIG = {
|
|
45
54
|
version: 1,
|
|
46
55
|
activeProfile: 'local',
|
|
@@ -417,7 +426,11 @@ export function loadConfig(profileOverride) {
|
|
|
417
426
|
getAuth() {
|
|
418
427
|
// Env var override
|
|
419
428
|
if (process.env.QUOX_TOKEN) {
|
|
420
|
-
|
|
429
|
+
// The JWT's own exp is the env token's expiry. Without it auth.isExpired() read every env
|
|
430
|
+
// token as expired and tried a refresh there is no refresh token for (the dashboard /cli
|
|
431
|
+
// browser terminal then opened the conversation app signed out). Not verification: the server verifies.
|
|
432
|
+
const exp = envTokenExpiryMs(process.env.QUOX_TOKEN);
|
|
433
|
+
return exp ? { access_token: process.env.QUOX_TOKEN, expires_at: exp } : { access_token: process.env.QUOX_TOKEN };
|
|
421
434
|
}
|
|
422
435
|
return this.getActiveProfile()?.auth || null;
|
|
423
436
|
},
|
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 {
|
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();
|
|
@@ -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) {
|
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);
|