@ahood/cli 0.8.8 → 0.10.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/dist/lockfile.js CHANGED
@@ -1,5 +1,6 @@
1
- import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
2
- import { basename, dirname, join } from "node:path";
1
+ import { chmodSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, renameSync, rmSync, writeFileSync, } from "node:fs";
2
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
3
+ import { hostname } from "node:os";
3
4
  export function readLockfile(path) {
4
5
  if (!existsSync(path))
5
6
  return {};
@@ -72,38 +73,126 @@ function sweepStaleTempFiles(path, ownTempName) {
72
73
  }
73
74
  }
74
75
  }
76
+ // How many links deep resolveWriteTarget will follow before declaring a cycle.
77
+ // Same order of magnitude as the kernel's own ceiling (40 on Linux); the exact
78
+ // number doesn't matter, only that a self-referential link terminates with an
79
+ // actionable error instead of spinning.
80
+ const MAX_SYMLINK_HOPS = 32;
81
+ // THE CONTRACT: a symlinked destination is written THROUGH, onto the file the
82
+ // link resolves to, and the link itself is left in place.
83
+ //
84
+ // The alternative -- letting renameSync replace the link with a regular file,
85
+ // which is what this used to do -- is indefensible for .mcp.json in
86
+ // particular: symlinking it at a shared or out-of-tree config is how a user
87
+ // keeps MCP server secrets OUT of the project directory, so replacing the link
88
+ // drops those secrets into precisely the directory the arrangement exists to
89
+ // keep them out of, while the real config silently keeps stale content.
90
+ // ahood-cli#119's mode preservation made that harder to notice rather than
91
+ // easier, since the file left behind carried the target's permissions
92
+ // (ahood-cli#144).
93
+ //
94
+ // Refusing outright (lstat, throw "this must be a regular file") was the other
95
+ // candidate, and was rejected: every caller here READS the same path first,
96
+ // through the link -- add.ts merges the existing .mcp.json, the lockfile
97
+ // writers merge the existing pins -- so a read-modify-write that reads the
98
+ // target and writes anywhere else is incoherent no matter which file ends up
99
+ // holding the result. Refusing would also make a crash-safety implementation
100
+ // detail visible as a hard failure: the plain writeFileSync this replaced
101
+ // followed the link, as does every editor and every shell redirection, and the
102
+ // callers have no idea a symlink is involved at all.
103
+ //
104
+ // lstatSync, never statSync, and deliberately so: statSync follows the link and
105
+ // therefore cannot answer the only question asked here ("is this path itself a
106
+ // link?"). Asking it with statSync is the original bug -- the mode came back
107
+ // from the link's target and was then applied to the regular file that had
108
+ // taken the link's place.
109
+ function resolveWriteTarget(path) {
110
+ let current = path;
111
+ for (let hop = 0; hop <= MAX_SYMLINK_HOPS; hop++) {
112
+ let isLink;
113
+ try {
114
+ isLink = lstatSync(current).isSymbolicLink();
115
+ }
116
+ catch {
117
+ // Nothing at this path: either a brand-new destination, or the dangling
118
+ // end of a chain whose final target doesn't exist yet -- a user who sets
119
+ // the symlink up before the first install. Write there, so the link
120
+ // starts resolving to the file the caller's next read will follow it to.
121
+ return current;
122
+ }
123
+ if (!isLink)
124
+ return current;
125
+ const link = readlinkSync(current);
126
+ current = isAbsolute(link) ? link : resolve(dirname(current), link);
127
+ }
128
+ throw new Error(`Refusing to write ${path}: it resolves through more than ${MAX_SYMLINK_HOPS} symlinks, so the chain loops back on itself. Repoint or delete that link.`);
129
+ }
75
130
  // Generalized so callers other than the lockfile itself (e.g. add.ts's
76
131
  // .mcp.json merge, which sits right next to the lockfile and can hold other
77
132
  // servers' secrets) get the same crash-safety guarantee instead of a bare
78
133
  // writeFileSync that risks truncating the file on interruption.
