@ahood/cli 0.8.8 → 0.9.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 +1 -1
- package/dist/commands/add.js +79 -44
- package/dist/commands/read.js +27 -1
- package/dist/commands/remove.js +54 -11
- package/dist/commands/snap.js +140 -35
- package/dist/commands/update.js +48 -2
- package/dist/flags.js +100 -8
- package/dist/help.js +22 -8
- package/dist/http.js +12 -19
- package/dist/lockfile.js +268 -34
- package/dist/terminal-safe.js +62 -0
- package/package.json +1 -1
package/dist/lockfile.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
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
3
|
export function readLockfile(path) {
|
|
4
4
|
if (!existsSync(path))
|
|
5
5
|
return {};
|
|
@@ -72,16 +72,83 @@ function sweepStaleTempFiles(path, ownTempName) {
|
|
|
72
72
|
}
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
|
+
// How many links deep resolveWriteTarget will follow before declaring a cycle.
|
|
76
|
+
// Same order of magnitude as the kernel's own ceiling (40 on Linux); the exact
|
|
77
|
+
// number doesn't matter, only that a self-referential link terminates with an
|
|
78
|
+
// actionable error instead of spinning.
|
|
79
|
+
const MAX_SYMLINK_HOPS = 32;
|
|
80
|
+
// THE CONTRACT: a symlinked destination is written THROUGH, onto the file the
|
|
81
|
+
// link resolves to, and the link itself is left in place.
|
|
82
|
+
//
|
|
83
|
+
// The alternative -- letting renameSync replace the link with a regular file,
|
|
84
|
+
// which is what this used to do -- is indefensible for .mcp.json in
|
|
85
|
+
// particular: symlinking it at a shared or out-of-tree config is how a user
|
|
86
|
+
// keeps MCP server secrets OUT of the project directory, so replacing the link
|
|
87
|
+
// drops those secrets into precisely the directory the arrangement exists to
|
|
88
|
+
// keep them out of, while the real config silently keeps stale content.
|
|
89
|
+
// ahood-cli#119's mode preservation made that harder to notice rather than
|
|
90
|
+
// easier, since the file left behind carried the target's permissions
|
|
91
|
+
// (ahood-cli#144).
|
|
92
|
+
//
|
|
93
|
+
// Refusing outright (lstat, throw "this must be a regular file") was the other
|
|
94
|
+
// candidate, and was rejected: every caller here READS the same path first,
|
|
95
|
+
// through the link -- add.ts merges the existing .mcp.json, the lockfile
|
|
96
|
+
// writers merge the existing pins -- so a read-modify-write that reads the
|
|
97
|
+
// target and writes anywhere else is incoherent no matter which file ends up
|
|
98
|
+
// holding the result. Refusing would also make a crash-safety implementation
|
|
99
|
+
// detail visible as a hard failure: the plain writeFileSync this replaced
|
|
100
|
+
// followed the link, as does every editor and every shell redirection, and the
|
|
101
|
+
// callers have no idea a symlink is involved at all.
|
|
102
|
+
//
|
|
103
|
+
// lstatSync, never statSync, and deliberately so: statSync follows the link and
|
|
104
|
+
// therefore cannot answer the only question asked here ("is this path itself a
|
|
105
|
+
// link?"). Asking it with statSync is the original bug -- the mode came back
|
|
106
|
+
// from the link's target and was then applied to the regular file that had
|
|
107
|
+
// taken the link's place.
|
|
108
|
+
function resolveWriteTarget(path) {
|
|
109
|
+
let current = path;
|
|
110
|
+
for (let hop = 0; hop <= MAX_SYMLINK_HOPS; hop++) {
|
|
111
|
+
let isLink;
|
|
112
|
+
try {
|
|
113
|
+
isLink = lstatSync(current).isSymbolicLink();
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// Nothing at this path: either a brand-new destination, or the dangling
|
|
117
|
+
// end of a chain whose final target doesn't exist yet -- a user who sets
|
|
118
|
+
// the symlink up before the first install. Write there, so the link
|
|
119
|
+
// starts resolving to the file the caller's next read will follow it to.
|
|
120
|
+
return current;
|
|
121
|
+
}
|
|
122
|
+
if (!isLink)
|
|
123
|
+
return current;
|
|
124
|
+
const link = readlinkSync(current);
|
|
125
|
+
current = isAbsolute(link) ? link : resolve(dirname(current), link);
|
|
126
|
+
}
|
|
127
|
+
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.`);
|
|
128
|
+
}
|
|
75
129
|
// Generalized so callers other than the lockfile itself (e.g. add.ts's
|
|
76
130
|
// .mcp.json merge, which sits right next to the lockfile and can hold other
|
|
77
131
|
// servers' secrets) get the same crash-safety guarantee instead of a bare
|
|
78
132
|
// writeFileSync that risks truncating the file on interruption.
|
|
79
133
|
export function writeJsonFileAtomic(path, data) {
|
|
80
|
-
|
|
134
|
+
// Resolved before the mkdir so the directory that actually gets created is
|
|
135
|
+
// the resolved target's, not the link's -- a link set up ahead of the first
|
|
136
|
+
// install can point somewhere that doesn't exist yet -- and so that every
|
|
137
|
+
// step below (temp file, rename, chmod, sweep) names the same single file.
|
|
138
|
+
const dest = resolveWriteTarget(path);
|
|
139
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
81
140
|
// Write-then-rename so a process interrupted mid-write never leaves a
|
|
82
141
|
// truncated file on disk -- a reader always sees either the old or the new
|
|
83
142
|
// complete content, never a partial one.
|
|
84
|
-
|
|
143
|
+
//
|
|
144
|
+
// The temp file must be a sibling of the RESOLVED destination rather than of
|
|
145
|
+
// `path`: renameSync cannot cross filesystems (EXDEV), and an out-of-tree
|
|
146
|
+
// target -- a different mount, a home directory on another volume -- is
|
|
147
|
+
// exactly the case a symlinked .mcp.json is used for. Deriving the temp name
|
|
148
|
+
// from `dest` keeps the rename inside one directory and keeps #119's
|
|
149
|
+
// `finally` unlink and #125's sweep pointed at the directory this code
|
|
150
|
+
// actually creates temp files in (ahood-cli#144).
|
|
151
|
+
const tmpPath = `${dest}.tmp-${process.pid}-${process.hrtime.bigint()}`;
|
|
85
152
|
// The mode the destination must end up with. Preserving an existing file's
|
|
86
153
|
// permissions matters because .mcp.json is a project-shared config other MCP
|
|
87
154
|
// clients read: whether it should be 0644 or tighter is its own decision
|
|
@@ -89,9 +156,14 @@ export function writeJsonFileAtomic(path, data) {
|
|
|
89
156
|
// of the 0600 temp file below (ahood-cli#119). With no destination yet there
|
|
90
157
|
// is nothing to preserve, so fall back to what a plain writeFileSync would
|
|
91
158
|
// have produced -- 0666 masked by the process umask.
|
|
159
|
+
//
|
|
160
|
+
// lstatSync rather than statSync even though `dest` is by construction never
|
|
161
|
+
// a symlink (resolveWriteTarget walked until it wasn't): the two agree here,
|
|
162
|
+
// and lstat says so at the call site instead of leaving a link-following stat
|
|
163
|
+
// one refactor away from reintroducing #144.
|
|
92
164
|
let finalMode;
|
|
93
165
|
try {
|
|
94
|
-
finalMode =
|
|
166
|
+
finalMode = lstatSync(dest).mode & 0o777;
|
|
95
167
|
}
|
|
96
168
|
catch {
|
|
97
169
|
finalMode = 0o666 & ~process.umask();
|
|
@@ -101,9 +173,9 @@ export function writeJsonFileAtomic(path, data) {
|
|
|
101
173
|
// resolved MCP server secrets, and it must never be world-readable, not
|
|
102
174
|
// even for the instant before the rename (ahood-cli#119).
|
|
103
175
|
writeFileSync(tmpPath, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 });
|
|
104
|
-
renameSync(tmpPath,
|
|
176
|
+
renameSync(tmpPath, dest);
|
|
105
177
|
try {
|
|
106
|
-
chmodSync(
|
|
178
|
+
chmodSync(dest, finalMode);
|
|
107
179
|
}
|
|
108
180
|
catch {
|
|
109
181
|
// Best-effort, and deliberately after the rename rather than on the temp
|
|
@@ -113,7 +185,13 @@ export function writeJsonFileAtomic(path, data) {
|
|
|
113
185
|
// already landed -- the caller would roll back a successful install.
|
|
114
186
|
}
|
|
115
187
|
try {
|
|
116
|
-
sweepStaleTempFiles(
|
|
188
|
+
sweepStaleTempFiles(dest, basename(tmpPath));
|
|
189
|
+
// Every version before ahood-cli#144 put its temp file next to the LINK
|
|
190
|
+
// instead of next to the target, so a crash under one of those can have
|
|
191
|
+
// stranded a plaintext-secret orphan there that no later write would ever
|
|
192
|
+
// look at again. One extra sweep of the link's own directory retires them.
|
|
193
|
+
if (dest !== path)
|
|
194
|
+
sweepStaleTempFiles(path, basename(tmpPath));
|
|
117
195
|
}
|
|
118
196
|
catch {
|
|
119
197
|
// Swallowed for the same reason as the chmod above and #119's unlink:
|
|
@@ -139,25 +217,116 @@ export function writeJsonFileAtomic(path, data) {
|
|
|
139
217
|
function writeLockfile(path, lockfile) {
|
|
140
218
|
writeJsonFileAtomic(path, lockfile);
|
|
141
219
|
}
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
// --
|
|
146
|
-
//
|
|
147
|
-
|
|
148
|
-
// still being set up.
|
|
149
|
-
function isLockStale(pidFile) {
|
|
220
|
+
// The pid recorded in a lock directory by its acquirer below, or undefined if
|
|
221
|
+
// there is no readable, plausible one: no pid file at all (a lock from before
|
|
222
|
+
// this file existed, or one whose acquirer is still mid-way through setting it
|
|
223
|
+
// up -- mkdirSync succeeded, the pid write hasn't landed yet), or contents
|
|
224
|
+
// that don't parse as a pid.
|
|
225
|
+
function readLockPid(pidFile) {
|
|
150
226
|
let pidText;
|
|
151
227
|
try {
|
|
152
228
|
pidText = readFileSync(pidFile, "utf-8");
|
|
153
229
|
}
|
|
154
230
|
catch {
|
|
155
|
-
return
|
|
231
|
+
return undefined;
|
|
156
232
|
}
|
|
157
233
|
const pid = Number(pidText);
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
234
|
+
return Number.isInteger(pid) && pid > 0 ? pid : undefined;
|
|
235
|
+
}
|
|
236
|
+
// A lock directory is stale if the pid recorded inside it belongs to a process
|
|
237
|
+
// that's no longer running, per isPidDead above. No readable pid means "not
|
|
238
|
+
// stale": safer to wait out a lock that may still be being set up than to
|
|
239
|
+
// reclaim one that is actually held.
|
|
240
|
+
function isLockStale(pidFile) {
|
|
241
|
+
const pid = readLockPid(pidFile);
|
|
242
|
+
return pid !== undefined && isPidDead(pid);
|
|
243
|
+
}
|
|
244
|
+
// Deliberately NOT the negation of isLockStale: with no readable pid at all a
|
|
245
|
+
// lock is neither stale nor demonstrably held, and the two callers want
|
|
246
|
+
// opposite answers in that case. Reclaiming needs proof the holder is dead
|
|
247
|
+
// (hence isLockStale), while withLock's deadline branch needs proof that
|
|
248
|
+
// someone is alive before it backs off a reclaim failure it already observed.
|
|
249
|
+
function isLockHeldByLiveProcess(pidFile) {
|
|
250
|
+
const pid = readLockPid(pidFile);
|
|
251
|
+
return pid !== undefined && !isPidDead(pid);
|
|
252
|
+
}
|
|
253
|
+
// Name of the reclaim claim directory created inside a lock directory that is
|
|
254
|
+
// about to be reclaimed. A single well-known name, deliberately not a
|
|
255
|
+
// per-process one: its entire job is to be a name exactly one process can
|
|
256
|
+
// create.
|
|
257
|
+
const RECLAIM_CLAIM = "reclaiming";
|
|
258
|
+
// Reclaiming a stale lock is a claim-verify-remove, not the bare
|
|
259
|
+
// check-then-delete it used to be: two waiters could both judge the SAME dead
|
|
260
|
+
// holder stale off the same snapshot, and then the second one's rmSync would
|
|
261
|
+
// delete the first one's freshly-taken, live lock -- two processes inside the
|
|
262
|
+
// critical section at once, which is the exact lost update on .mcp.json /
|
|
263
|
+
// skills.lock.json that withLock exists to prevent (ahood-cli#141).
|
|
264
|
+
//
|
|
265
|
+
// The claim is a directory created INSIDE the lock directory, so it rides on
|
|
266
|
+
// the same mkdir atomicity the lock itself is built on: exactly one waiter can
|
|
267
|
+
// create it, and the losers fall through to the ordinary wait. Two
|
|
268
|
+
// alternatives were weighed and rejected:
|
|
269
|
+
// - Merely re-reading the pid immediately before the rmSync narrows the
|
|
270
|
+
// window but never closes it; the check and the unlink still aren't atomic.
|
|
271
|
+
// - renameSync-ing the lock directory itself to a unique name makes the
|
|
272
|
+
// rename the claim, but a rename succeeds against WHATEVER directory sits
|
|
273
|
+
// at that path -- a live successor's included -- and it leaves the lock
|
|
274
|
+
// path briefly absent, so a third process can mkdir it and acquire for
|
|
275
|
+
// real. That manufactures two live holders rather than preventing them.
|
|
276
|
+
// This claim never removes or rewrites anything a holder owns and never makes
|
|
277
|
+
// the lock path vanish, so mutual exclusion survives even a claim that turns
|
|
278
|
+
// out to have been a mistake.
|
|
279
|
+
//
|
|
280
|
+
// Winning the claim is what makes the staleness verdict trustworthy, which is
|
|
281
|
+
// why the pid is re-read UNDER it: the directory judged stale a moment ago may
|
|
282
|
+
// already have been reclaimed and retaken by someone alive. Once the claim is
|
|
283
|
+
// held and the pid still reads dead, nothing else can remove that directory --
|
|
284
|
+
// a release by its holder is ruled out by that dead pid, and every other
|
|
285
|
+
// reclaimer is blocked by the claim -- so the verdict still holds at the moment
|
|
286
|
+
// of the rmSync below.
|
|
287
|
+
//
|
|
288
|
+
// Returns the error to remember for withLock's deadline branch (see
|
|
289
|
+
// ahood-cli#140), or undefined when there is nothing worth remembering.
|
|
290
|
+
function reclaimStaleLock(lockDir, pidFile) {
|
|
291
|
+
const claim = join(lockDir, RECLAIM_CLAIM);
|
|
292
|
+
try {
|
|
293
|
+
mkdirSync(claim);
|
|
294
|
+
}
|
|
295
|
+
catch (error) {
|
|
296
|
+
const code = error.code;
|
|
297
|
+
// EEXIST means another waiter is reclaiming this very directory right now
|
|
298
|
+
// and ENOENT that it is already gone; both resolve themselves on the next
|
|
299
|
+
// retry. Anything else (EACCES/EPERM/EROFS on a directory this process
|
|
300
|
+
// cannot write into) is a reclaim that can never succeed, and is what the
|
|
301
|
+
// deadline branch reports instead of a misleading "waiting for the lock".
|
|
302
|
+
if (code === "EEXIST" || code === "ENOENT")
|
|
303
|
+
return undefined;
|
|
304
|
+
return existsSync(lockDir) ? error : undefined;
|
|
305
|
+
}
|
|
306
|
+
if (!isLockStale(pidFile)) {
|
|
307
|
+
// Not the directory that was judged stale after all -- it was reclaimed and
|
|
308
|
+
// retaken while this process looked, or it belongs to a holder that hasn't
|
|
309
|
+
// written its pid yet. Hand the claim back, leaving the lock exactly as it
|
|
310
|
+
// was found.
|
|
311
|
+
try {
|
|
312
|
+
rmSync(claim, { recursive: true, force: true });
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
// Best effort: the holder's own release takes this directory, claim and
|
|
316
|
+
// all, with it. Worst case is a lock nothing reclaims, which times out
|
|
317
|
+
// with an actionable message rather than corrupting anything -- the same
|
|
318
|
+
// outcome as a process killed between the two syscalls above.
|
|
319
|
+
}
|
|
320
|
+
return undefined;
|
|
321
|
+
}
|
|
322
|
+
try {
|
|
323
|
+
rmSync(lockDir, { recursive: true, force: true });
|
|
324
|
+
}
|
|
325
|
+
catch (error) {
|
|
326
|
+
if (existsSync(lockDir))
|
|
327
|
+
return error;
|
|
328
|
+
}
|
|
329
|
+
return undefined;
|
|
161
330
|
}
|
|
162
331
|
// Simple advisory lock via mkdir's atomicity (EEXIST on a second caller),
|
|
163
332
|
// so two concurrent `ahood skill add`/`remove` invocations against the same
|
|
@@ -177,6 +346,15 @@ export function withLock(path, fn) {
|
|
|
177
346
|
const lockDir = `${path}.lock`;
|
|
178
347
|
const pidFile = join(lockDir, "pid");
|
|
179
348
|
const deadline = Date.now() + 5000;
|
|
349
|
+
// Set when the lock was judged stale but could not actually be removed and
|
|
350
|
+
// that directory is still sitting there -- see the reclaim branch below.
|
|
351
|
+
// Reported instead of the generic timeout, because "another process holds
|
|
352
|
+
// the lock" sends the user hunting for a process that provably no longer
|
|
353
|
+
// exists (ahood-cli#140).
|
|
354
|
+
let reclaimError;
|
|
355
|
+
// Whether this process's own pid ever made it into the lock directory --
|
|
356
|
+
// what lets the release below tell its own lock from a successor's.
|
|
357
|
+
let wrotePid = false;
|
|
180
358
|
for (;;) {
|
|
181
359
|
try {
|
|
182
360
|
mkdirSync(lockDir);
|
|
@@ -185,6 +363,7 @@ export function withLock(path, fn) {
|
|
|
185
363
|
// just without staleness detection for this particular acquisition.
|
|
186
364
|
try {
|
|
187
365
|
writeFileSync(pidFile, String(process.pid));
|
|
366
|
+
wrotePid = true;
|
|
188
367
|
}
|
|
189
368
|
catch {
|
|
190
369
|
// non-fatal, see comment above
|
|
@@ -194,22 +373,54 @@ export function withLock(path, fn) {
|
|
|
194
373
|
catch (error) {
|
|
195
374
|
if (error.code !== "EEXIST")
|
|
196
375
|
throw error;
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
}
|
|
205
|
-
catch {
|
|
206
|
-
// lost the race -- fall through to wait/retry
|
|
207
|
-
}
|
|
208
|
-
continue;
|
|
209
|
-
}
|
|
376
|
+
// The deadline is tested FIRST so that every retry path is bounded by
|
|
377
|
+
// it. The reclaim branch below used to `continue` straight past both
|
|
378
|
+
// this check and the sleep, so a stale lock this process could not
|
|
379
|
+
// actually remove (rmSync's force:true suppresses only ENOENT, never
|
|
380
|
+
// EACCES/EPERM/EROFS) turned into an unbounded 100%-CPU spin that never
|
|
381
|
+
// threw and never printed anything -- the one thing a CLI built to run
|
|
382
|
+
// unattended in CI must never do (ahood-cli#140).
|
|
210
383
|
if (Date.now() > deadline) {
|
|
384
|
+
// The remembered failure is re-confirmed here rather than trusted,
|
|
385
|
+
// because a process that DOES have permission (a root/owner ahood in
|
|
386
|
+
// another terminal) can clear that directory and take the lock for
|
|
387
|
+
// real while this one waits -- and "the process that held it is gone"
|
|
388
|
+
// would then be a confident lie about a lock somebody is legitimately
|
|
389
|
+
// holding. Re-confirming asks exactly what the message claims: is
|
|
390
|
+
// there still a directory, and is nobody alive holding it? Identity
|
|
391
|
+
// (inode, birthtime) deliberately isn't used for this: a removed and
|
|
392
|
+
// immediately recreated directory routinely gets the same inode back,
|
|
393
|
+
// so it can't tell a replacement from the original anyway.
|
|
394
|
+
if (reclaimError && existsSync(lockDir) && !isLockHeldByLiveProcess(pidFile)) {
|
|
395
|
+
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).`);
|
|
396
|
+
}
|
|
211
397
|
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
398
|
}
|
|
399
|
+
if (isLockStale(pidFile)) {
|
|
400
|
+
// Reclaim it. Two very different conditions can fail that, and only
|
|
401
|
+
// one of them is the race the old comment here claimed: if the
|
|
402
|
+
// directory is GONE afterwards, a concurrent process reclaimed it (or
|
|
403
|
+
// a live holder released it) first, which is harmless -- the retry
|
|
404
|
+
// below simply takes the lock. If it is still THERE, this process
|
|
405
|
+
// cannot remove it at all, and retrying can only fail the same way,
|
|
406
|
+
// so the failure is remembered for the deadline branch above rather
|
|
407
|
+
// than swallowed (ahood-cli#140). It is remembered rather than thrown
|
|
408
|
+
// immediately because a mid-reclaim race can also surface as e.g.
|
|
409
|
+
// ENOTEMPTY with the directory still present, and that one does clear
|
|
410
|
+
// itself on the next attempt.
|
|
411
|
+
//
|
|
412
|
+
// The memory deliberately survives later iterations (hence `??`
|
|
413
|
+
// rather than a plain assignment): a failed recursive removal can take
|
|
414
|
+
// the `pid` file with it (it deletes the contents, then fails on the
|
|
415
|
+
// directory itself -- which of the two happens before the error is an
|
|
416
|
+
// implementation detail that differs between Node versions), and
|
|
417
|
+
// without a pid file isLockStale reports "not stale", so this branch
|
|
418
|
+
// never runs again and there is no second chance to observe the
|
|
419
|
+
// failure. Clearing it per-iteration made the error message depend on
|
|
420
|
+
// that detail, which is why it read correctly on one machine and fell
|
|
421
|
+
// back to the generic timeout on another (ahood-cli#145).
|
|
422
|
+
reclaimError = reclaimStaleLock(lockDir, pidFile) ?? reclaimError;
|
|
423
|
+
}
|
|
213
424
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
|
|
214
425
|
}
|
|
215
426
|
}
|
|
@@ -217,7 +428,30 @@ export function withLock(path, fn) {
|
|
|
217
428
|
return fn();
|
|
218
429
|
}
|
|
219
430
|
finally {
|
|
220
|
-
rmSync
|
|
431
|
+
// Releasing used to be an unconditional rmSync, which is only correct for
|
|
432
|
+
// as long as this process still holds the lock it took -- and it does not
|
|
433
|
+
// always. A reclaimer that judged this process dead can delete this lock
|
|
434
|
+
// out from under a running critical section (ahood-cli#141 on one host,
|
|
435
|
+
// and the host-local pid probe above across a container bind mount or a
|
|
436
|
+
// network share), and the unconditional removal then took the SUCCESSOR's
|
|
437
|
+
// lock down with it, turning one lost update into a chain of them.
|
|
438
|
+
// Removing only a directory that still records THIS process's pid keeps
|
|
439
|
+
// the damage to the process that was wronged.
|
|
440
|
+
//
|
|
441
|
+
// A pid file that is unreadable despite having been written is left alone
|
|
442
|
+
// for the same reason: the only thing that removes it is a reclaimer
|
|
443
|
+
// partway through taking this lock, so whatever is at that path is no
|
|
444
|
+
// longer this process's to delete.
|
|
445
|
+
//
|
|
446
|
+
// With no pid of this process's own ever written (the best-effort write
|
|
447
|
+
// above failed) there is nothing to compare against -- and nothing to
|
|
448
|
+
// protect either, since a lock directory with no readable pid is never
|
|
449
|
+
// judged stale and so can never have been reclaimed. Removing it
|
|
450
|
+
// unconditionally is then both safe and necessary: leaving it behind
|
|
451
|
+
// strands a lock that nothing is able to reclaim.
|
|
452
|
+
if (!wrotePid || readLockPid(pidFile) === process.pid) {
|
|
453
|
+
rmSync(lockDir, { recursive: true, force: true });
|
|
454
|
+
}
|
|
221
455
|
}
|
|
222
456
|
}
|
|
223
457
|
export function writeLockfileEntry(path, ownerSlashSkill, entry) {
|
|
@@ -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
|
+
}
|
package/package.json
CHANGED