@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/README.md +92 -6
- package/dist/commands/add.js +147 -54
- package/dist/commands/completion.js +61 -37
- package/dist/commands/diff.js +4 -1
- package/dist/commands/edit.js +3 -1
- package/dist/commands/init.js +90 -10
- package/dist/commands/list.js +11 -5
- package/dist/commands/login.js +63 -3
- package/dist/commands/mcp.js +6 -3
- package/dist/commands/publish.js +35 -5
- package/dist/commands/read.js +30 -2
- package/dist/commands/remove.js +83 -12
- package/dist/commands/search.js +12 -4
- package/dist/commands/share.js +5 -2
- package/dist/commands/snap.js +140 -35
- package/dist/commands/star.js +5 -2
- package/dist/commands/unpublish.js +5 -1
- package/dist/commands/update.js +109 -11
- package/dist/commands/versions.js +3 -1
- package/dist/commands/view.js +4 -1
- package/dist/commands/whoami.js +50 -9
- package/dist/flags.js +100 -8
- package/dist/help.js +292 -15
- package/dist/http.js +137 -45
- package/dist/index.js +106 -22
- package/dist/kind-info.js +40 -0
- package/dist/kinds.js +122 -0
- package/dist/lockfile.js +397 -38
- package/dist/mcp/safe-tool.js +14 -2
- package/dist/spec.js +8 -5
- package/dist/terminal-safe.js +62 -0
- package/dist/useme/SKILL.md +170 -0
- package/dist/useme.js +14 -0
- package/package.json +3 -2
package/dist/lockfile.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync,
|
|
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
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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)
|
|
90
|
-
//
|
|
91
|
-
//
|
|
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 =
|
|
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,
|
|
193
|
+
renameSync(tmpPath, dest);
|
|
105
194
|
try {
|
|
106
|
-
chmodSync(
|
|
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(
|
|
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
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
// --
|
|
146
|
-
//
|
|
147
|
-
|
|
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
|
|
248
|
+
return undefined;
|
|
156
249
|
}
|
|
157
250
|
const pid = Number(pidText);
|
|
158
|
-
|
|
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
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
206
|
-
|
|
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
|
|
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) {
|
package/dist/mcp/safe-tool.js
CHANGED
|
@@ -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
|
|
42
|
-
|
|
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
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
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:
|
|
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
|
+
}
|