79
- export function writeJsonFileAtomic(path, data) {
80
- mkdirSync(dirname(path), { recursive: true });
134
+ //
135
+ // `mode` (ahood-cli#158) lets a caller decide the mode of a file this write
136
+ // CREATES: installMcpEntry requests 0600 when the payload it just resolved
137
+ // holds a credential, because only the caller knows whether the data is a
138
+ // secret store or a shared config. It never applies to an existing
139
+ // destination -- that file's mode is its owner's (or another tool's) choice,
140
+ // and preserving it is #119's whole point -- so an existing file is untouched
141
+ // no matter what is requested, and the caller learns about a loose existing
142
+ // file some other way (the warning in add.ts). Omitted, the fallback stays
143
+ // exactly what a plain writeFileSync would have produced. An explicitly
144
+ // requested mode is applied exactly, via the post-rename chmod below, rather
145
+ // than umask-masked at creation: a caller making a security decision about a
146
+ // file it is creating must get what it asked for regardless of the ambient
147
+ // umask.
148
+ export function writeJsonFileAtomic(path, data, mode) {
149
+ // Resolved before the mkdir so the directory that actually gets created is
150
+ // the resolved target's, not the link's -- a link set up ahead of the first
151
+ // install can point somewhere that doesn't exist yet -- and so that every
152
+ // step below (temp file, rename, chmod, sweep) names the same single file.
153
+ const dest = resolveWriteTarget(path);
154
+ mkdirSync(dirname(dest), { recursive: true });
81
155
  // Write-then-rename so a process interrupted mid-write never leaves a
82
156
  // truncated file on disk -- a reader always sees either the old or the new
83
157
  // complete content, never a partial one.
84
- const tmpPath = `${path}.tmp-${process.pid}-${process.hrtime.bigint()}`;
158
+ //
159
+ // The temp file must be a sibling of the RESOLVED destination rather than of
160
+ // `path`: renameSync cannot cross filesystems (EXDEV), and an out-of-tree
161
+ // target -- a different mount, a home directory on another volume -- is
162
+ // exactly the case a symlinked .mcp.json is used for. Deriving the temp name
163
+ // from `dest` keeps the rename inside one directory and keeps #119's
164
+ // `finally` unlink and #125's sweep pointed at the directory this code
165
+ // actually creates temp files in (ahood-cli#144).
166
+ const tmpPath = `${dest}.tmp-${process.pid}-${process.hrtime.bigint()}`;
85
167
  // The mode the destination must end up with. Preserving an existing file's
86
168
  // permissions matters because .mcp.json is a project-shared config other MCP
87
169
  // clients read: whether it should be 0644 or tighter is its own decision
88
170
  // (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.
171
+ // of the 0600 temp file below (ahood-cli#119) -- and that is true even when
172
+ // the caller passed `mode`, which therefore only ever governs creation. With
173
+ // no destination yet there is nothing to preserve: an explicit request wins
174
+ // (see the comment above), and without one the fallback is what a plain
175
+ // writeFileSync would have produced -- 0666 masked by the process umask.
176
+ //
177
+ // lstatSync rather than statSync even though `dest` is by construction never
178
+ // a symlink (resolveWriteTarget walked until it wasn't): the two agree here,
179
+ // and lstat says so at the call site instead of leaving a link-following stat
180
+ // one refactor away from reintroducing #144.
92
181
  let finalMode;
93
182
  try {
94
- finalMode = statSync(path).mode & 0o777;
183
+ finalMode = lstatSync(dest).mode & 0o777;
95
184
  }
96
185
  catch {
97
- finalMode = 0o666 & ~process.umask();
186
+ finalMode = mode !== undefined ? mode & 0o777 : 0o666 & ~process.umask();
98
187
  }
99
188
  try {
100
189
  // 0600 from the moment of creation: for .mcp.json this temp file holds
101
190
  // resolved MCP server secrets, and it must never be world-readable, not
102
191
  // even for the instant before the rename (ahood-cli#119).
103
192
  writeFileSync(tmpPath, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 });
104
- renameSync(tmpPath, path);
193
+ renameSync(tmpPath, dest);
105
194
  try {
106
- chmodSync(path, finalMode);
195
+ chmodSync(dest, finalMode);
107
196
  }
108
197
  catch {
109
198
  // Best-effort, and deliberately after the rename rather than on the temp
@@ -113,7 +202,13 @@ export function writeJsonFileAtomic(path, data) {
113
202
  // already landed -- the caller would roll back a successful install.
114
203
  }
115
204
  try {
116
- sweepStaleTempFiles(path, basename(tmpPath));
205
+ sweepStaleTempFiles(dest, basename(tmpPath));
206
+ // Every version before ahood-cli#144 put its temp file next to the LINK
207
+ // instead of next to the target, so a crash under one of those can have
208
+ // stranded a plaintext-secret orphan there that no later write would ever
209
+ // look at again. One extra sweep of the link's own directory retires them.
210
+ if (dest !== path)
211
+ sweepStaleTempFiles(path, basename(tmpPath));
117
212
  }
118
213
  catch {
119
214
  // Swallowed for the same reason as the chmod above and #119's unlink:
@@ -139,25 +234,194 @@ export function writeJsonFileAtomic(path, data) {
139
234
  function writeLockfile(path, lockfile) {
140
235
  writeJsonFileAtomic(path, lockfile);
141
236
  }
142
- // A lock directory is stale if the PID recorded inside it (written by the
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.
149
- function isLockStale(pidFile) {
237
+ // The pid recorded in a lock directory by its acquirer below, or undefined if
238
+ // there is no readable, plausible one: no pid file at all (a lock from before
239
+ // this file existed, or one whose acquirer is still mid-way through setting it
240
+ // up -- mkdirSync succeeded, the pid write hasn't landed yet), or contents
241
+ // that don't parse as a pid.
242
+ function readLockPid(pidFile) {
150
243
  let pidText;
151
244
  try {
152
245
  pidText = readFileSync(pidFile, "utf-8");
153
246
  }
154
247
  catch {
155
- return false;
248
+ return undefined;
156
249
  }
157
250
  const pid = Number(pidText);
158
- if (!Number.isInteger(pid) || pid <= 0)
251
+ return Number.isInteger(pid) && pid > 0 ? pid : undefined;
252
+ }
253
+ // /etc/machine-id is the stable per-machine identifier on systemd Linux:
254
+ // stable across reboots, distinct per machine. Hostname alone is not a
255
+ // sufficient identity -- two containers from the same image routinely share
256
+ // it -- so the machine id anchors the comparison wherever it exists. Read
257
+ // errors are swallowed (macOS has no such file; a read can also fail) and
258
+ // leave the identity to the hostname alone, the same best-effort posture the
259
+ // pid write below already takes.
260
+ function readMachineId() {
261
+ try {
262
+ const machineId = readFileSync("/etc/machine-id", "utf-8").trim();
263
+ return machineId.length > 0 ? machineId : undefined;
264
+ }
265
+ catch {
266
+ return undefined;
267
+ }
268
+ }
269
+ // The exact string written into a lock's `host` file: first line the machine
270
+ // id (empty when this host has none), second line the hostname. Computed once
271
+ // and cached -- neither value can change under a running process, and the
272
+ // retry loop below re-checks staleness every 25ms. Exported so a test can
273
+ // plant a lock recorded by THIS host (a foreign one just writes any other
274
+ // string), and so the format a reader must parse has exactly one definition.
275
+ let cachedHostIdentity;
276
+ export function currentHostIdentity() {
277
+ cachedHostIdentity ??= `${readMachineId() ?? ""}\n${hostname()}`;
278
+ return cachedHostIdentity;
279
+ }
280
+ function parseHostIdentity(text) {
281
+ const newline = text.indexOf("\n");
282
+ // No newline: tolerate hostname-only content rather than reading the whole
283
+ // line as a machine id that could never match.
284
+ if (newline === -1)
285
+ return { hostname: text };
286
+ return { machineId: text.slice(0, newline) || undefined, hostname: text.slice(newline + 1) };
287
+ }
288
+ // A recorded identity matches this host when the machine ids agree, or -- when
289
+ // either side has no machine id -- the hostnames do. Machine id deliberately
290
+ // wins over hostname where both exist: the same machine-id under a different
291
+ // hostname is the same machine (renamed), while a shared hostname over
292
+ // different machine ids is exactly the two-containers-from-one-image case
293
+ // hostname-only matching cannot tell apart -- the bug this exists to close.
294
+ function isSameHost(recorded) {
295
+ const ours = parseHostIdentity(currentHostIdentity());
296
+ const theirs = parseHostIdentity(recorded);
297
+ if (ours.machineId && theirs.machineId)
298
+ return ours.machineId === theirs.machineId;
299
+ return ours.hostname === theirs.hostname;
300
+ }
301
+ // The `host` file a lock records its holder's identity in, or undefined when
302
+ // there is no readable one: no host file at all, or contents that trim to
303
+ // nothing. See isLockStale for what a missing host line means.
304
+ function readLockHost(hostFile) {
305
+ try {
306
+ const host = readFileSync(hostFile, "utf-8").trim();
307
+ return host.length > 0 ? host : undefined;
308
+ }
309
+ catch {
310
+ return undefined;
311
+ }
312
+ }
313
+ // A lock directory is stale if the pid recorded inside it belongs to a process
314
+ // that's no longer running, per isPidDead above -- but only a lock recorded on
315
+ // THIS host: the pid probe cannot see another host's processes, so a lock
316
+ // whose host line names a different machine is treated as live and waited out
317
+ // no matter what the pid probe answers (ahood-cli#157). A missing host line is
318
+ // judged by pid alone, exactly as <=0.9.0 code judged every lock: the only
319
+ // such locks are legacy ones and those whose best-effort host write failed,
320
+ // and reading them as foreign instead would strand every stale local lock
321
+ // behind a misleading "held on another host" timeout -- a regression #100's
322
+ // reclaim exists to prevent, in exchange for keeping only the pre-#157 status
323
+ // quo, which disappears as soon as a lock carries a host line.
324
+ //
325
+ // No readable pid still means "not stale": safer to wait out a lock that may
326
+ // still be being set up than to reclaim one that is actually held.
327
+ function isLockStale(pidFile, hostFile) {
328
+ const pid = readLockPid(pidFile);
329
+ if (pid === undefined)
159
330
  return false;
160
- return isPidDead(pid);
331
+ const host = readLockHost(hostFile);
332
+ return (host === undefined || isSameHost(host)) && isPidDead(pid);
333
+ }
334
+ // Deliberately NOT the negation of isLockStale: with no readable pid at all a
335
+ // lock is neither stale nor demonstrably held, and the two callers want
336
+ // opposite answers in that case. Reclaiming needs proof the holder is dead
337
+ // (hence isLockStale), while withLock's deadline branch needs proof that
338
+ // someone is alive before it backs off a reclaim failure it already observed.
339
+ // A foreign-host lock counts as held no matter what the pid probe answers:
340
+ // that holder is exactly the process this machine cannot see (ahood-cli#157).
341
+ function isLockHeldByLiveProcess(pidFile, hostFile) {
342
+ const host = readLockHost(hostFile);
343
+ if (host !== undefined && !isSameHost(host))
344
+ return true;
345
+ const pid = readLockPid(pidFile);
346
+ return pid !== undefined && !isPidDead(pid);
347
+ }
348
+ // Name of the reclaim claim directory created inside a lock directory that is
349
+ // about to be reclaimed. A single well-known name, deliberately not a
350
+ // per-process one: its entire job is to be a name exactly one process can
351
+ // create.
352
+ const RECLAIM_CLAIM = "reclaiming";
353
+ // Reclaiming a stale lock is a claim-verify-remove, not the bare
354
+ // check-then-delete it used to be: two waiters could both judge the SAME dead
355
+ // holder stale off the same snapshot, and then the second one's rmSync would
356
+ // delete the first one's freshly-taken, live lock -- two processes inside the
357
+ // critical section at once, which is the exact lost update on .mcp.json /
358
+ // skills.lock.json that withLock exists to prevent (ahood-cli#141).
359
+ //
360
+ // The claim is a directory created INSIDE the lock directory, so it rides on
361
+ // the same mkdir atomicity the lock itself is built on: exactly one waiter can
362
+ // create it, and the losers fall through to the ordinary wait. Two
363
+ // alternatives were weighed and rejected:
364
+ // - Merely re-reading the pid immediately before the rmSync narrows the
365
+ // window but never closes it; the check and the unlink still aren't atomic.
366
+ // - renameSync-ing the lock directory itself to a unique name makes the
367
+ // rename the claim, but a rename succeeds against WHATEVER directory sits
368
+ // at that path -- a live successor's included -- and it leaves the lock
369
+ // path briefly absent, so a third process can mkdir it and acquire for
370
+ // real. That manufactures two live holders rather than preventing them.
371
+ // This claim never removes or rewrites anything a holder owns and never makes
372
+ // the lock path vanish, so mutual exclusion survives even a claim that turns
373
+ // out to have been a mistake.
374
+ //
375
+ // Winning the claim is what makes the staleness verdict trustworthy, which is
376
+ // why the pid is re-read UNDER it: the directory judged stale a moment ago may
377
+ // already have been reclaimed and retaken by someone alive. Once the claim is
378
+ // held and the pid still reads dead, nothing else can remove that directory --
379
+ // a release by its holder is ruled out by that dead pid, and every other
380
+ // reclaimer is blocked by the claim -- so the verdict still holds at the moment
381
+ // of the rmSync below.
382
+ //
383
+ // Returns the error to remember for withLock's deadline branch (see
384
+ // ahood-cli#140), or undefined when there is nothing worth remembering.
385
+ function reclaimStaleLock(lockDir, pidFile, hostFile) {
386
+ const claim = join(lockDir, RECLAIM_CLAIM);
387
+ try {
388
+ mkdirSync(claim);
389
+ }
390
+ catch (error) {
391
+ const code = error.code;
392
+ // EEXIST means another waiter is reclaiming this very directory right now
393
+ // and ENOENT that it is already gone; both resolve themselves on the next
394
+ // retry. Anything else (EACCES/EPERM/EROFS on a directory this process
395
+ // cannot write into) is a reclaim that can never succeed, and is what the
396
+ // deadline branch reports instead of a misleading "waiting for the lock".
397
+ if (code === "EEXIST" || code === "ENOENT")
398
+ return undefined;
399
+ return existsSync(lockDir) ? error : undefined;
400
+ }
401
+ if (!isLockStale(pidFile, hostFile)) {
402
+ // Not the directory that was judged stale after all -- it was reclaimed and
403
+ // retaken while this process looked, or it belongs to a holder that hasn't
404
+ // written its pid yet. Hand the claim back, leaving the lock exactly as it
405
+ // was found.
406
+ try {
407
+ rmSync(claim, { recursive: true, force: true });
408
+ }
409
+ catch {
410
+ // Best effort: the holder's own release takes this directory, claim and
411
+ // all, with it. Worst case is a lock nothing reclaims, which times out
412
+ // with an actionable message rather than corrupting anything -- the same
413
+ // outcome as a process killed between the two syscalls above.
414
+ }
415
+ return undefined;
416
+ }
417
+ try {
418
+ rmSync(lockDir, { recursive: true, force: true });
419
+ }
420
+ catch (error) {
421
+ if (existsSync(lockDir))
422
+ return error;
423
+ }
424
+ return undefined;
161
425
  }
162
426
  // Simple advisory lock via mkdir's atomicity (EEXIST on a second caller),
163
427
  // so two concurrent `ahood skill add`/`remove` invocations against the same
@@ -171,12 +435,26 @@ function isLockStale(pidFile) {
171
435
  // hard-killed (SIGKILL/OOM) mid-critical-section: without this, such a lock
172
436
  // is never released, and every subsequent add/remove/update in that project
173
437
  // busy-waits out the full timeout below and then fails permanently until a
174
- // human manually deletes the stale directory (ahood-cli#100).
438
+ // human manually deletes the stale directory (ahood-cli#100). It also holds
439
+ // a `host` file recording WHERE that holder ran, because a pid is only
440
+ // meaningful on the machine it belongs to: on a shared filesystem a live
441
+ // holder on another host reads as dead here, and reclaiming its lock puts
442
+ // two processes inside the critical section at once (ahood-cli#157).
175
443
  export function withLock(path, fn) {
176
444
  mkdirSync(dirname(path), { recursive: true });
177
445
  const lockDir = `${path}.lock`;
178
446
  const pidFile = join(lockDir, "pid");
447
+ const hostFile = join(lockDir, "host");
179
448
  const deadline = Date.now() + 5000;
449
+ // Set when the lock was judged stale but could not actually be removed and
450
+ // that directory is still sitting there -- see the reclaim branch below.
451
+ // Reported instead of the generic timeout, because "another process holds
452
+ // the lock" sends the user hunting for a process that provably no longer
453
+ // exists (ahood-cli#140).
454
+ let reclaimError;
455
+ // Whether this process's own pid ever made it into the lock directory --
456
+ // what lets the release below tell its own lock from a successor's.
457
+ let wrotePid = false;
180
458
  for (;;) {
181
459
  try {
182
460
  mkdirSync(lockDir);
@@ -185,6 +463,14 @@ export function withLock(path, fn) {
185
463
  // just without staleness detection for this particular acquisition.
186
464
  try {
187
465
  writeFileSync(pidFile, String(process.pid));
466
+ wrotePid = true;
467
+ // Best-effort like the pid write above, and for the same reason:
468
+ // mkdirSync is what actually holds the lock. A lock whose host line
469
+ // never landed carries no host identity and is judged exactly as
470
+ // pre-#157 code judged every lock -- by pid alone (see isLockStale's
471
+ // missing-host rule) -- so a failed write here degrades to today's
472
+ // behaviour rather than to something new.
473
+ writeFileSync(hostFile, currentHostIdentity());
188
474
  }
189
475
  catch {
190
476
  // non-fatal, see comment above
@@ -194,22 +480,72 @@ export function withLock(path, fn) {
194
480
  catch (error) {
195
481
  if (error.code !== "EEXIST")
196
482
  throw error;
197
- if (isLockStale(pidFile)) {
198
- // Reclaim it. Best-effort removal: if a concurrent process wins the
199
- // race and reclaims (or a live holder finishes and releases) first,
200
- // this just falls through to the normal wait/retry below instead of
201
- // throwing.
202
- try {
203
- rmSync(lockDir, { recursive: true, force: true });
483
+ // The deadline is tested FIRST so that every retry path is bounded by
484
+ // it. The reclaim branch below used to `continue` straight past both
485
+ // this check and the sleep, so a stale lock this process could not
486
+ // actually remove (rmSync's force:true suppresses only ENOENT, never
487
+ // EACCES/EPERM/EROFS) turned into an unbounded 100%-CPU spin that never
488
+ // threw and never printed anything -- the one thing a CLI built to run
489
+ // unattended in CI must never do (ahood-cli#140).
490
+ if (Date.now() > deadline) {
491
+ // The remembered failure is re-confirmed here rather than trusted,
492
+ // because a process that DOES have permission (a root/owner ahood in
493
+ // another terminal) can clear that directory and take the lock for
494
+ // real while this one waits -- and "the process that held it is gone"
495
+ // would then be a confident lie about a lock somebody is legitimately
496
+ // holding. Re-confirming asks exactly what the message claims: is
497
+ // there still a directory, and is nobody alive holding it? Identity
498
+ // (inode, birthtime) deliberately isn't used for this: a removed and
499
+ // immediately recreated directory routinely gets the same inode back,
500
+ // so it can't tell a replacement from the original anyway.
501
+ if (reclaimError && existsSync(lockDir) && !isLockHeldByLiveProcess(pidFile, hostFile)) {
502
+ throw new Error(`Could not reclaim the stale lock on ${path} at ${lockDir}: ${reclaimError.code ?? reclaimError.message}. The process that held it is gone, but this one cannot remove that directory -- delete it manually (it may belong to another user, e.g. left behind by a sudo/root run, or sit on a read-only filesystem).`);
204
503
  }
205
- catch {
206
- // lost the race -- fall through to wait/retry
504
+ // A lock recorded on another host never reaches the reclaim branch
505
+ // (isLockStale refuses to judge it stale), so its timeout lands here.
506
+ // Name the holder's host rather than falling through to the generic
507
+ // message: the pid in that lock is dead by this host's lights, so "if
508
+ // no other ahood process is running" reads as permission to delete a
509
+ // lock that may well belong to a live process this machine cannot see
510
+ // (ahood-cli#157).
511
+ const recordedHost = readLockHost(hostFile);
512
+ if (recordedHost !== undefined && !isSameHost(recordedHost)) {
513
+ const holder = parseHostIdentity(recordedHost);
514
+ const holderPid = readLockPid(pidFile);
515
+ const holderParts = [
516
+ `hostname "${holder.hostname}"`,
517
+ ...(holder.machineId ? [`machine id ${holder.machineId}`] : []),
518
+ ...(holderPid !== undefined ? [`pid ${holderPid}`] : []),
519
+ ].join(", ");
520
+ throw new Error(`Timed out waiting for the lock on ${path} at ${lockDir}: it is held on another host (${holderParts}), which this machine cannot probe for liveness. If nothing on that host holds it any more, delete that directory.`);
207
521
  }
208
- continue;
209
- }
210
- if (Date.now() > deadline) {
211
522
  throw new Error(`Timed out waiting for the lock on ${path} at ${lockDir}. If no other ahood process is running, delete that directory manually.`);
212
523
  }
524
+ if (isLockStale(pidFile, hostFile)) {
525
+ // Reclaim it. Two very different conditions can fail that, and only
526
+ // one of them is the race the old comment here claimed: if the
527
+ // directory is GONE afterwards, a concurrent process reclaimed it (or
528
+ // a live holder released it) first, which is harmless -- the retry
529
+ // below simply takes the lock. If it is still THERE, this process
530
+ // cannot remove it at all, and retrying can only fail the same way,
531
+ // so the failure is remembered for the deadline branch above rather
532
+ // than swallowed (ahood-cli#140). It is remembered rather than thrown
533
+ // immediately because a mid-reclaim race can also surface as e.g.
534
+ // ENOTEMPTY with the directory still present, and that one does clear
535
+ // itself on the next attempt.
536
+ //
537
+ // The memory deliberately survives later iterations (hence `??`
538
+ // rather than a plain assignment): a failed recursive removal can take
539
+ // the `pid` file with it (it deletes the contents, then fails on the
540
+ // directory itself -- which of the two happens before the error is an
541
+ // implementation detail that differs between Node versions), and
542
+ // without a pid file isLockStale reports "not stale", so this branch
543
+ // never runs again and there is no second chance to observe the
544
+ // failure. Clearing it per-iteration made the error message depend on
545
+ // that detail, which is why it read correctly on one machine and fell
546
+ // back to the generic timeout on another (ahood-cli#145).
547
+ reclaimError = reclaimStaleLock(lockDir, pidFile, hostFile) ?? reclaimError;
548
+ }
213
549
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
214
550
  }
215
551
  }
@@ -217,7 +553,30 @@ export function withLock(path, fn) {
217
553
  return fn();
218
554
  }
219
555
  finally {
220
- rmSync(lockDir, { recursive: true, force: true });
556
+ // Releasing used to be an unconditional rmSync, which is only correct for
557
+ // as long as this process still holds the lock it took -- and it does not
558
+ // always. A reclaimer that judged this process dead can delete this lock
559
+ // out from under a running critical section (ahood-cli#141 on one host,
560
+ // and the host-local pid probe above across a container bind mount or a
561
+ // network share), and the unconditional removal then took the SUCCESSOR's
562
+ // lock down with it, turning one lost update into a chain of them.
563
+ // Removing only a directory that still records THIS process's pid keeps
564
+ // the damage to the process that was wronged.
565
+ //
566
+ // A pid file that is unreadable despite having been written is left alone
567
+ // for the same reason: the only thing that removes it is a reclaimer
568
+ // partway through taking this lock, so whatever is at that path is no
569
+ // longer this process's to delete.
570
+ //
571
+ // With no pid of this process's own ever written (the best-effort write
572
+ // above failed) there is nothing to compare against -- and nothing to
573
+ // protect either, since a lock directory with no readable pid is never
574
+ // judged stale and so can never have been reclaimed. Removing it
575
+ // unconditionally is then both safe and necessary: leaving it behind
576
+ // strands a lock that nothing is able to reclaim.
577
+ if (!wrotePid || readLockPid(pidFile) === process.pid) {
578
+ rmSync(lockDir, { recursive: true, force: true });
579
+ }
221
580
  }
222
581
  }
223
582
  export function writeLockfileEntry(path, ownerSlashSkill, entry) {
@@ -38,8 +38,20 @@ export function safeTool(fn) {
38
38
  return { content: [{ type: "text", text: JSON.stringify(result ?? null) }] };
39
39
  }
40
40
  catch (error) {
41
- const message = error instanceof Error ? error.message : String(error);
42
- const body = { error: message, error_code: errorCodeFor(error) };
41
+ const code = errorCodeFor(error);
42
+ let message = error instanceof Error ? error.message : String(error);
43
+ // ahood-cli#159 / ahood#313 Phase 5. The registry's 401 body is always
44
+ // the byte-identical `{ error: "Unauthorized" }` (lib/api-auth.ts) --
45
+ // correct, but not actionable: an MCP client here is a coding agent
46
+ // reading this JSON, and "Unauthorized" names no remedy. The CLI's own
47
+ // top-level handler already prints the fix (src/index.ts: "Run `ahood
48
+ // login` first (or set AHOOD_TOKEN)."); append the same sentence here
49
+ // verbatim so both surfaces say the same thing rather than two
50
+ // near-copies drifting apart. Only auth_error: usage/not-found/network
51
+ // errors carry their own already-actionable text.
52
+ if (code === "auth_error")
53
+ message += " Run `ahood login` first (or set AHOOD_TOKEN).";
54
+ const body = { error: message, error_code: code };
43
55
  return { content: [{ type: "text", text: JSON.stringify(body) }], isError: true };
44
56
  }
45
57
  };
package/dist/spec.js CHANGED
@@ -6,16 +6,19 @@ import { UsageError } from "./usage-error.js";
6
6
  // (a spec like "alice/.." previously resolved outside .claude/skills/ entirely).
7
7
  const SEGMENT_RE = /^[a-z0-9][a-z0-9._-]*$/i;
8
8
  export const SEMVER_RE = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
9
- // The backend enforces these same caps on write (app/api/v1/profile/route.ts's
10
- // MAX_USERNAME_LENGTH for owner usernames; lib/skills/mutations.ts's
11
- // createSkill 3-64 check for skill slugs), so a segment over these lengths
12
- // can never match a real owner/skill anyway. Rejecting it here -- before any
9
+ // Owner: the backend's MAX_USERNAME_LOOKUP_LENGTH (256, ahood#628), NOT the
10
+ // 32-character cap on usernames a user CHOOSES. Provisioned usernames (an
11
+ // email local part up to 64, a Clerk id, plus a collision suffix) can exceed
12
+ // 32, and those users must still be able to publish and manage their own
13
+ // skills -- the server accepts them since #628, so this client cap had to
14
+ // follow. Skill: lib/skills/mutations.ts's createSkill 3-64 slug cap. A
15
+ // segment over these lengths can never match a real owner/skill anyway. Rejecting it here -- before any
13
16
  // request is built -- turns a validly-charset-formatted but absurdly long
14
17
  // segment into a clean local error instead of a network round trip that, at
15
18
  // a truly pathological length (thousands of characters), hits a raw infra/
16
19
  // CDN-layer URL-length limit and 502s before the backend's own routing ever
17
20
  // runs (ahood-cli#38).
18
- const MAX_SEGMENT_LENGTH = { owner: 32, skill: 64 };
21
+ const MAX_SEGMENT_LENGTH = { owner: 256, skill: 64 };
19
22
  export function validateSegment(value, kind, spec) {
20
23
  if (!SEGMENT_RE.test(value) || value === "." || value === "..") {
21
24
  throw new UsageError(`Invalid ${kind} "${value}" in "${spec}" -- must start with a letter/digit and contain only letters, digits, ".", "_", or "-".`);
@@ -0,0 +1,62 @@
1
+ // Control characters are replaced with a space rather than deleted so that
2
+ // stripping can't silently glue two tokens into one misleading word.
3
+ //
4
+ // Newlines (\x0a) and carriage returns (\x0d) are inside this range and are
5
+ // deliberately flattened too (ahood-cli#127): a forged prompt line
6
+ // ("...\n\nEnter your token:") needs no escape sequence at all, just a
7
+ // newline, so preserving them would leave the spoofing half of the threat
8
+ // model open while closing the escape half. The readability cost is small
9
+ // because callers bound what reaches here to a few lines' worth of text, not
10
+ // a stack trace.
11
+ const CONTROL_CHARACTERS = /[\x00-\x1f\x7f-\x9f]/g;
12
+ // Text that reaches the terminal but originates outside this CLI -- a manifest
13
+ // field from a third-party-published archive, an error string forwarded by the
14
+ // API -- can smuggle a terminal control sequence (cursor movement, line-clear)
15
+ // that repaints what the user sees. That matters most in `add`, which prints
16
+ // such text immediately before a masked secret prompt: the exact moment a
17
+ // repaint can convince a user to type a credential into an attacker's message.
18
+ // This strips C0/C1 control characters (including ESC, \x1b) and caps length so
19
+ // a single field can't also flood the terminal.
20
+ //
21
+ // Takes `unknown` rather than `string` because a caller's "string" may be a
22
+ // declared type over an unchecked cast of downloaded JSON, not a guarantee:
23
+ // calling .replace() directly on a `registry_type: 123` would turn a
24
+ // diagnosable message into the raw TypeError ahood-cli#121 removed from that
25
+ // same path (ahood-cli#122).
26
+ export function sanitizeForTerminal(text, maxLength = 200) {
27
+ return String(text).replace(CONTROL_CHARACTERS, " ").slice(0, maxLength);
28
+ }
29
+ // Same C0/C1 range as CONTROL_CHARACTERS above, minus the three characters
30
+ // that carry a document's layout rather than a terminal instruction: tab
31
+ // (\x09), line feed (\x0a) and carriage return (\x0d).
32
+ const CONTROL_CHARACTERS_EXCEPT_LAYOUT = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g;
33
+ // The document-shaped sibling of sanitizeForTerminal, for the one caller that
34
+ // prints a whole publisher-authored file to a human's terminal
35
+ // (`ahood skill read`, ahood-cli#133) rather than a field interpolated into a
36
+ // CLI-authored message. Two of sanitizeForTerminal's decisions invert at that
37
+ // size, which is why this is a separate function rather than an extra
38
+ // argument on that one -- a flag that flips half of a function's documented
39
+ // behaviour is harder to reason about than two functions with two rationales:
40
+ //
41
+ // - Newlines are preserved. #127 flattens them because a forged prompt line
42
+ // ("...\n\nEnter your token:") needs no escape sequence at all, and that
43
+ // rationale explicitly rests on callers bounding input "to a few lines'
44
+ // worth of text". A markdown document is not that: flattening it yields
45
+ // one unreadable line, destroying the command. The spoofing risk it trades
46
+ // away is also much weaker here -- `read` prints and exits without ever
47
+ // prompting, and a user who typed `ahood skill read` has already been told
48
+ // they are looking at someone else's untrusted prose.
49
+ //
50
+ // - There is no length cap. sanitizeForTerminal's 200 exists so one field
51
+ // can't flood the terminal; capping a document the user explicitly asked
52
+ // to read is its own failure mode, and a hostile one: a publisher could
53
+ // simply push the part worth hiding past the cap and let the CLI truncate
54
+ // it away, turning a safety measure into a concealment primitive. Once the
55
+ // escapes are gone the residue is plain text that scrolls -- which the
56
+ // terminal's scrollback, a pager, or Ctrl-C already handles, and which
57
+ // cannot repaint, retitle, or hide anything.
58
+ //
59
+ // Takes `unknown` for the same reason sanitizeForTerminal does (ahood-cli#122).
60
+ export function sanitizeDocumentForTerminal(text) {
61
+ return String(text).replace(CONTROL_CHARACTERS_EXCEPT_LAYOUT, " ");
62
+ }