quoxcli 0.4.0 → 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/admin.js +7 -0
- package/lib/api/endpoints/agent-attach.js +7 -0
- package/lib/api/endpoints/compliance.js +25 -0
- package/lib/api/endpoints/inbox.js +6 -0
- package/lib/api/endpoints/inboxq.js +19 -0
- package/lib/api/endpoints/index.js +2 -0
- package/lib/api/endpoints/mon.js +122 -0
- package/lib/api/endpoints/tools.js +13 -0
- package/lib/api/endpoints/v1.js +13 -0
- package/lib/commands/admin.js +35 -0
- package/lib/commands/agentAttach.js +15 -0
- package/lib/commands/approvals.js +193 -3
- package/lib/commands/ask.js +1 -1
- package/lib/commands/auth.js +64 -11
- package/lib/commands/backup.js +11 -4
- package/lib/commands/capabilities.js +3 -2
- package/lib/commands/chat.js +15 -0
- package/lib/commands/claude.js +4 -1
- package/lib/commands/compliance.js +109 -0
- package/lib/commands/connect.js +69 -0
- package/lib/commands/dev.js +6 -8
- package/lib/commands/doctor.js +22 -3
- package/lib/commands/evidence.js +2 -1
- package/lib/commands/gripe.js +24 -13
- package/lib/commands/inbox.js +37 -3
- package/lib/commands/inboxq.js +147 -0
- package/lib/commands/kuma.js +78 -5
- package/lib/commands/licenceLinkView.js +311 -0
- package/lib/commands/linkedin.js +19 -0
- package/lib/commands/loop.js +30 -3
- package/lib/commands/lucid.js +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 +5 -2
- package/lib/commands/offload.js +2 -3
- package/lib/commands/plan.js +17 -0
- package/lib/commands/provider.js +29 -8
- package/lib/commands/qlarity.js +32 -1
- package/lib/commands/run.js +2 -1
- package/lib/commands/server.js +37 -0
- package/lib/commands/setup.js +127 -75
- package/lib/commands/skills.js +4 -1
- package/lib/commands/slot.js +49 -9
- package/lib/commands/status.js +92 -44
- package/lib/commands/tool.js +30 -0
- package/lib/commands/vault.js +6 -3
- package/lib/commands/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/core/announce.js +86 -0
- package/lib/core/auth.js +20 -2
- package/lib/core/callMe.js +118 -0
- package/lib/core/client.js +27 -1
- package/lib/core/commandRegistrars.js +8 -0
- package/lib/core/config.js +28 -2
- package/lib/core/confirm.js +68 -7
- package/lib/core/crossOrgApprovals.js +69 -0
- package/lib/core/errors.js +52 -1
- package/lib/core/governedSession.js +135 -5
- package/lib/core/inboxResolution.js +3 -2
- package/lib/core/loginGuard.js +73 -0
- package/lib/core/offload.js +37 -0
- package/lib/core/org-override.js +5 -1
- package/lib/core/output.js +1 -1
- package/lib/core/profileTrust.js +182 -0
- package/lib/core/quoxglyph.js +22 -1
- package/lib/core/router.js +14 -0
- package/lib/core/stepUpPrompt.js +99 -0
- package/lib/core/version.js +1 -1
- package/lib/core/walkthrough.js +9 -5
- package/lib/dragon/select.js +2 -2
- package/lib/quoxon/EXTENSIONS.md +142 -19
- package/lib/quoxon/api.js +185 -34
- package/lib/quoxon/app.js +1032 -152
- package/lib/quoxon/approvalBatch.js +117 -0
- package/lib/quoxon/approvalFacts.js +56 -2
- package/lib/quoxon/approvalSentence.js +8 -4
- package/lib/quoxon/approvalsView.js +69 -11
- 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 +7 -0
- package/lib/quoxon/commands.js +40 -10
- package/lib/quoxon/connect.js +5 -3
- package/lib/quoxon/deckLazy.js +58 -0
- package/lib/quoxon/destructive.js +35 -0
- package/lib/quoxon/dnd.js +71 -0
- package/lib/quoxon/dragonCompanion.js +37 -7
- package/lib/quoxon/dragonMoments.js +78 -0
- package/lib/quoxon/echo.js +1 -0
- package/lib/quoxon/events.js +43 -7
- package/lib/quoxon/extensions.js +398 -38
- package/lib/quoxon/gripeQueue.js +135 -0
- package/lib/quoxon/inboxView.js +125 -22
- package/lib/quoxon/keys.js +2 -2
- package/lib/quoxon/loopCard.js +5 -1
- package/lib/quoxon/loopCards.js +17 -8
- package/lib/quoxon/loopStream.js +20 -9
- package/lib/quoxon/mailView.js +71 -0
- package/lib/quoxon/main.js +20 -5
- package/lib/quoxon/markdown.js +1 -1
- package/lib/quoxon/model.js +15 -0
- package/lib/quoxon/neverAnswered.js +20 -0
- package/lib/quoxon/objects/away.js +43 -5
- package/lib/quoxon/onboarding/connectServer.js +162 -0
- package/lib/quoxon/onboarding/copy.js +6 -0
- package/lib/quoxon/onboarding/index.js +5 -43
- package/lib/quoxon/onboarding/probe.js +20 -1
- package/lib/quoxon/onboarding/view.js +16 -3
- package/lib/quoxon/orgSwitch.js +22 -0
- package/lib/quoxon/print.js +2 -0
- package/lib/quoxon/profileSwitch.js +7 -0
- package/lib/quoxon/promptHistory.js +3 -1
- package/lib/quoxon/render.js +63 -14
- package/lib/quoxon/roster.js +10 -5
- package/lib/quoxon/rosterSessions.js +44 -0
- package/lib/quoxon/screenContext.js +1 -1
- package/lib/quoxon/secretShape.js +86 -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/cliLeaves.json +63 -1
- package/lib/quoxon/slash/hatWalk.js +1 -1
- package/lib/quoxon/slash/palette.js +22 -1
- package/lib/quoxon/slash/paletteController.js +10 -4
- package/lib/quoxon/slash/restart.js +7 -4
- package/lib/quoxon/slash/stepUp.js +5 -5
- package/lib/quoxon/slash/stepUpCmds.js +74 -1
- package/lib/quoxon/slashSpinner.js +35 -0
- package/lib/quoxon/slotAttach.js +8 -0
- package/lib/quoxon/slotChat.js +17 -0
- package/lib/quoxon/slotFormat.js +1 -1
- package/lib/quoxon/slotOutbox.js +113 -0
- package/lib/quoxon/suggestCommand.js +1 -1
- package/lib/quoxon/term.js +25 -1
- package/lib/quoxon/transcript.js +2 -1
- package/lib/quoxon/welcome.js +1 -1
- package/lib/quoxon/whatsnew.js +1 -1
- package/lib/quoxon/whatsnew.jsonl +36 -0
- package/lib/server/installFlow.js +168 -0
- package/lib/server/status.js +15 -1
- package/lib/toolOutputShape.js +55 -0
- package/package.json +3 -2
- package/vendor/dragonlab/SOURCE +3 -3
- package/vendor/dragonlab/design/quoxperticus/v2/quoxperticus-master.svg +59 -0
- package/vendor/dragonlab/design/quoxperticus/v3/character.mjs +43 -0
- package/vendor/dragonlab/design/quoxperticus/v4/character.mjs +57 -0
- package/vendor/dragonlab/design/quoxperticus/v4/review.mjs +35 -0
- package/vendor/dragonlab/design/quoxperticus/v4/watch.mjs +56 -0
- package/vendor/dragonlab/design/vulcan/v1/character.mjs +34 -0
- package/vendor/dragonlab/design/vulcan/v1/vulcan-master.svg +57 -0
- package/vendor/dragonlab/design/vulcan/v2/character.mjs +39 -0
- package/vendor/dragonlab/design/vulcan/v3/character.mjs +41 -0
- package/vendor/dragonlab/design/vulcan/v3/review.mjs +35 -0
- package/vendor/dragonlab/design/vulcan/v3/watch.mjs +56 -0
- package/vendor/dragonlab/factory/svg-rig-contour.mjs +64 -0
- package/vendor/dragonlab/integration/companions-v1/characters.mjs +20 -0
- package/vendor/dragonlab/integration/companions-v1/quoxperticus.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/view.mjs +1 -1
- package/vendor/dragonlab/integration/companions-v1/vulcan.mjs +2 -0
package/lib/core/errors.js
CHANGED
|
@@ -214,6 +214,14 @@ export function explainError(err, ctx = null) {
|
|
|
214
214
|
const raw = RAW_HEADLINE.test(String(err.message || '').split('\n')[0]) || err.reasonFromError === true;
|
|
215
215
|
const reason = serverReason(err);
|
|
216
216
|
|
|
217
|
+
// SKILLRIGHTS #478: require-acceptance refusal is not a generic 409 conflict.
|
|
218
|
+
if (status === 409 && (code === 'acceptance_required' || body.error === 'acceptance_required')) {
|
|
219
|
+
const pack = typeof body.packId === 'string' && body.packId ? body.packId : 'quox-core-dev-pack';
|
|
220
|
+
return {
|
|
221
|
+
headline: sentence(reason || `This org's distribution policy is require-acceptance, and nobody has accepted the current terms for pack '${pack}'`),
|
|
222
|
+
next: `accept the terms as yourself: quox dev skills accept ${pack} (add --i-accept with no terminal), then re-run the sync.`,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
217
225
|
// #8 service-key fallback: the real problem is "no stored session", say it once.
|
|
218
226
|
if (err.serviceKeyFallback && (status === 401 || status === 403)) {
|
|
219
227
|
return {
|
|
@@ -268,6 +276,28 @@ export function explainError(err, ctx = null) {
|
|
|
268
276
|
if (status === 404 && /^approval not found$/i.test(reason || '')) {
|
|
269
277
|
return { headline: 'No approval with that id in this org; it may belong to another org or be past retention.', next: notFoundNext(ctx) || '`quox approvals --help` shows how to list what exists.' };
|
|
270
278
|
}
|
|
279
|
+
// DS-010 / #1335: a step-up refusal is about WHO is at the keyboard, not about org or role. The old fall-through
|
|
280
|
+
// sent people to "switch org / ask an admin", which cannot help: only the approver's own PIN does.
|
|
281
|
+
if (status === 403 && /step_up_required/.test(all)) {
|
|
282
|
+
if (body.reason === 'non_human') {
|
|
283
|
+
return { headline: 'This approval needs a person, and this login is an agent or service identity.', next: 'approve it from a person\'s own login (the dashboard, or `quox` in their terminal).' };
|
|
284
|
+
}
|
|
285
|
+
return {
|
|
286
|
+
headline: 'This approval needs your step-up PIN.',
|
|
287
|
+
next: body.pin_set === false
|
|
288
|
+
? 'set a PIN in the dashboard (Settings > Account > Approval PIN), then run this again in a terminal.'
|
|
289
|
+
: 'run this command in an interactive terminal; it will ask for the PIN with hidden input. The PIN cannot be passed as a flag or environment variable.',
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
// the auth service's requireInstanceAdmin refusal (code instance_admin_required): instance admin is instance-wide,
|
|
293
|
+
// so neither switching org nor asking an org admin can help
|
|
294
|
+
if (status === 403 && /instance_admin_required|instance administrator access required/i.test(all)) {
|
|
295
|
+
return { headline: sentence(reason || 'Instance administrator access required'), next: 'this needs an instance administrator account. Run it with a profile that is one (quox -p <profile> ...), or ask your instance admin.' };
|
|
296
|
+
}
|
|
297
|
+
// an instance-admin gate: switching org cannot help, only an instance admin can
|
|
298
|
+
if (status === 403 && /instance admin required/i.test(all)) {
|
|
299
|
+
return { headline: 'This needs an instance admin, and this login is not one.', next: 'ask an instance admin of this Quox server to run it, or to make your login an instance admin.' };
|
|
300
|
+
}
|
|
271
301
|
// #5 a role refusal is not an org-context problem
|
|
272
302
|
if (status === 403 && /insufficient permissions?|lacks?[ _]capability|requires? (an? |the )?(admin|owner) role|not an? (org )?(admin|owner)/i.test(all)) {
|
|
273
303
|
return { headline: sentence(reason || 'Your role does not allow this'), next: 'your role in this org does not allow this; ask an org owner or admin to do it or to raise your role.' };
|
|
@@ -332,6 +362,7 @@ function fallbackExplain(err, ctx) {
|
|
|
332
362
|
}
|
|
333
363
|
|
|
334
364
|
// CLI-ERRORS: an instruction sentence (the step the user takes) vs the rest (what happened).
|
|
365
|
+
const TLS_HINT_CODES = ['DEPTH_ZERO_SELF_SIGNED_CERT', 'SELF_SIGNED_CERT_IN_CHAIN', 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY', 'ERR_TLS_CERT_ALTNAME_INVALID', 'CERT_HAS_EXPIRED'];
|
|
335
366
|
const INSTRUCTION = /^(run|check|use|retry|try|pass|fix|set|pick|switch|ask|log ?in|re-?run|please)\b/i;
|
|
336
367
|
|
|
337
368
|
function splitInstruction(text) {
|
|
@@ -351,6 +382,10 @@ function splitInstruction(text) {
|
|
|
351
382
|
* CLI-ERRORS: errors the CLI raises itself (auth, network, validation, anything else) in the same
|
|
352
383
|
* contract as API errors: one headline, at most one Next, the rest (`more`) only with --verbose.
|
|
353
384
|
*/
|
|
385
|
+
function connectUrl(u) {
|
|
386
|
+
try { return new URL(u).origin; } catch { return '<url>'; }
|
|
387
|
+
}
|
|
388
|
+
|
|
354
389
|
export function explainLocal(err) {
|
|
355
390
|
if (!err || err instanceof QuoxApiError || err instanceof QuoxUserError) return null;
|
|
356
391
|
const message = String(err.message || '');
|
|
@@ -362,7 +397,7 @@ export function explainLocal(err) {
|
|
|
362
397
|
const more = message.split('\n').slice(1).map((l) => l.trim()).filter(Boolean);
|
|
363
398
|
if (m && code && /CERT|SELF_SIGNED|UNABLE_TO_VERIFY/.test(code)) {
|
|
364
399
|
return { headline: `Could not reach the ${svc} service at ${url}: its certificate is not trusted (${code}).`,
|
|
365
|
-
next:
|
|
400
|
+
next: `trust this server for one profile with quox connect ${connectUrl(url)} --profile <name> (it shows the certificate SHA256 and asks y/N; with no terminal add --fingerprint <sha256>); never disable TLS verification.`, more };
|
|
366
401
|
}
|
|
367
402
|
if (m && (code === 'ERR_SSL_WRONG_VERSION_NUMBER' || code === 'EPROTO')) {
|
|
368
403
|
return { headline: `The ${svc} service at ${url} speaks plain http, but this profile's URL says https://.`,
|
|
@@ -389,6 +424,20 @@ export function explainLocal(err) {
|
|
|
389
424
|
return sp;
|
|
390
425
|
}
|
|
391
426
|
if (err instanceof QuoxValidationError) return splitInstruction(message);
|
|
427
|
+
// H4: a bare fetch (login, MFA, refresh) that died on a certificate this machine does not trust. undici reports it
|
|
428
|
+
// as "fetch failed"; name the problem and the exact next command. Never auto-trust, never TLS-off.
|
|
429
|
+
const tlsCode = TLS_HINT_CODES.find((c) => c === (err.cause?.cause?.code || err.cause?.code || err.code));
|
|
430
|
+
if (tlsCode && err.quoxUrl) {
|
|
431
|
+
const profile = err.quoxProfile ? ` --profile ${err.quoxProfile}` : '';
|
|
432
|
+
const expired = tlsCode === 'CERT_HAS_EXPIRED' || tlsCode === 'ERR_TLS_CERT_ALTNAME_INVALID';
|
|
433
|
+
return { headline: expired
|
|
434
|
+
? `The server at ${err.quoxUrl} presents a certificate that ${tlsCode === 'CERT_HAS_EXPIRED' ? 'has expired' : 'does not name the address you used'} (${tlsCode}).`
|
|
435
|
+
: `The server at ${err.quoxUrl} uses a certificate this machine does not trust yet.`,
|
|
436
|
+
next: expired
|
|
437
|
+
? 'the server needs a certificate that is current and names this address; trusting it would not help. Never disable TLS verification.'
|
|
438
|
+
: `run quox connect ${err.quoxUrl}${profile} (it shows the SHA256 fingerprint and asks y/N); with no terminal use quox connect ${err.quoxUrl}${profile} --trust-cert, or --fingerprint <sha256> to pin it. Never disable TLS verification.`,
|
|
439
|
+
more: [`TLS error: ${tlsCode}`] };
|
|
440
|
+
}
|
|
392
441
|
const first = message.split('\n')[0];
|
|
393
442
|
return { headline: sentence(first || 'Something went wrong'), next: null, more: message.split('\n').slice(1), stack: true };
|
|
394
443
|
}
|
|
@@ -404,6 +453,7 @@ function renderContract(err, ex, ctx) {
|
|
|
404
453
|
}
|
|
405
454
|
if (err.detail) console.error(chalk.dim(` Detail: ${renderDetail(err.detail)}`));
|
|
406
455
|
if (isVerbose(ctx)) {
|
|
456
|
+
if (err.debug) console.error(chalk.dim(` Debug: ${renderDetail(err.debug)}`));
|
|
407
457
|
if (err.status) console.error(chalk.dim(` Status: ${err.status} ${typeof err.code === 'object' && err.code ? renderDetail(err.code) : (err.code || '')}`));
|
|
408
458
|
if (err instanceof QuoxApiError && err.message && err.message !== ex.headline) console.error(chalk.dim(` Server said: ${err.message}`));
|
|
409
459
|
for (const line of ex.more || []) if (line) console.error(chalk.dim(` More: ${line}`));
|
|
@@ -420,6 +470,7 @@ export function handleError(err, outputFormat = 'table', ctx = null) {
|
|
|
420
470
|
};
|
|
421
471
|
if (err.status) obj.status = err.status;
|
|
422
472
|
if (err.detail) obj.detail = err.detail;
|
|
473
|
+
if (err.debug) obj.debug = err.debug; // plumbing kept out of the headline, still there for scripts
|
|
423
474
|
// LL-3d: scripts parsing --json get the server's own body verbatim, not
|
|
424
475
|
// our prose rendering of it.
|
|
425
476
|
if (err.body && typeof err.body === 'object') obj.body = err.body;
|
|
@@ -12,7 +12,18 @@ import os from 'node:os';
|
|
|
12
12
|
import fs from 'node:fs';
|
|
13
13
|
import path from 'node:path';
|
|
14
14
|
import { spawnSync as realSpawnSync } from 'node:child_process';
|
|
15
|
-
import {
|
|
15
|
+
import { colorOn } from './banner.js';
|
|
16
|
+
import { qLines, qDims } from '../quoxon/qart.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The session banner's Q is quoxon's settled vector horned-Q (the face of the app, same mark as
|
|
20
|
+
* bare `quox` and cli.quox.ai), not the old half-block bitmap. 'small' for a narrow terminal, 'mid'
|
|
21
|
+
* otherwise. Returns the art rows and their visible width (used to pad the text column alignment).
|
|
22
|
+
*/
|
|
23
|
+
function introArt(color, cols) {
|
|
24
|
+
const size = cols && cols < 72 ? 'small' : 'mid';
|
|
25
|
+
return { lines: qLines(size, { color }), width: qDims(size).cols };
|
|
26
|
+
}
|
|
16
27
|
import { startHeartbeat as realStartHeartbeat } from './sessionHeartbeat.js';
|
|
17
28
|
|
|
18
29
|
const NAME_RE = /^[A-Za-z0-9._-]{1,48}$/;
|
|
@@ -160,10 +171,10 @@ export function howItWorksLines({ name, program = 'claude', bare }) {
|
|
|
160
171
|
];
|
|
161
172
|
}
|
|
162
173
|
|
|
163
|
-
export function renderIntro({ name, host, mode, tmuxSession, note, program = 'claude', preattach }, { color = false } = {}) {
|
|
174
|
+
export function renderIntro({ name, host, mode, tmuxSession, note, program = 'claude', preattach }, { color = false, cols = process.stdout?.columns } = {}) {
|
|
164
175
|
const b = (s) => (color ? `\x1b[1m${s}\x1b[0m` : s);
|
|
165
176
|
const d = (s) => (color ? `\x1b[2m${s}\x1b[0m` : s);
|
|
166
|
-
const art =
|
|
177
|
+
const { lines: art, width: artW } = introArt(color, cols);
|
|
167
178
|
const modeText = {
|
|
168
179
|
NEW: 'NEW',
|
|
169
180
|
REATTACH: 'REATTACH (same process, no context reload)',
|
|
@@ -182,7 +193,7 @@ export function renderIntro({ name, host, mode, tmuxSession, note, program = 'cl
|
|
|
182
193
|
b('How this works'),
|
|
183
194
|
...howItWorksLines({ name, program }).map(([k, v]) => `${d(k.padEnd(19))}${v}`),
|
|
184
195
|
];
|
|
185
|
-
const w = art[0] ?
|
|
196
|
+
const w = art[0] ? artW : 0;
|
|
186
197
|
const lines = [];
|
|
187
198
|
for (let i = 0; i < Math.max(art.length, text.length); i++) {
|
|
188
199
|
lines.push(` ${art[i] || ' '.repeat(w)} ${text[i] || ''}`);
|
|
@@ -254,7 +265,108 @@ export function ensureHelpBinding(d) {
|
|
|
254
265
|
return r && r.status === 0 ? 'bound' : 'failed';
|
|
255
266
|
}
|
|
256
267
|
|
|
257
|
-
|
|
268
|
+
/**
|
|
269
|
+
* SESSION-LAUNCH-DEDUPE: tmux session names are case-sensitive but conversation names are not, so
|
|
270
|
+
* `--resume quoxon` must find `quox-QUOXON`. Returns the real session name or null.
|
|
271
|
+
*/
|
|
272
|
+
export function findSessionCI(d, wanted) {
|
|
273
|
+
const r = d.spawnSync('tmux', ['list-sessions', '-F', '#{session_name}'], { encoding: 'utf8' });
|
|
274
|
+
if (!r || r.error || r.status !== 0) return null;
|
|
275
|
+
const w = String(wanted).toLowerCase();
|
|
276
|
+
const names = String(r.stdout || '').split('\n').map((l) => l.split('\t')[0].trim()).filter(Boolean);
|
|
277
|
+
return names.find((n) => n.toLowerCase() === w) || null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const PS_RE = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/;
|
|
281
|
+
const base = (t) => String(t || '').split('/').pop();
|
|
282
|
+
|
|
283
|
+
/** True when argv is a claude process resuming `name` (case-insensitive), also via `node .../claude`. */
|
|
284
|
+
export function resumesName(args, name) {
|
|
285
|
+
const t = String(args).trim().split(/\s+/);
|
|
286
|
+
let i = 0;
|
|
287
|
+
if (base(t[0]) === 'node' && base(t[1]) === 'claude') i = 1;
|
|
288
|
+
else if (base(t[0]) !== 'claude') return false;
|
|
289
|
+
const want = String(name).toLowerCase();
|
|
290
|
+
for (let k = i + 1; k < t.length; k++) {
|
|
291
|
+
if ((t[k] === '--resume' || t[k] === '-r') && String(t[k + 1] || '').toLowerCase() === want) return true;
|
|
292
|
+
if (t[k].startsWith('--resume=') && t[k].slice(9).toLowerCase() === want) return true;
|
|
293
|
+
}
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Live claude processes already holding conversation `name`: [{ pid, tty, tmux }]. */
|
|
298
|
+
export function findLiveHolders(d, name) {
|
|
299
|
+
const ps = d.spawnSync('ps', ['-eo', 'pid=,ppid=,tty=,args='], { encoding: 'utf8' });
|
|
300
|
+
if (!ps || ps.error || ps.status !== 0) return [];
|
|
301
|
+
const procs = new Map();
|
|
302
|
+
for (const line of String(ps.stdout || '').split('\n')) {
|
|
303
|
+
const m = PS_RE.exec(line);
|
|
304
|
+
if (m) procs.set(Number(m[1]), { pid: Number(m[1]), ppid: Number(m[2]), tty: m[3], args: m[4] });
|
|
305
|
+
}
|
|
306
|
+
const self = d.pid ?? process.pid;
|
|
307
|
+
const hits = [...procs.values()].filter((p) => p.pid !== self && p.pid !== process.ppid && resumesName(p.args, name));
|
|
308
|
+
if (!hits.length) return [];
|
|
309
|
+
const panes = new Map();
|
|
310
|
+
const lp = d.spawnSync('tmux', ['list-panes', '-a', '-F', '#{pane_pid} #{session_name}'], { encoding: 'utf8' });
|
|
311
|
+
if (lp && !lp.error && lp.status === 0) {
|
|
312
|
+
for (const l of String(lp.stdout || '').split('\n')) {
|
|
313
|
+
const m = /^(\d+)\s+(\S+)$/.exec(l.trim());
|
|
314
|
+
if (m) panes.set(Number(m[1]), m[2]);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return hits.map((p) => {
|
|
318
|
+
let tmux = null;
|
|
319
|
+
for (let pid = p.pid, hops = 0; pid > 1 && hops < 32 && !tmux; hops++) { tmux = panes.get(pid) || null; pid = procs.get(pid)?.ppid ?? (hops === 0 ? p.ppid : 0); }
|
|
320
|
+
return { pid: p.pid, tty: p.tty, tmux };
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** The conversation name a claude argv resumes (`--resume X`, `-r X`, `--resume=X`), or null. */
|
|
325
|
+
function resumedName(args) {
|
|
326
|
+
const t = String(args).trim().split(/\s+/);
|
|
327
|
+
let i = 0;
|
|
328
|
+
if (base(t[0]) === 'node' && base(t[1]) === 'claude') i = 1;
|
|
329
|
+
else if (base(t[0]) !== 'claude') return null;
|
|
330
|
+
for (let k = i + 1; k < t.length; k++) {
|
|
331
|
+
if ((t[k] === '--resume' || t[k] === '-r') && t[k + 1]) return t[k + 1];
|
|
332
|
+
if (t[k].startsWith('--resume=') && t[k].length > 9) return t[k].slice(9);
|
|
333
|
+
}
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const PS_AGE_RE = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(\d+)\s+(.*)$/;
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Every live `claude --resume <name>` on this box (same argv detection as findLiveHolders):
|
|
341
|
+
* [{ name, pid, tty, tmux, ageSec }]. tmux is the enclosing tmux session or null. [] if ps fails.
|
|
342
|
+
*/
|
|
343
|
+
export function listLiveSessions(d) {
|
|
344
|
+
const ps = d.spawnSync('ps', ['-eo', 'pid=,ppid=,tty=,etimes=,args='], { encoding: 'utf8' });
|
|
345
|
+
if (!ps || ps.error || ps.status !== 0) return [];
|
|
346
|
+
const procs = new Map();
|
|
347
|
+
for (const line of String(ps.stdout || '').split('\n')) {
|
|
348
|
+
const m = PS_AGE_RE.exec(line);
|
|
349
|
+
if (m) procs.set(Number(m[1]), { pid: Number(m[1]), ppid: Number(m[2]), tty: m[3], ageSec: Number(m[4]), args: m[5] });
|
|
350
|
+
}
|
|
351
|
+
const self = d.pid ?? process.pid;
|
|
352
|
+
const hits = [...procs.values()].filter((p) => p.pid !== self && resumedName(p.args));
|
|
353
|
+
if (!hits.length) return [];
|
|
354
|
+
const panes = new Map();
|
|
355
|
+
const lp = d.spawnSync('tmux', ['list-panes', '-a', '-F', '#{pane_pid} #{session_name}'], { encoding: 'utf8' });
|
|
356
|
+
if (lp && !lp.error && lp.status === 0) {
|
|
357
|
+
for (const l of String(lp.stdout || '').split('\n')) {
|
|
358
|
+
const m = /^(\d+)\s+(\S+)$/.exec(l.trim());
|
|
359
|
+
if (m) panes.set(Number(m[1]), m[2]);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return hits.map((p) => {
|
|
363
|
+
let tmux = null;
|
|
364
|
+
for (let pid = p.pid, hops = 0; pid > 1 && hops < 32 && !tmux; hops++) { tmux = panes.get(pid) || null; pid = procs.get(pid)?.ppid ?? (hops === 0 ? p.ppid : 0); }
|
|
365
|
+
return { name: resumedName(p.args), pid: p.pid, tty: p.tty, tmux, ageSec: p.ageSec };
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export function runGoverned({ program = 'claude', name, args = [], resume = false, noTmux = false, force = false, bin, preattach }, over) {
|
|
258
370
|
const d = defaults(over);
|
|
259
371
|
const n = name || autoName(d.now());
|
|
260
372
|
if (program === 'run') {
|
|
@@ -281,6 +393,24 @@ export function runGoverned({ program = 'claude', name, args = [], resume = fals
|
|
|
281
393
|
return 1;
|
|
282
394
|
}
|
|
283
395
|
alive = r.status === 0;
|
|
396
|
+
if (!alive && resume && program === 'claude') {
|
|
397
|
+
const real = findSessionCI(d, PREFIX + full);
|
|
398
|
+
if (real) {
|
|
399
|
+
// Same conversation, different case: attach, never start a second Claude.
|
|
400
|
+
const attach = `tmux attach -t ${real}`;
|
|
401
|
+
if (!(d.isTTY ?? process.stdout.isTTY)) { d.write(`${n} is already running in tmux ${real}. Attach: ${attach}\n`); return 0; }
|
|
402
|
+
const a = d.spawnSync('tmux', ['attach-session', '-t', real], { stdio: 'inherit' });
|
|
403
|
+
return a && typeof a.status === 'number' ? a.status : 0;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if (!alive && resume && program === 'claude' && !force) {
|
|
408
|
+
const [h] = findLiveHolders(d, n);
|
|
409
|
+
if (h) {
|
|
410
|
+
const where = h.tmux ? `tmux ${h.tmux}` : `not in tmux on ${h.tty}`;
|
|
411
|
+
d.write(`${n} is already running (pid ${h.pid}, ${where}). ${h.tmux ? `Attach: tmux attach -t ${h.tmux}. ` : ''}To start a second copy anyway: --force.\n`);
|
|
412
|
+
return 1;
|
|
413
|
+
}
|
|
284
414
|
}
|
|
285
415
|
const plan = planLaunch({ name: n, args, env: d.env, alive, resume, noTmux, program, bin, preattach });
|
|
286
416
|
const shown = program === 'run' ? bin : program;
|
|
@@ -41,13 +41,14 @@ export function humanUtc(iso) {
|
|
|
41
41
|
export function resolutionFromItem(item, requestedAction) {
|
|
42
42
|
if (!item || !item.id) return null;
|
|
43
43
|
const response = item.response || null;
|
|
44
|
-
const outcome = item.status === 'expired' ? 'expired' : item.status === 'cancelled' ? 'cancelled' : (outcomeOf(response) || 'resolved');
|
|
44
|
+
const outcome = item.status === 'expired' ? 'expired' : item.status === 'cancelled' ? 'cancelled' : item.status === 'withdrawn' ? 'withdrawn' : (outcomeOf(response) || 'resolved');
|
|
45
45
|
const name = item.resolved_by_name || response?.responded_by_name || null;
|
|
46
46
|
const at = toIso(item.resolved_at) || toIso(response?.responded_at) || null;
|
|
47
47
|
const requested = String(requestedAction || '').trim().toLowerCase() || null;
|
|
48
|
-
const matches = Boolean(requested) && outcome !== 'expired' && outcome !== 'cancelled' && normalise(requested) === outcome;
|
|
48
|
+
const matches = Boolean(requested) && outcome !== 'expired' && outcome !== 'cancelled' && outcome !== 'withdrawn' && normalise(requested) === outcome;
|
|
49
49
|
let message;
|
|
50
50
|
if (outcome === 'expired') message = `This approval expired at ${humanUtc(at)}; nothing was approved. Ask for it again.`;
|
|
51
|
+
else if (outcome === 'withdrawn') message = `This request was withdrawn at ${humanUtc(at)}${name ? ` by ${name}` : ''}; nothing was decided and nothing ran.`;
|
|
51
52
|
else if (outcome === 'cancelled') message = `This item was cancelled at ${humanUtc(at)}${name ? ` by ${name}` : ''}; there is nothing to respond to.`;
|
|
52
53
|
else {
|
|
53
54
|
const head = `Already ${outcome}${name ? ` by ${name}` : ''} at ${humanUtc(at)}`;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Login guards shared by `quox login` and the TUI /login.
|
|
3
|
+
* - prefill comes ONLY from the active profile's own last_email
|
|
4
|
+
* - a non-default profile signing in as an email another profile already holds gets a y/N warning
|
|
5
|
+
* - a 429 says how long to wait (or that retrying may extend the limit)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** The active profile's own last_email, or ''. Never another profile's, never a global. */
|
|
9
|
+
export function loginPrefillEmail(config) {
|
|
10
|
+
try { return String(config.getActiveProfile?.()?.last_email || '').trim(); } catch { return ''; }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** A server URL reduced to scheme://host:port so https://H, https://h/ and https://h:443 compare equal. '' when not a URL. */
|
|
14
|
+
export function normalizeServerUrl(u) {
|
|
15
|
+
try { const x = new URL(String(u)); return `${x.protocol}//${x.hostname.toLowerCase()}:${x.port || (x.protocol === 'https:' ? '443' : '80')}`; } catch { return ''; }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Name of ANOTHER profile currently signed in as `email` (case-insensitive), else null.
|
|
20
|
+
* H2: the same email on a DIFFERENT server is a different account. When `url` (the server this sign-in targets) is
|
|
21
|
+
* given and the other profile has its own server URL that differs, it does not count. A profile with no URL of its
|
|
22
|
+
* own is unknown, so it still counts (the safe side for an identity guard).
|
|
23
|
+
*/
|
|
24
|
+
export function profileHoldingEmail(config, email, url = null) {
|
|
25
|
+
const want = String(email || '').trim().toLowerCase();
|
|
26
|
+
if (!want) return null;
|
|
27
|
+
const me = config.getActiveProfileName?.();
|
|
28
|
+
for (const [name, p] of Object.entries(config._data?.profiles || {})) {
|
|
29
|
+
if (name === me) continue;
|
|
30
|
+
if (String(p?.auth?.email || '').trim().toLowerCase() !== want) continue;
|
|
31
|
+
const mine = normalizeServerUrl(url), theirs = normalizeServerUrl(p?.url);
|
|
32
|
+
if (mine && theirs && mine !== theirs) continue;
|
|
33
|
+
return name;
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Only a non-default profile needs the warning: it is how agent-only profiles become the owner. */
|
|
39
|
+
export function isNonDefaultProfile(config) {
|
|
40
|
+
return Boolean(config._data?.activeProfile) && config.getActiveProfileName?.() !== config._data.activeProfile;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function sameIdentityWarning(email, holder, profile) {
|
|
44
|
+
return `${email} is already signed in as profile '${holder}'. Sign profile '${profile}' in as the same user? y/N`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Plain-language wait guidance for a rate-limited sign-in. */
|
|
48
|
+
export function rateLimitHint({ retryAfterMs } = {}) {
|
|
49
|
+
if (Number.isFinite(retryAfterMs) && retryAfterMs > 0) {
|
|
50
|
+
const min = Math.max(1, Math.ceil(retryAfterMs / 60000));
|
|
51
|
+
return `Too many authentication attempts: try again in ~${min} min; each retry may extend the limit.`;
|
|
52
|
+
}
|
|
53
|
+
return 'Too many authentication attempts: wait a few minutes; retrying now may extend the limit.';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Retry-After (seconds or HTTP date) header and common body fields -> ms, else null. */
|
|
57
|
+
export function retryAfterMsFrom(res, data) {
|
|
58
|
+
const h = res?.headers?.get?.('retry-after');
|
|
59
|
+
if (h != null && h !== '') {
|
|
60
|
+
const n = Number(h);
|
|
61
|
+
if (Number.isFinite(n)) return n * 1000;
|
|
62
|
+
const t = Date.parse(h);
|
|
63
|
+
if (Number.isFinite(t)) return Math.max(0, t - Date.now());
|
|
64
|
+
}
|
|
65
|
+
const s = data?.retry_after ?? data?.retryAfter ?? data?.retry_after_seconds;
|
|
66
|
+
if (Number.isFinite(Number(s)) && s != null) return Number(s) * 1000;
|
|
67
|
+
const reset = data?.reset_at ?? data?.resetAt ?? data?.reset;
|
|
68
|
+
if (reset != null) {
|
|
69
|
+
const t = typeof reset === 'number' ? (reset < 1e12 ? reset * 1000 : reset) : Date.parse(reset);
|
|
70
|
+
if (Number.isFinite(t)) return Math.max(0, t - Date.now());
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
package/lib/core/offload.js
CHANGED
|
@@ -531,3 +531,40 @@ export function gitToplevel(cwd) {
|
|
|
531
531
|
const r = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8' });
|
|
532
532
|
return r.status === 0 ? r.stdout.trim() : cwd;
|
|
533
533
|
}
|
|
534
|
+
|
|
535
|
+
// #1485: the git repo root for cwd, or null when cwd is not inside a repo.
|
|
536
|
+
// Unlike gitToplevel (which falls back to cwd), this reports the no-repo case
|
|
537
|
+
// so resolveSyncDir can refuse rather than sync an unbounded tree.
|
|
538
|
+
export function gitRepoRoot(cwd, { run = (c, a, o) => spawnSync(c, a, o) } = {}) {
|
|
539
|
+
const r = run('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8' });
|
|
540
|
+
return r.status === 0 ? r.stdout.trim() : null;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// #1485: resolve the ONE directory offload rsyncs to the shared worker, and
|
|
544
|
+
// refuse an unbounded one. `quox offload` from a non-repo dir used to fall
|
|
545
|
+
// back to cwd, so running it from the home directory rsynced all ~29G of $HOME
|
|
546
|
+
// (including a live token file) onto the worker and filled its disk. The sync
|
|
547
|
+
// must be a bounded git repo root, never $HOME and never an ancestor of it.
|
|
548
|
+
export function resolveSyncDir(cwd, optionDir, { home = os.homedir(), repoRoot = gitRepoRoot } = {}) {
|
|
549
|
+
const h = path.resolve(home);
|
|
550
|
+
const guardHome = (dir, what) => {
|
|
551
|
+
if (dir === h) {
|
|
552
|
+
throw new OffloadError(`offload refuses to sync your home directory (${dir}). cd into a project repo, or pass --dir <repo-root>.`);
|
|
553
|
+
}
|
|
554
|
+
if (h === dir || h.startsWith(dir + path.sep) || dir === path.parse(dir).root) {
|
|
555
|
+
throw new OffloadError(`offload refuses to sync ${what} (${dir}): it contains your home directory, so the whole tree would be copied to the shared worker. Pass --dir <repo-root>.`);
|
|
556
|
+
}
|
|
557
|
+
};
|
|
558
|
+
if (optionDir) {
|
|
559
|
+
const d = path.resolve(optionDir);
|
|
560
|
+
guardHome(d, 'the --dir path');
|
|
561
|
+
return d;
|
|
562
|
+
}
|
|
563
|
+
const root = repoRoot(cwd);
|
|
564
|
+
if (!root) {
|
|
565
|
+
throw new OffloadError(`offload must run from inside a git repository (cwd ${cwd} is not one). cd into a repo, or pass --dir <repo-root>. Refusing so your whole home directory is not synced to the shared worker.`);
|
|
566
|
+
}
|
|
567
|
+
const d = path.resolve(root);
|
|
568
|
+
guardHome(d, 'the resolved repo root');
|
|
569
|
+
return d;
|
|
570
|
+
}
|
package/lib/core/org-override.js
CHANGED
|
@@ -20,6 +20,10 @@ import { QuoxValidationError } from './errors.js';
|
|
|
20
20
|
// Commands where --org legitimately means something other than "act in".
|
|
21
21
|
export const EXEMPT = new Set(['login', 'logout', 'signup', 'org switch', 'invites list', 'invites accept']);
|
|
22
22
|
|
|
23
|
+
// Verbs that mint a same-user token for the named org themselves (core/crossOrgApprovals.clientForTarget), so --org
|
|
24
|
+
// naming another org is honoured on that org, not refused. Membership is enforced server-side by the mint.
|
|
25
|
+
export const CROSS_ORG = new Set(['approvals list', 'approvals approve', 'approvals deny', 'approvals walkthrough']);
|
|
26
|
+
|
|
23
27
|
export function commandPath(cmd) {
|
|
24
28
|
const parts = [];
|
|
25
29
|
for (let c = cmd; c && c.parent; c = c.parent) parts.unshift(c.name());
|
|
@@ -35,7 +39,7 @@ export function commandPath(cmd) {
|
|
|
35
39
|
* @throws {QuoxValidationError} when the override differs from the session org
|
|
36
40
|
*/
|
|
37
41
|
export function assertOrgOverrideSafe({ override, authData, hasUserToken, command = '' }) {
|
|
38
|
-
if (!override || !hasUserToken || EXEMPT.has(command)) return;
|
|
42
|
+
if (!override || !hasUserToken || EXEMPT.has(command) || CROSS_ORG.has(command)) return;
|
|
39
43
|
const want = String(override).toLowerCase();
|
|
40
44
|
const sid = authData?.org_id || null;
|
|
41
45
|
const known = [sid, authData?.org_slug, authData?.org_name]
|
package/lib/core/output.js
CHANGED
|
@@ -45,7 +45,7 @@ export function formatOutput(result, options = {}) {
|
|
|
45
45
|
formatCsv(rows, columns, headers);
|
|
46
46
|
break;
|
|
47
47
|
case 'quiet':
|
|
48
|
-
formatQuiet(rows, columns);
|
|
48
|
+
formatQuiet(rows, options.quietColumn ? [options.quietColumn] : columns);
|
|
49
49
|
break;
|
|
50
50
|
default:
|
|
51
51
|
formatTable(rows, columns, headers, options);
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ONBOARD-P1: per-profile trust for a server's own certificate (`quox connect`).
|
|
3
|
+
*
|
|
4
|
+
* A new QuoxCORE serves a self-signed edge certificate. The sanctioned fix is to trust THAT certificate, for the
|
|
5
|
+
* profile that talks to that server, and nowhere else. This module:
|
|
6
|
+
* - reads the certificate chain a server presents (a handshake only: nothing is ever sent over it, the
|
|
7
|
+
* certificate is shown to the user and trusted only after they say yes),
|
|
8
|
+
* - refuses what trusting cannot fix (expired, wrong name),
|
|
9
|
+
* - stores the PEM under ~/.config/quox/certs/<profile>.pem and records it on the profile as `caCert`,
|
|
10
|
+
* - makes later requests for that profile trust it: bin/quox.js re-runs itself once with NODE_EXTRA_CA_CERTS
|
|
11
|
+
* set to the profile's file (Node 20 has no per-request CA for the built-in fetch). The global environment
|
|
12
|
+
* is never touched, and TLS verification is never turned off.
|
|
13
|
+
*/
|
|
14
|
+
import tls from 'node:tls';
|
|
15
|
+
import https from 'node:https';
|
|
16
|
+
import { createHash, X509Certificate } from 'node:crypto';
|
|
17
|
+
import { existsSync, mkdirSync, writeFileSync, chmodSync, readFileSync, unlinkSync } from 'node:fs';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
import { spawnSync } from 'node:child_process';
|
|
20
|
+
import { getConfigDir, getConfigFile, resolveProfileFlagFromArgv } from './config.js';
|
|
21
|
+
|
|
22
|
+
const CODES_TRUST_CAN_FIX = new Set(['DEPTH_ZERO_SELF_SIGNED_CERT', 'SELF_SIGNED_CERT_IN_CHAIN', 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', 'UNABLE_TO_GET_ISSUER_CERT', 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY']);
|
|
23
|
+
export const isTrustFixable = (code) => CODES_TRUST_CAN_FIX.has(code);
|
|
24
|
+
|
|
25
|
+
const TLS_SENTENCE_CODES = new Set([...CODES_TRUST_CAN_FIX, 'CERT_HAS_EXPIRED', 'ERR_TLS_CERT_ALTNAME_INVALID']);
|
|
26
|
+
/**
|
|
27
|
+
* H4: one plain sentence for a TLS certificate failure, with the fix. undici reports all of these as a bare
|
|
28
|
+
* "fetch failed". -> null when `code` is not a certificate-trust code.
|
|
29
|
+
* A self-signed or unknown-issuer certificate is fixed by trusting it for one profile; an expired or wrong-name
|
|
30
|
+
* certificate is NOT (trusting a file would not cure it), so those say what does.
|
|
31
|
+
*/
|
|
32
|
+
export function tlsFailureSentence(code, url) {
|
|
33
|
+
if (!TLS_SENTENCE_CODES.has(code)) return null;
|
|
34
|
+
if (code === 'CERT_HAS_EXPIRED') return `${url} presents a certificate that has expired (${code}). Trusting it would not help: the server needs a new certificate.`;
|
|
35
|
+
if (code === 'ERR_TLS_CERT_ALTNAME_INVALID') return `${url} presents a certificate that does not name the address you used (${code}). Use an address the certificate names; trusting it would not help.`;
|
|
36
|
+
return `${url} presents a certificate this machine does not trust: it is self-signed or its issuer is unknown (${code}). If this is your own server, check its fingerprint and trust it for one profile only: quox connect ${url} --trust-cert`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const certDir = () => join(getConfigDir(), 'certs');
|
|
40
|
+
const safeName = (profile) => String(profile).replace(/[^A-Za-z0-9._-]/g, '_').replace(/\.{2,}/g, '_');
|
|
41
|
+
export const certPathFor = (profile) => join(certDir(), `${safeName(profile)}.pem`);
|
|
42
|
+
|
|
43
|
+
/** A short, filesystem- and shell-safe profile name derived from a server URL (203.0.113.10:3000 -> 203-0-113-10). */
|
|
44
|
+
export function profileNameForUrl(url) {
|
|
45
|
+
try { return new URL(url).hostname.replace(/[^A-Za-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'server'; } catch { return 'server'; }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const pemOf = (der) => `-----BEGIN CERTIFICATE-----\n${der.toString('base64').match(/.{1,64}/g).join('\n')}\n-----END CERTIFICATE-----\n`;
|
|
49
|
+
export const fingerprint256 = (der) => createHash('sha256').update(der).digest('hex').toUpperCase().match(/../g).join(':');
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Read the certificate chain `url` presents. -> { host, port, chain: [{ subject, issuer, validFrom, validTo, san,
|
|
53
|
+
* fingerprint256, pem, isCA, selfSigned }], leaf } . Throws a plain Error when there is no TLS answer.
|
|
54
|
+
* The handshake runs with chain validation off ONLY so the certificate can be read; the socket is destroyed
|
|
55
|
+
* before any byte of application data, so nothing is trusted and nothing is sent.
|
|
56
|
+
*/
|
|
57
|
+
export function readPeerChain(url, { timeout = 5000, connect = tls.connect } = {}) {
|
|
58
|
+
const u = new URL(url);
|
|
59
|
+
if (u.protocol !== 'https:') return Promise.reject(new Error('the address is not https://, so there is no certificate to trust'));
|
|
60
|
+
const host = u.hostname;
|
|
61
|
+
const port = Number(u.port || 443);
|
|
62
|
+
return new Promise((resolve, reject) => {
|
|
63
|
+
const isIp = /^[\d.]+$/.test(host) || host.includes(':');
|
|
64
|
+
const sock = connect({ host, port, servername: isIp ? undefined : host, rejectUnauthorized: false, timeout }, () => {
|
|
65
|
+
try {
|
|
66
|
+
const peer = sock.getPeerCertificate(true);
|
|
67
|
+
sock.destroy();
|
|
68
|
+
if (!peer || !peer.raw) return reject(new Error('the server presented no certificate'));
|
|
69
|
+
const seen = new Set();
|
|
70
|
+
const chain = [];
|
|
71
|
+
for (let c = peer; c && c.raw && !seen.has(c.fingerprint256); c = c.issuerCertificate) {
|
|
72
|
+
seen.add(c.fingerprint256);
|
|
73
|
+
let isCA = false;
|
|
74
|
+
try { isCA = Boolean(new X509Certificate(c.raw).ca); } catch { /* unreadable: not a CA */ }
|
|
75
|
+
const fp = fingerprint256(c.raw);
|
|
76
|
+
chain.push({
|
|
77
|
+
subject: c.subject?.CN || Object.values(c.subject || {}).join(' ') || '(no subject)',
|
|
78
|
+
issuer: c.issuer?.CN || Object.values(c.issuer || {}).join(' ') || '(no issuer)',
|
|
79
|
+
validFrom: c.valid_from, validTo: c.valid_to, san: c.subjectaltname || '',
|
|
80
|
+
fingerprint256: fp, pem: pemOf(c.raw), isCA,
|
|
81
|
+
selfSigned: JSON.stringify(c.subject) === JSON.stringify(c.issuer)
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
resolve({ host, port, chain, leaf: peer });
|
|
85
|
+
} catch (e) { sock.destroy(); reject(e); }
|
|
86
|
+
});
|
|
87
|
+
sock.on('error', (e) => reject(Object.assign(new Error(e.message), { code: e.code })));
|
|
88
|
+
sock.on('timeout', () => { sock.destroy(); reject(Object.assign(new Error(`no answer within ${Math.round(timeout / 1000)} s`), { code: 'TIMEOUT' })); });
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Trusting a certificate only fixes an untrusted-but-valid one. -> null when fine, else the reason it cannot be trusted. */
|
|
93
|
+
export function untrustableReason(peer, host, { now = Date.now(), checkServerIdentity = tls.checkServerIdentity } = {}) {
|
|
94
|
+
const from = Date.parse(peer.leaf?.valid_from), to = Date.parse(peer.leaf?.valid_to);
|
|
95
|
+
if (Number.isFinite(to) && to < now) return `its certificate expired on ${peer.leaf.valid_to}. Trusting it would not help; the server needs a new certificate.`;
|
|
96
|
+
if (Number.isFinite(from) && from > now) return `its certificate is not valid until ${peer.leaf.valid_from} (check this machine's clock).`;
|
|
97
|
+
const err = checkServerIdentity(host, peer.leaf);
|
|
98
|
+
if (err) return `its certificate does not name ${host} (${(peer.leaf?.subjectaltname || 'no names').replace(/IP Address:/g, '')}). Trusting it would not help; use an address the certificate names.`;
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The card the user compares with `quox server status` on the server. */
|
|
103
|
+
export function certCard(url, peer) {
|
|
104
|
+
const leaf = peer.chain[0];
|
|
105
|
+
const lines = [
|
|
106
|
+
`${url} presents this certificate:`,
|
|
107
|
+
` Subject ${leaf.subject}`,
|
|
108
|
+
` Issuer ${leaf.selfSigned ? `${leaf.issuer} (self-signed)` : leaf.issuer}`,
|
|
109
|
+
` Names ${leaf.san.replace(/IP Address:/g, 'IP:') || '(none listed)'}`,
|
|
110
|
+
` Valid ${leaf.validFrom} to ${leaf.validTo}`,
|
|
111
|
+
` SHA256 ${leaf.fingerprint256}`
|
|
112
|
+
];
|
|
113
|
+
for (const c of peer.chain.slice(1)) lines.push(` Signed by ${c.subject} SHA256 ${c.fingerprint256}`);
|
|
114
|
+
lines.push('Compare the SHA256 with the one on the server (run `quox server status` there) before you trust it.');
|
|
115
|
+
return lines.join('\n');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Write the chain's PEM for `profile` (0600 file in a 0700 directory). -> the path. */
|
|
119
|
+
export function storeProfileCert(profile, peer) {
|
|
120
|
+
const dir = certDir();
|
|
121
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
122
|
+
try { chmodSync(dir, 0o700); } catch { /* best effort */ }
|
|
123
|
+
const file = certPathFor(profile);
|
|
124
|
+
writeFileSync(file, peer.chain.map((c) => c.pem).join(''), { mode: 0o600 });
|
|
125
|
+
try { chmodSync(file, 0o600); } catch { /* best effort */ }
|
|
126
|
+
return file;
|
|
127
|
+
}
|
|
128
|
+
export function removeProfileCert(profile) { try { unlinkSync(certPathFor(profile)); } catch { /* not there */ } }
|
|
129
|
+
|
|
130
|
+
/** A fetch-shaped function that trusts exactly `ca` (a PEM string) for this one request. For probeQuoxCore. */
|
|
131
|
+
export function fetchTrusting(ca, { request = https.request } = {}) {
|
|
132
|
+
return (target, { signal, headers } = {}) => new Promise((resolve, reject) => {
|
|
133
|
+
const req = request(target, { ca, headers, signal, method: 'GET' }, (res) => {
|
|
134
|
+
const chunks = [];
|
|
135
|
+
res.on('data', (d) => chunks.push(d));
|
|
136
|
+
res.on('end', () => {
|
|
137
|
+
const body = Buffer.concat(chunks).toString('utf8');
|
|
138
|
+
resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, text: async () => body });
|
|
139
|
+
});
|
|
140
|
+
res.on('error', reject);
|
|
141
|
+
});
|
|
142
|
+
req.on('error', reject);
|
|
143
|
+
req.end();
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The CA file recorded on `profileName` in a parsed config object, if it exists on disk. */
|
|
148
|
+
export function profileCaPath(configData, profileName, { exists = existsSync } = {}) {
|
|
149
|
+
const f = configData?.profiles?.[profileName]?.caCert;
|
|
150
|
+
return typeof f === 'string' && f && exists(f) ? f : null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* bin/quox.js calls this first. When the profile this run uses has a stored server certificate (and the user has
|
|
155
|
+
* not set their own NODE_EXTRA_CA_CERTS), re-run the same command with NODE_EXTRA_CA_CERTS set to it, for this
|
|
156
|
+
* process tree only. -> an exit code when it ran the command, or null (carry on in this process).
|
|
157
|
+
*/
|
|
158
|
+
export function reexecWithProfileTrust(argv, env = process.env, {
|
|
159
|
+
readConfig = () => JSON.parse(readFileSync(getConfigFile(), 'utf8')),
|
|
160
|
+
spawn = spawnSync, exists = existsSync, execPath = process.execPath, execArgv = process.execArgv, script = process.argv[1]
|
|
161
|
+
} = {}) {
|
|
162
|
+
// A NODE_EXTRA_CA_CERTS the user set themselves always wins. One WE set (QUOX_PROFILE_TRUST_APPLIED names the file)
|
|
163
|
+
// belongs to the profile that set it: a nested `quox -p other` must not inherit it.
|
|
164
|
+
const ours = env.QUOX_PROFILE_TRUST_APPLIED && env.NODE_EXTRA_CA_CERTS === env.QUOX_PROFILE_TRUST_APPLIED;
|
|
165
|
+
if (env.NODE_EXTRA_CA_CERTS && !ours) return null;
|
|
166
|
+
let data;
|
|
167
|
+
try { data = readConfig(); } catch { return null; }
|
|
168
|
+
const name = resolveProfileFlagFromArgv(argv) || env.QUOX_PROFILE || data?.activeProfile;
|
|
169
|
+
const ca = name ? profileCaPath(data, name, { exists }) : null;
|
|
170
|
+
if (ours ? ca === env.NODE_EXTRA_CA_CERTS : !ca) return null; // already right, or nothing to apply
|
|
171
|
+
const childEnv = { ...env };
|
|
172
|
+
if (ca) { childEnv.NODE_EXTRA_CA_CERTS = ca; childEnv.QUOX_PROFILE_TRUST_APPLIED = ca; }
|
|
173
|
+
else { delete childEnv.NODE_EXTRA_CA_CERTS; delete childEnv.QUOX_PROFILE_TRUST_APPLIED; }
|
|
174
|
+
// Ctrl-C reaches the whole foreground group; the child handles it, this parent must outlive it to pass on the code.
|
|
175
|
+
const hold = () => {};
|
|
176
|
+
process.on('SIGINT', hold);
|
|
177
|
+
try {
|
|
178
|
+
const r = spawn(execPath, [...execArgv, script, ...argv], { stdio: 'inherit', env: childEnv });
|
|
179
|
+
if (typeof r.status === 'number') return r.status;
|
|
180
|
+
return r.signal === 'SIGINT' ? 130 : 1;
|
|
181
|
+
} finally { process.removeListener('SIGINT', hold); }
|
|
182
|
+
}
|