@ahood/cli 0.8.6 → 0.8.8
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/README.md +3 -1
- package/dist/commands/add.js +123 -27
- package/dist/http.js +25 -1
- package/dist/lockfile.js +120 -21
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -81,13 +81,15 @@ Requires Node.js 18 or later.
|
|
|
81
81
|
| `AHOOD_API_URL` | Overrides the registry endpoint (default: `https://ahood.vercel.app`). Must be `https://`, except for `localhost`/`127.0.0.1` or a `.test`/`.invalid`/`.example`/`.localhost` host, which may use plain `http://` for local development. |
|
|
82
82
|
| `XDG_CONFIG_HOME` | If set, browser-login credentials are stored under `$XDG_CONFIG_HOME/ahood/credentials.json` instead of the default `~/.config/ahood/credentials.json`. |
|
|
83
83
|
|
|
84
|
-
Paths `ahood` reads and writes in your project
|
|
84
|
+
Paths `ahood` reads and writes in your project. The lockfile is meant to be committed, same as `package-lock.json`:
|
|
85
85
|
|
|
86
86
|
| Path | What's in it |
|
|
87
87
|
| --- | --- |
|
|
88
88
|
| `.claude/skills/<owner>@<skill>/` | Installed skill files. |
|
|
89
89
|
| `.claude/agents/<owner>@<skill>.md` | An installed agent, as a single file (`@` owner/skill separator, since Claude Code's own subagent loader scans `.claude/agents/*.md` flat, non-recursively). |
|
|
90
90
|
| `.claude/skills.lock.json` | Exact installed version + checksum per skill or agent, written by `add`/`update`, read by every install to verify integrity. |
|
|
91
|
+
| `.mcp.json` | MCP server entries, for `mcp`-kind artifacts only. Merged into rather than owned — other MCP clients share this file. **May contain secrets in plaintext**, since a server manifest can declare credentials that `add` prompts for; `add` warns when it writes one. |
|
|
92
|
+
| `*.json.tmp-<pid>-<hrtime>` | Never, in normal operation. Writes to the two `.json` files above go through a temp file that is renamed into place, and only a hard kill (SIGKILL, OOM, power loss) mid-write can strand one. A stranded copy of `.mcp.json` carries the same plaintext secrets, so **gitignore this pattern**. The next successful write removes any left by a process that is no longer running. |
|
|
91
93
|
|
|
92
94
|
## Usage examples
|
|
93
95
|
|
package/dist/commands/add.js
CHANGED
|
@@ -114,7 +114,7 @@ export async function extractTarGz(buffer, destDir) {
|
|
|
114
114
|
const fullPath = resolve(destDir, entryPath);
|
|
115
115
|
if (fullPath !== resolvedDest && !fullPath.startsWith(resolvedDest + sep)) {
|
|
116
116
|
stream.resume();
|
|
117
|
-
reject(new Error(`Refusing to extract unsafe archive entry: ${header.name}`));
|
|
117
|
+
reject(new Error(`Refusing to extract unsafe archive entry: ${sanitizeForTerminal(header.name)}`));
|
|
118
118
|
// next() is deliberately NOT called -- nothing further in this archive
|
|
119
119
|
// should be processed. destroy() tears the paused extractor down
|
|
120
120
|
// rather than leaving it stalled mid-entry.
|
|
@@ -237,17 +237,24 @@ export async function extractSingleFileContent(buffer, entryName) {
|
|
|
237
237
|
throw new Error(`${entryName} not found in the downloaded archive`);
|
|
238
238
|
return found;
|
|
239
239
|
}
|
|
240
|
-
// Every manifest field used below
|
|
240
|
+
// Every manifest field used below -- and every tar entry name extractTarGz
|
|
241
|
+
// quotes back in an error above -- reaches the terminal (an error message, or
|
|
241
242
|
// -- worse -- the text printed immediately before a masked secret prompt)
|
|
242
|
-
// but comes verbatim from a third-party-published
|
|
243
|
+
// but comes verbatim from a third-party-published archive: server-side
|
|
243
244
|
// validation only checks these are strings, not that they're free of
|
|
244
245
|
// control/escape characters. A malicious description could otherwise smuggle
|
|
245
246
|
// a terminal control sequence (cursor movement, line-clear) that repaints
|
|
246
247
|
// what the user sees at the exact moment they're about to type a credential.
|
|
247
248
|
// Strips C0/C1 control characters (including ESC, \x1b) and caps length so a
|
|
248
249
|
// single field can't also flood the terminal.
|
|
250
|
+
//
|
|
251
|
+
// Takes `unknown` rather than `string` because every caller's "string" is a
|
|
252
|
+
// declared type over an unchecked `as ServerManifest` cast of downloaded JSON,
|
|
253
|
+
// not a guarantee: calling .replace() directly on a `registry_type: 123` would
|
|
254
|
+
// turn a diagnosable message into the raw TypeError ahood-cli#121 removed from
|
|
255
|
+
// this same path (ahood-cli#122).
|
|
249
256
|
function sanitizeForTerminal(text) {
|
|
250
|
-
return text.replace(/[\x00-\x1f\x7f-\x9f]/g, " ").slice(0, 200);
|
|
257
|
+
return String(text).replace(/[\x00-\x1f\x7f-\x9f]/g, " ").slice(0, 200);
|
|
251
258
|
}
|
|
252
259
|
// Only npm+npx is supported in v1 (docs/superpowers/specs/2026-09-03-mcp-server-artifacts-design.md) --
|
|
253
260
|
// server-side publish validation (lib/publish/parse-server-manifest.ts) already
|
|
@@ -260,7 +267,7 @@ function buildMcpServerConfig(manifest, env) {
|
|
|
260
267
|
if (manifest.packages && manifest.packages.length === 1) {
|
|
261
268
|
const pkg = manifest.packages[0];
|
|
262
269
|
if (pkg.registry_type !== "npm" || pkg.runtime_hint !== "npx") {
|
|
263
|
-
throw new Error(`${manifestName}'s server.json uses registry_type "${pkg.registry_type}"/runtime_hint "${pkg.runtime_hint}", which this version of ahood does not know how to install (only npm+npx is supported).`);
|
|
270
|
+
throw new Error(`${manifestName}'s server.json uses registry_type "${sanitizeForTerminal(pkg.registry_type)}"/runtime_hint "${sanitizeForTerminal(pkg.runtime_hint)}", which this version of ahood does not know how to install (only npm+npx is supported).`);
|
|
264
271
|
}
|
|
265
272
|
const config = { command: "npx", args: ["-y", `${pkg.identifier}@${pkg.version}`] };
|
|
266
273
|
if (Object.keys(env).length > 0)
|
|
@@ -379,8 +386,8 @@ function assertNoCollision(fileContents, owner, skill) {
|
|
|
379
386
|
`Remove it manually first if you want to reinstall ${owner}/${skill}.`);
|
|
380
387
|
}
|
|
381
388
|
}
|
|
382
|
-
// Extracts server.json, validates its shape, and resolves every
|
|
383
|
-
// environment variable it declares (prompting only for
|
|
389
|
+
// Extracts server.json, validates its shape, and resolves every
|
|
390
|
+
// environment variable it declares (prompting only for secrets not already
|
|
384
391
|
// set in the shell) -- the part of installing an mcp entry that's identical
|
|
385
392
|
// whether this is a fresh `add` or an `update` moving an existing pin
|
|
386
393
|
// forward. Exported so update.ts's real mcp-update path (ahood-cli#169)
|
|
@@ -401,10 +408,38 @@ function assertNoCollision(fileContents, owner, skill) {
|
|
|
401
408
|
// timeout -- against this CLI's "must never hang" rule -- or, worse, silently
|
|
402
409
|
// bank the first line of unrelated piped input (`echo y | ahood skill
|
|
403
410
|
// update`) as the credential and fingerprint over it.
|
|
411
|
+
//
|
|
412
|
+
// `secretNames` comes back alongside the config because `env` also carries
|
|
413
|
+
// non-secret configuration now (ahood-cli#120): callers that need to know
|
|
414
|
+
// whether what they just wrote to disk is actually a credential can no longer
|
|
415
|
+
// infer it from `env` being non-empty.
|
|
404
416
|
export async function resolveMcpServerConfig(buffer, options = {}) {
|
|
405
417
|
const { existingEnv, allowNonTtyPrompt = true } = options;
|
|
406
418
|
const content = await extractSingleFileContent(buffer, "server.json");
|
|
407
|
-
|
|
419
|
+
// Guarded the same way readMcpConfig guards .mcp.json: an unguarded parse
|
|
420
|
+
// surfaced a raw V8 SyntaxError with a stack instead of this CLI's usual
|
|
421
|
+
// single-line error (ahood-cli#121). The parser's own message is dropped
|
|
422
|
+
// rather than run through sanitizeForTerminal, because V8 quotes a snippet
|
|
423
|
+
// of the offending source in it -- that source is third-party archive
|
|
424
|
+
// content, and sanitizing would still paste it into the terminal, just
|
|
425
|
+
// with its control characters flattened.
|
|
426
|
+
let parsed;
|
|
427
|
+
try {
|
|
428
|
+
parsed = JSON.parse(content.toString("utf-8"));
|
|
429
|
+
}
|
|
430
|
+
catch {
|
|
431
|
+
throw new Error("The downloaded archive's server.json is not valid JSON -- this published version is malformed and cannot be installed.");
|
|
432
|
+
}
|
|
433
|
+
// Checked before the cast because buildMcpServerConfig immediately reads
|
|
434
|
+
// properties off the result: valid JSON that isn't an object (`[]`, `"x"`,
|
|
435
|
+
// `3`, `null`) would otherwise reach it and surface as an unactionable raw
|
|
436
|
+
// TypeError (ahood-cli#121). Deliberately stops at "is it an object" -- the
|
|
437
|
+
// manifest's actual shape is buildMcpServerConfig's job just below, and a
|
|
438
|
+
// second implementation of that check here would be free to drift from it.
|
|
439
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
440
|
+
throw new Error("The downloaded archive's server.json is valid JSON but its top level is not an object -- this published version is malformed and cannot be installed.");
|
|
441
|
+
}
|
|
442
|
+
const manifest = parsed;
|
|
408
443
|
// Validate the manifest's shape (unsupported registry_type/runtime_hint,
|
|
409
444
|
// or neither a single 'packages' nor a single 'remotes' entry) BEFORE
|
|
410
445
|
// prompting for any secret -- an install/update that's going to be
|
|
@@ -414,10 +449,46 @@ export async function resolveMcpServerConfig(buffer, options = {}) {
|
|
|
414
449
|
// secretEnv is known.
|
|
415
450
|
buildMcpServerConfig(manifest, {});
|
|
416
451
|
const envVars = manifest.packages?.[0]?.environment_variables ?? [];
|
|
417
|
-
const
|
|
452
|
+
const env = {};
|
|
453
|
+
// Non-secret variables used to be skipped outright, so a server declaring
|
|
454
|
+
// one installed into a config that could not start it, with no diagnostic
|
|
455
|
+
// (ahood-cli#120). They're configuration, not credentials: read them from
|
|
456
|
+
// the shell, but never prompt -- a prompt here would reintroduce exactly
|
|
457
|
+
// the unattended-use hazard ahood-cli#169/0.8.2 removed from the update
|
|
458
|
+
// path, and there is nothing to mask anyway.
|
|
459
|
+
//
|
|
460
|
+
// Resolved in a first pass, ahead of any masked prompt below, for the same
|
|
461
|
+
// reason buildMcpServerConfig is dry-run above and assertNoCollision runs
|
|
462
|
+
// before installMcpEntry's resolve: an install that's going to be refused
|
|
463
|
+
// for a missing required variable must not first cost the user a secret
|
|
464
|
+
// prompt.
|
|
465
|
+
for (const variable of envVars) {
|
|
466
|
+
if (variable.is_secret)
|
|
467
|
+
continue;
|
|
468
|
+
// Same truthiness rule as the secret branch below: an empty-string env
|
|
469
|
+
// var reads as "not set" rather than installing an empty value.
|
|
470
|
+
const fromEnv = process.env[variable.name];
|
|
471
|
+
if (fromEnv) {
|
|
472
|
+
env[variable.name] = fromEnv;
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
// `is_required` was parsed and validated but never consulted at install
|
|
476
|
+
// time (ahood-cli#120). Optional means optional -- install without it.
|
|
477
|
+
// Required means the server cannot start without it, so refusing beats
|
|
478
|
+
// writing a config that is broken in a way nothing reports.
|
|
479
|
+
if (variable.is_required) {
|
|
480
|
+
throw new Error(`${sanitizeForTerminal(variable.name)} is required by ${sanitizeForTerminal(manifest.name)} but is not set -- export ${sanitizeForTerminal(variable.name)} and re-run.`);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
// Required-ness is consulted on the secret path only in the one branch
|
|
484
|
+
// where a secret can still end up unresolved: the unattended update guard
|
|
485
|
+
// below. Every other branch either resolves a value or prompts for one, so
|
|
486
|
+
// an `is_required` check there would change nothing (ahood-cli#120).
|
|
487
|
+
const secretNames = [];
|
|
418
488
|
for (const variable of envVars) {
|
|
419
489
|
if (!variable.is_secret)
|
|
420
490
|
continue;
|
|
491
|
+
secretNames.push(variable.name);
|
|
421
492
|
const fromEnv = process.env[variable.name];
|
|
422
493
|
// Truthiness, not `!== undefined` -- matches credentials.ts's
|
|
423
494
|
// resolveToken(), the precedent this feature was explicitly modeled on
|
|
@@ -425,24 +496,56 @@ export async function resolveMcpServerConfig(buffer, options = {}) {
|
|
|
425
496
|
// specifically so an empty-string env var falls through to the real
|
|
426
497
|
// source instead of silently installing an empty secret.
|
|
427
498
|
if (fromEnv) {
|
|
428
|
-
|
|
499
|
+
env[variable.name] = fromEnv;
|
|
429
500
|
continue;
|
|
430
501
|
}
|
|
431
502
|
// Ordered after the env var deliberately: exporting the variable stays
|
|
432
503
|
// the way to rotate a credential during an update, rather than being
|
|
433
|
-
// shadowed by the stale value already on disk.
|
|
504
|
+
// shadowed by the stale value already on disk. Deliberately not extended
|
|
505
|
+
// to non-secret variables (ahood-cli#120): carrying a value forward from
|
|
506
|
+
// .mcp.json is credential-preservation machinery, and a non-secret is
|
|
507
|
+
// plain configuration that the shell should stay authoritative for.
|
|
434
508
|
const carriedForward = existingEnv?.[variable.name];
|
|
435
509
|
if (carriedForward) {
|
|
436
|
-
|
|
510
|
+
env[variable.name] = carriedForward;
|
|
437
511
|
continue;
|
|
438
512
|
}
|
|
439
513
|
if (!allowNonTtyPrompt && !process.stdin.isTTY) {
|
|
514
|
+
// Optional means optional here too, exactly as it does on the
|
|
515
|
+
// non-secret pass above (ahood-cli#126). This guard used to fire for
|
|
516
|
+
// ANY unresolved secret, so a single artifact declaring an optional
|
|
517
|
+
// token that nothing had exported aborted the whole `ahood skill
|
|
518
|
+
// update` walk -- and said the variable "is required" while the
|
|
519
|
+
// manifest that declared it said otherwise. Announced rather than
|
|
520
|
+
// silent because skipping and carrying forward leave different entries
|
|
521
|
+
// in .mcp.json and nothing else distinguishes them; the notice is
|
|
522
|
+
// confined to this branch, so an interactive `add` (which still prompts
|
|
523
|
+
// for optional secrets) and a routine update whose credential carries
|
|
524
|
+
// forward both stay quiet.
|
|
525
|
+
if (!variable.is_required) {
|
|
526
|
+
console.warn(`${sanitizeForTerminal(variable.name)} is optional and is not set -- updating ${sanitizeForTerminal(manifest.name)} without it.`);
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
440
529
|
throw new Error(`${sanitizeForTerminal(variable.name)} is required by ${sanitizeForTerminal(manifest.name)} but is not set, and there is no terminal to prompt on -- export ${sanitizeForTerminal(variable.name)} and re-run.`);
|
|
441
530
|
}
|
|
442
|
-
|
|
531
|
+
env[variable.name] = await promptSecret(`${sanitizeForTerminal(variable.name)} (${sanitizeForTerminal(variable.description)}): `);
|
|
443
532
|
}
|
|
444
|
-
const serverConfig = buildMcpServerConfig(manifest,
|
|
445
|
-
return { manifest, serverConfig };
|
|
533
|
+
const serverConfig = buildMcpServerConfig(manifest, env);
|
|
534
|
+
return { manifest, serverConfig, secretNames };
|
|
535
|
+
}
|
|
536
|
+
// The plaintext-secret warning printed after a successful mcp install/update.
|
|
537
|
+
// Still derived from the config that actually landed on disk -- the property
|
|
538
|
+
// the old bare `serverConfig.env` check was chosen for -- but intersected with
|
|
539
|
+
// the manifest's declared secrets, because `env` now also holds non-secret
|
|
540
|
+
// configuration (ahood-cli#120) and would otherwise warn about credentials
|
|
541
|
+
// for a server that declares none.
|
|
542
|
+
function warnIfSecretsWereWritten(serverConfig, secretNames) {
|
|
543
|
+
const env = serverConfig.env;
|
|
544
|
+
if (!env)
|
|
545
|
+
return;
|
|
546
|
+
if (!secretNames.some((name) => Object.prototype.hasOwnProperty.call(env, name)))
|
|
547
|
+
return;
|
|
548
|
+
console.warn(`WARNING: ${MCP_CONFIG_PATH} now contains one or more secret values in plaintext -- do not commit it to version control.`);
|
|
446
549
|
}
|
|
447
550
|
// Fingerprint of exactly what ahood wrote into .mcp.json's mcpServers.<skill>
|
|
448
551
|
// entry, stored in the lockfile alongside the version/checksum pin. Lets
|
|
@@ -520,7 +623,7 @@ async function installMcpEntry(owner, skill, meta, buffer) {
|
|
|
520
623
|
// something that's going to be refused anyway" reasoning, just for the
|
|
521
624
|
// collision check specifically rather than the manifest-shape check.
|
|
522
625
|
assertNoCollision(readMcpConfig(), owner, skill);
|
|
523
|
-
const { serverConfig } = await resolveMcpServerConfig(buffer);
|
|
626
|
+
const { serverConfig, secretNames } = await resolveMcpServerConfig(buffer);
|
|
524
627
|
// Read-modify-write under an advisory lock, mirroring lockfile.ts's own
|
|
525
628
|
// writeLockfileEntry: .mcp.json sits right next to the lockfile and can
|
|
526
629
|
// hold other servers' secrets, so it gets the same protection against a
|
|
@@ -558,13 +661,8 @@ async function installMcpEntry(owner, skill, meta, buffer) {
|
|
|
558
661
|
}
|
|
559
662
|
console.log(`Installed ${owner}/${skill}@${meta.version} into ${MCP_CONFIG_PATH} as "${skill}"`);
|
|
560
663
|
// Only the npm+npx package path ever carries secrets into `env` (headers
|
|
561
|
-
// on a remote entry are static per v1's scope, per buildMcpServerConfig)
|
|
562
|
-
|
|
563
|
-
// secretEnv out of resolveMcpServerConfig separately) means this warning
|
|
564
|
-
// can never drift from what was actually written to disk.
|
|
565
|
-
if (serverConfig.env && Object.keys(serverConfig.env).length > 0) {
|
|
566
|
-
console.warn(`WARNING: ${MCP_CONFIG_PATH} now contains one or more secret values in plaintext -- do not commit it to version control.`);
|
|
567
|
-
}
|
|
664
|
+
// on a remote entry are static per v1's scope, per buildMcpServerConfig).
|
|
665
|
+
warnIfSecretsWereWritten(serverConfig, secretNames);
|
|
568
666
|
}
|
|
569
667
|
// Re-runs the mcp install flow for an already-installed entry, moving its
|
|
570
668
|
// lockfile pin forward -- update.ts's real mcp-update path (ahood-cli#169).
|
|
@@ -610,7 +708,7 @@ export async function updateMcpEntry(owner, skill, meta, currentEntry) {
|
|
|
610
708
|
// (no entry on disk at all) there's nothing to carry forward and a genuine
|
|
611
709
|
// prompt is correct.
|
|
612
710
|
const existingEnv = readMcpEntryEnv(existingOnDisk);
|
|
613
|
-
const { serverConfig } = await resolveMcpServerConfig(buffer, { existingEnv, allowNonTtyPrompt: false });
|
|
711
|
+
const { serverConfig, secretNames } = await resolveMcpServerConfig(buffer, { existingEnv, allowNonTtyPrompt: false });
|
|
614
712
|
// Read-modify-write under an advisory lock, mirroring installMcpEntry's
|
|
615
713
|
// own pattern. Re-checks the fingerprint here too (not just above) since
|
|
616
714
|
// arbitrary time may have passed since the pre-check -- the download, and
|
|
@@ -675,9 +773,7 @@ export async function updateMcpEntry(owner, skill, meta, currentEntry) {
|
|
|
675
773
|
throw error;
|
|
676
774
|
}
|
|
677
775
|
console.log(`Updated ${key} to ${meta.version}`);
|
|
678
|
-
|
|
679
|
-
console.warn(`WARNING: ${MCP_CONFIG_PATH} now contains one or more secret values in plaintext -- do not commit it to version control.`);
|
|
680
|
-
}
|
|
776
|
+
warnIfSecretsWereWritten(serverConfig, secretNames);
|
|
681
777
|
}
|
|
682
778
|
// Downloads a specific version's archive and verifies it against the
|
|
683
779
|
// checksum in `meta`, WITHOUT extracting anything -- the shared first half
|
package/dist/http.js
CHANGED
|
@@ -37,6 +37,30 @@ export async function apiFetch(path, init = {}) {
|
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
39
|
const MAX_ERROR_MESSAGE_LENGTH = 500;
|
|
40
|
+
// The HTML/length checks below (ahood-cli#31) let a *short, non-HTML* body
|
|
41
|
+
// through verbatim, which meant an error string could still carry ANSI escapes
|
|
42
|
+
// straight to the terminal (ahood-cli#127). That matters here more than in the
|
|
43
|
+
// archive-content path ahood-cli#122 hardened, because sanitizeErrorMessage
|
|
44
|
+
// sits on every apiJson call in the CLI -- including `add`, which prints
|
|
45
|
+
// server-sourced text immediately before a masked secret prompt, the exact
|
|
46
|
+
// moment a cursor-movement/line-clear sequence can repaint what the user sees.
|
|
47
|
+
//
|
|
48
|
+
// Control characters are replaced with a space rather than deleted so that
|
|
49
|
+
// stripping can't silently glue two tokens into one misleading word.
|
|
50
|
+
//
|
|
51
|
+
// Newlines (\x0a) and carriage returns (\x0d) are inside this range and are
|
|
52
|
+
// deliberately flattened too: a forged prompt line ("...\n\nEnter your token:")
|
|
53
|
+
// needs no escape sequence at all, just a newline, so preserving them would
|
|
54
|
+
// leave the spoofing half of the threat model open while closing the escape
|
|
55
|
+
// half. The readability cost is small because anything reaching this branch is
|
|
56
|
+
// already capped at MAX_ERROR_MESSAGE_LENGTH -- a few lines' worth of text, not
|
|
57
|
+
// a stack trace.
|
|
58
|
+
//
|
|
59
|
+
// Known duplication: this is the same substitution as `sanitizeForTerminal` in
|
|
60
|
+
// src/commands/add.ts (ahood-cli#122). It is copied rather than shared on
|
|
61
|
+
// purpose -- promoting a shared helper means editing add.ts's call sites, which
|
|
62
|
+
// is follow-up refactor work kept out of this security fix; see ahood-cli#127.
|
|
63
|
+
const CONTROL_CHARACTERS = /[\x00-\x1f\x7f-\x9f]/g;
|
|
40
64
|
// Defense in depth (ahood-cli#31): the API is expected to never forward a raw
|
|
41
65
|
// upstream error body (an HTML block page from a proxy/WAF, a giant stack
|
|
42
66
|
// trace, etc.) into `error`, but this CLI shouldn't trust that unconditionally
|
|
@@ -46,7 +70,7 @@ const MAX_ERROR_MESSAGE_LENGTH = 500;
|
|
|
46
70
|
export function sanitizeErrorMessage(message) {
|
|
47
71
|
const looksLikeHtml = /<!DOCTYPE|<html[\s>]/i.test(message);
|
|
48
72
|
if (!looksLikeHtml && message.length <= MAX_ERROR_MESSAGE_LENGTH)
|
|
49
|
-
return message;
|
|
73
|
+
return message.replace(CONTROL_CHARACTERS, " ");
|
|
50
74
|
// No preview of the raw content: an HTML-shaped body's first bytes are
|
|
51
75
|
// exactly where infra details (e.g. a Cloudflare block page's title, Ray
|
|
52
76
|
// ID) live, so a "helpful" excerpt would leak the same details this
|
package/dist/lockfile.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { dirname, join } from "node:path";
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { basename, dirname, join } from "node:path";
|
|
3
3
|
export function readLockfile(path) {
|
|
4
4
|
if (!existsSync(path))
|
|
5
5
|
return {};
|
|
@@ -15,6 +15,63 @@ export function readLockfile(path) {
|
|
|
15
15
|
throw new Error(`Lockfile at ${path} is corrupted and could not be parsed as JSON. Fix or delete it before continuing.`);
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
|
+
// Signal 0 doesn't deliver anything, it just probes whether the process could
|
|
19
|
+
// be signaled. ESRCH means it's gone; EPERM means it's alive but owned by
|
|
20
|
+
// someone else, which is conservatively treated as still alive since that
|
|
21
|
+
// can't be disproven. Shared by the stale-lock reclaim (ahood-cli#100) and the
|
|
22
|
+
// stale-temp sweep (ahood-cli#125) so both answer "is the process that left
|
|
23
|
+
// this behind still around?" the same way, rather than growing two divergent
|
|
24
|
+
// staleness rules.
|
|
25
|
+
function isPidDead(pid) {
|
|
26
|
+
try {
|
|
27
|
+
process.kill(pid, 0);
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
return error.code === "ESRCH";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
// Exactly what the temp name below produces: a decimal pid and a decimal
|
|
35
|
+
// hrtime, nothing else. Anything the user merely happened to name
|
|
36
|
+
// `<basename>.tmp-something` fails this and is left alone -- deleting a file
|
|
37
|
+
// that was never ours is far worse than leaving a stale temp file behind
|
|
38
|
+
// (ahood-cli#125).
|
|
39
|
+
const TEMP_SUFFIX_RE = /^([1-9][0-9]{0,9})-[0-9]{1,25}$/;
|
|
40
|
+
// The finally in writeJsonFileAtomic can't run on SIGKILL, an OOM kill or
|
|
41
|
+
// power loss, so a temp file can still be orphaned in the window between the
|
|
42
|
+
// write and the rename -- and for .mcp.json that orphan holds resolved MCP
|
|
43
|
+
// server secrets under a name nothing else ever removes (ahood-cli#125). The
|
|
44
|
+
// next successful write to the same destination collects them.
|
|
45
|
+
//
|
|
46
|
+
// withLock serializes the writers this CLI itself starts, but a temp file is
|
|
47
|
+
// only provably collectable once the process that created it is gone, so the
|
|
48
|
+
// pid embedded in the name is checked rather than trusting the lock alone. A
|
|
49
|
+
// pid recycled since the last boot can only make a dead writer look alive,
|
|
50
|
+
// which just defers the cleanup; it can never make a live writer look dead, so
|
|
51
|
+
// this never deletes a file still being written.
|
|
52
|
+
function sweepStaleTempFiles(path, ownTempName) {
|
|
53
|
+
const dir = dirname(path);
|
|
54
|
+
const prefix = `${basename(path)}.tmp-`;
|
|
55
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
56
|
+
if (!entry.isFile())
|
|
57
|
+
continue;
|
|
58
|
+
if (entry.name === ownTempName)
|
|
59
|
+
continue;
|
|
60
|
+
if (!entry.name.startsWith(prefix))
|
|
61
|
+
continue;
|
|
62
|
+
const match = TEMP_SUFFIX_RE.exec(entry.name.slice(prefix.length));
|
|
63
|
+
if (!match)
|
|
64
|
+
continue;
|
|
65
|
+
if (!isPidDead(Number(match[1])))
|
|
66
|
+
continue;
|
|
67
|
+
try {
|
|
68
|
+
rmSync(join(dir, entry.name), { force: true });
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// One unremovable orphan must not stop the rest of the sweep.
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
18
75
|
// Generalized so callers other than the lockfile itself (e.g. add.ts's
|
|
19
76
|
// .mcp.json merge, which sits right next to the lockfile and can hold other
|
|
20
77
|
// servers' secrets) get the same crash-safety guarantee instead of a bare
|
|
@@ -25,22 +82,70 @@ export function writeJsonFileAtomic(path, data) {
|
|
|
25
82
|
// truncated file on disk -- a reader always sees either the old or the new
|
|
26
83
|
// complete content, never a partial one.
|
|
27
84
|
const tmpPath = `${path}.tmp-${process.pid}-${process.hrtime.bigint()}`;
|
|
28
|
-
|
|
29
|
-
|
|
85
|
+
// The mode the destination must end up with. Preserving an existing file's
|
|
86
|
+
// permissions matters because .mcp.json is a project-shared config other MCP
|
|
87
|
+
// clients read: whether it should be 0644 or tighter is its own decision
|
|
88
|
+
// (ahood#169), not something this write path gets to change as a side effect
|
|
89
|
+
// of the 0600 temp file below (ahood-cli#119). With no destination yet there
|
|
90
|
+
// is nothing to preserve, so fall back to what a plain writeFileSync would
|
|
91
|
+
// have produced -- 0666 masked by the process umask.
|
|
92
|
+
let finalMode;
|
|
93
|
+
try {
|
|
94
|
+
finalMode = statSync(path).mode & 0o777;
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
finalMode = 0o666 & ~process.umask();
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
// 0600 from the moment of creation: for .mcp.json this temp file holds
|
|
101
|
+
// resolved MCP server secrets, and it must never be world-readable, not
|
|
102
|
+
// even for the instant before the rename (ahood-cli#119).
|
|
103
|
+
writeFileSync(tmpPath, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 });
|
|
104
|
+
renameSync(tmpPath, path);
|
|
105
|
+
try {
|
|
106
|
+
chmodSync(path, finalMode);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// Best-effort, and deliberately after the rename rather than on the temp
|
|
110
|
+
// file: widening the temp file first would reopen the world-readable
|
|
111
|
+
// window this fix closes. A failure here leaves the file MORE restrictive
|
|
112
|
+
// than intended, never less, so it isn't worth failing a write that has
|
|
113
|
+
// already landed -- the caller would roll back a successful install.
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
sweepStaleTempFiles(path, basename(tmpPath));
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
// Swallowed for the same reason as the chmod above and #119's unlink:
|
|
120
|
+
// the rename has already landed, so failing to tidy up somebody else's
|
|
121
|
+
// abandoned temp file must never turn a successful write into an error
|
|
122
|
+
// the caller rolls back (ahood-cli#125).
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
// The rename consumes the temp file on success; this only bites when the
|
|
127
|
+
// write or the rename threw, where an orphaned *.tmp-* copy of .mcp.json
|
|
128
|
+
// would strand plaintext secrets under a name no .gitignore rule matches
|
|
129
|
+
// (ahood-cli#119). Swallowed because it must never mask the real reason
|
|
130
|
+
// the write failed -- that error is what the caller has to report.
|
|
131
|
+
try {
|
|
132
|
+
rmSync(tmpPath, { force: true });
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
// best effort, see comment above
|
|
136
|
+
}
|
|
137
|
+
}
|
|
30
138
|
}
|
|
31
139
|
function writeLockfile(path, lockfile) {
|
|
32
140
|
writeJsonFileAtomic(path, lockfile);
|
|
33
141
|
}
|
|
34
142
|
// A lock directory is stale if the PID recorded inside it (written by the
|
|
35
|
-
// acquirer below) belongs to a process that's no longer running
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
// process is still mid-way through acquiring (mkdirSync succeeded, the pid
|
|
42
|
-
// write hasn't landed yet) -- safer to wait it out than to reclaim a lock
|
|
43
|
-
// that's actually still being set up.
|
|
143
|
+
// acquirer below) belongs to a process that's no longer running, per
|
|
144
|
+
// isPidDead above. A missing/unparseable pid file is treated as "not stale"
|
|
145
|
+
// -- either a lock from before this file existed, or another process is
|
|
146
|
+
// still mid-way through acquiring (mkdirSync succeeded, the pid write hasn't
|
|
147
|
+
// landed yet) -- safer to wait it out than to reclaim a lock that's actually
|
|
148
|
+
// still being set up.
|
|
44
149
|
function isLockStale(pidFile) {
|
|
45
150
|
let pidText;
|
|
46
151
|
try {
|
|
@@ -52,13 +157,7 @@ function isLockStale(pidFile) {
|
|
|
52
157
|
const pid = Number(pidText);
|
|
53
158
|
if (!Number.isInteger(pid) || pid <= 0)
|
|
54
159
|
return false;
|
|
55
|
-
|
|
56
|
-
process.kill(pid, 0);
|
|
57
|
-
return false;
|
|
58
|
-
}
|
|
59
|
-
catch (error) {
|
|
60
|
-
return error.code === "ESRCH";
|
|
61
|
-
}
|
|
160
|
+
return isPidDead(pid);
|
|
62
161
|
}
|
|
63
162
|
// Simple advisory lock via mkdir's atomicity (EEXIST on a second caller),
|
|
64
163
|
// so two concurrent `ahood skill add`/`remove` invocations against the same
|
|
@@ -109,7 +208,7 @@ export function withLock(path, fn) {
|
|
|
109
208
|
continue;
|
|
110
209
|
}
|
|
111
210
|
if (Date.now() > deadline) {
|
|
112
|
-
throw new Error(`Timed out waiting for the
|
|
211
|
+
throw new Error(`Timed out waiting for the lock on ${path} at ${lockDir}. If no other ahood process is running, delete that directory manually.`);
|
|
113
212
|
}
|
|
114
213
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
|
|
115
214
|
}
|
package/package.json
CHANGED