pi-session-mcp 0.8.0 → 0.8.1
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/.pi-session-mcp-cli/doctor.js +14 -7
- package/.pi-session-mcp-cli/override-lock.js +125 -0
- package/.pi-session-mcp-cli/setup-platform.js +195 -0
- package/.pi-session-mcp-cli/skill-customize.js +131 -46
- package/.pi-session-mcp-cli/skill-library.js +72 -4
- package/CHANGELOG.md +91 -0
- package/README.md +1 -1
- package/dist/doctor.d.ts +3 -0
- package/dist/doctor.js +14 -7
- package/dist/override-lock.d.ts +34 -0
- package/dist/override-lock.js +125 -0
- package/dist/pi-adapter.d.ts +6 -4
- package/dist/sdk-pi-adapter.js +28 -5
- package/dist/session-registry.d.ts +8 -6
- package/dist/session-registry.js +3 -2
- package/dist/setup-ownership.d.ts +4 -1
- package/dist/setup-ownership.js +6 -3
- package/dist/setup-skills.d.ts +40 -1
- package/dist/setup-skills.js +311 -78
- package/dist/skill-customize.d.ts +19 -1
- package/dist/skill-customize.js +131 -46
- package/dist/skill-library.d.ts +46 -1
- package/dist/skill-library.js +72 -4
- package/docs/acceptance.md +365 -15
- package/docs/adr/0007-profile-skill-selection.md +11 -0
- package/docs/architecture.md +1 -1
- package/docs/client-setup-design.md +106 -20
- package/docs/client-setup.md +19 -7
- package/docs/doctor-design.md +1 -1
- package/docs/installation.md +8 -2
- package/docs/pi-extensions-design.md +11 -0
- package/docs/releasing.md +18 -9
- package/docs/skills.md +145 -16
- package/docs/threat-model.md +3 -3
- package/docs/tool-contracts.md +2 -2
- package/package.json +1 -1
- package/skills/pi-session-orchestration/SKILL.md +5 -4
|
@@ -6,7 +6,7 @@ import { PiExtensionError } from "./extension-spec.js";
|
|
|
6
6
|
import { readPackageMetadata } from "./package-metadata.js";
|
|
7
7
|
import { MAX_SKILL_FILE_BYTES } from "./skill-catalog.js";
|
|
8
8
|
import { readSkillBases, SkillBasesError } from "./skill-customize.js";
|
|
9
|
-
import { readSkillTree, shippedSkillsDirectory } from "./skill-library.js";
|
|
9
|
+
import { LEGACY_SKILL_TREE_FINGERPRINT_PATTERN, readSkillTree, releasedSkillFingerprint, shippedSkillsDirectory } from "./skill-library.js";
|
|
10
10
|
const defaultFileSystem = { readFile, stat, access };
|
|
11
11
|
const CHECK_ORDER = ["node_version_unsupported", "package_metadata_unreadable", "build_missing", "build_stale", "version_mismatch", "config_missing", "config_unreadable", "config_invalid", "workspace_missing", "workspace_not_directory", "workspace_unreadable", "workspace_not_writable", "extension_missing", "extension_version_mismatch", "skill_missing", "skill_unreadable", "skill_duplicate", "skill_override_outdated"];
|
|
12
12
|
function failure(id, subject, remediation) { return Object.freeze({ id, severity: "error", subject, remediation }); }
|
|
@@ -67,9 +67,10 @@ async function productionBuildInputMtimes(root) {
|
|
|
67
67
|
}
|
|
68
68
|
/**
|
|
69
69
|
* Compares the shipped skills with the fingerprints recorded when the operator copied them.
|
|
70
|
-
* Reads only the bases file and the shipped files
|
|
70
|
+
* Reads only the bases file and the shipped files, takes no lock and writes nothing: the bases
|
|
71
|
+
* file is replaced atomically, so a read sees either the old or the new content.
|
|
71
72
|
*/
|
|
72
|
-
async function skillOverrideChecks(overrideDirectory, shippedDirectory) {
|
|
73
|
+
async function skillOverrideChecks(overrideDirectory, shippedDirectory, released) {
|
|
73
74
|
if (overrideDirectory === undefined)
|
|
74
75
|
return [];
|
|
75
76
|
let bases;
|
|
@@ -90,9 +91,15 @@ async function skillOverrideChecks(overrideDirectory, shippedDirectory) {
|
|
|
90
91
|
catch {
|
|
91
92
|
continue;
|
|
92
93
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
94
|
+
const command = `pi-session-mcp-setup --apply --customize-skill ${name}`;
|
|
95
|
+
// A legacy base is meaningful only as the version 2 value of the released original it names.
|
|
96
|
+
const recorded = LEGACY_SKILL_TREE_FINGERPRINT_PATTERN.test(base) ? releasedSkillFingerprint(name, base, released) : base;
|
|
97
|
+
if (recorded === undefined)
|
|
98
|
+
checks.push(warning("skill_override_outdated", `skill:${name}`, `the recorded base predates version 2 fingerprints and cannot be confirmed; review the shipped skill, then run ${command}`));
|
|
99
|
+
else
|
|
100
|
+
checks.push(recorded === fingerprint
|
|
101
|
+
? ok("skill_override_outdated", `skill:${name}`)
|
|
102
|
+
: warning("skill_override_outdated", `skill:${name}`, `review the changed shipped skill, then run ${command}`));
|
|
96
103
|
}
|
|
97
104
|
return checks;
|
|
98
105
|
}
|
|
@@ -260,7 +267,7 @@ export async function runDoctor(dependencies = {}) {
|
|
|
260
267
|
}
|
|
261
268
|
}
|
|
262
269
|
if (config)
|
|
263
|
-
checks.push(...await skillOverrideChecks(config.skillOverrideDirectory, shippedSkillsDirectory(dependencies.packageRoot)));
|
|
270
|
+
checks.push(...await skillOverrideChecks(config.skillOverrideDirectory, shippedSkillsDirectory(dependencies.packageRoot), dependencies.releasedSkillTrees));
|
|
264
271
|
const rank = new Map(CHECK_ORDER.map((id, index) => [id, index]));
|
|
265
272
|
checks.sort((a, b) => (rank.get(a.id) ?? 999) - (rank.get(b.id) ?? 999) || a.subject.localeCompare(b.subject));
|
|
266
273
|
const hasError = checks.some((check) => check.severity === "error");
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { lstat, open } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { assertSupportedFilesystem, currentSetupPlatform, isTrustedSystemBinary, openLockedDarwin } from "./setup-platform.js";
|
|
6
|
+
/**
|
|
7
|
+
* The kernel lock that serializes every read-modify-write of the skill bases file across
|
|
8
|
+
* processes. It lives on a stable lock file, never on the bases file: that file is replaced by a
|
|
9
|
+
* rename, which would move the lock onto an unlinked inode. The lock file is never deleted, and a
|
|
10
|
+
* holder that dies releases the lock through the kernel.
|
|
11
|
+
*/
|
|
12
|
+
export const SKILL_BASES_LOCK_FILE = ".pi-session-mcp-bases.lock";
|
|
13
|
+
export const DEFAULT_OVERRIDE_LOCK_TIMEOUT_MS = 30_000;
|
|
14
|
+
const MIN_LOCK_TIMEOUT_MS = 50;
|
|
15
|
+
const MAX_LOCK_TIMEOUT_MS = 120_000;
|
|
16
|
+
/** Exit status of `flock -E` when the lock stayed busy for the whole wait. */
|
|
17
|
+
const FLOCK_BUSY_EXIT = 75;
|
|
18
|
+
const FLOCK_CANDIDATES = ["/usr/bin/flock", "/bin/flock"];
|
|
19
|
+
export class OverrideLockError extends Error {
|
|
20
|
+
code;
|
|
21
|
+
constructor(code) {
|
|
22
|
+
super("Skill override lock is not available");
|
|
23
|
+
this.code = code;
|
|
24
|
+
this.name = "OverrideLockError";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Takes the exclusive lock for one override directory, which must exist. Fails closed: an
|
|
29
|
+
* unsupported platform, a missing trusted `flock`, an unsupported filesystem, a link or non-file
|
|
30
|
+
* at the lock path and a descriptor that no longer names the path all end in
|
|
31
|
+
* `override_lock_unavailable`.
|
|
32
|
+
*/
|
|
33
|
+
export async function acquireOverrideLock(overrideDirectory, options = {}) {
|
|
34
|
+
const platform = currentSetupPlatform(options.platform);
|
|
35
|
+
if (!platform)
|
|
36
|
+
throw new OverrideLockError("override_lock_unavailable");
|
|
37
|
+
const timeoutMs = Number.isFinite(options.timeoutMs) ? Math.max(MIN_LOCK_TIMEOUT_MS, Math.min(options.timeoutMs, MAX_LOCK_TIMEOUT_MS)) : DEFAULT_OVERRIDE_LOCK_TIMEOUT_MS;
|
|
38
|
+
const flockPath = platform === "linux" ? flockBinary(options) : undefined;
|
|
39
|
+
if (platform === "linux" && flockPath === undefined)
|
|
40
|
+
throw new OverrideLockError("override_lock_unavailable");
|
|
41
|
+
try {
|
|
42
|
+
await assertSupportedFilesystem(overrideDirectory, platform, options.readStatfs);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
throw new OverrideLockError("override_lock_unavailable");
|
|
46
|
+
}
|
|
47
|
+
const lockPath = join(overrideDirectory, SKILL_BASES_LOCK_FILE);
|
|
48
|
+
let handle;
|
|
49
|
+
try {
|
|
50
|
+
// macOS takes the kernel lock at open time; on Linux the trusted flock locks this very
|
|
51
|
+
// open file description, so the lock outlives the flock process and ends with this descriptor.
|
|
52
|
+
handle = await openLockFile(lockPath, platform, timeoutMs, options.signal);
|
|
53
|
+
if (flockPath !== undefined)
|
|
54
|
+
await lockWithFlock(flockPath, handle.fd, timeoutMs, options.signal);
|
|
55
|
+
// The path may have been replaced while waiting: the lock only counts on the file it names.
|
|
56
|
+
const [descriptor, linked] = await Promise.all([handle.stat(), lstat(lockPath)]);
|
|
57
|
+
if (!descriptor.isFile() || !linked.isFile() || descriptor.dev !== linked.dev || descriptor.ino !== linked.ino)
|
|
58
|
+
throw new OverrideLockError("override_lock_unavailable");
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
await handle?.close().catch(() => undefined);
|
|
62
|
+
throw error instanceof OverrideLockError ? error : new OverrideLockError("override_lock_unavailable");
|
|
63
|
+
}
|
|
64
|
+
const held = handle;
|
|
65
|
+
let released = false;
|
|
66
|
+
return {
|
|
67
|
+
async release() {
|
|
68
|
+
if (released)
|
|
69
|
+
return;
|
|
70
|
+
released = true;
|
|
71
|
+
await held.close().catch(() => undefined);
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function flockBinary(options) {
|
|
76
|
+
const trusted = options.isTrustedBinary ?? ((path) => isTrustedSystemBinary(path));
|
|
77
|
+
return (options.flockCandidates ?? FLOCK_CANDIDATES).find((candidate) => trusted(candidate));
|
|
78
|
+
}
|
|
79
|
+
async function openLockFile(lockPath, platform, timeoutMs, signal) {
|
|
80
|
+
try {
|
|
81
|
+
if (platform === "darwin")
|
|
82
|
+
return await openLockedDarwin(lockPath, { timeoutMs, ...(signal ? { signal } : {}) });
|
|
83
|
+
return await open(lockPath, constants.O_CREAT | constants.O_RDWR | constants.O_NOFOLLOW, 0o600);
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
throw new OverrideLockError(error.message === "ownership_lock_busy" ? "override_lock_busy" : "override_lock_unavailable");
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function lockWithFlock(flockPath, lockFd, timeoutMs, signal) {
|
|
90
|
+
return new Promise((resolve, reject) => {
|
|
91
|
+
if (signal?.aborted) {
|
|
92
|
+
reject(new OverrideLockError("override_lock_unavailable"));
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
let locker;
|
|
96
|
+
try {
|
|
97
|
+
// flock parses fractional seconds with the locale's decimal separator, so only whole
|
|
98
|
+
// seconds go on the command line. The exact bound is the timer below, which stops flock.
|
|
99
|
+
const wholeSeconds = String(Math.max(1, Math.ceil(timeoutMs / 1_000)));
|
|
100
|
+
locker = spawn(flockPath, ["-E", String(FLOCK_BUSY_EXIT), "-w", wholeSeconds, "3"], { stdio: ["ignore", "ignore", "ignore", lockFd], detached: true });
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
reject(new OverrideLockError("override_lock_unavailable"));
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
let expired = false;
|
|
107
|
+
const stop = () => { if (locker.pid !== undefined) {
|
|
108
|
+
try {
|
|
109
|
+
process.kill(-locker.pid, "SIGKILL");
|
|
110
|
+
}
|
|
111
|
+
catch { /* already gone */ }
|
|
112
|
+
} };
|
|
113
|
+
const cleanup = () => { clearTimeout(bound); signal?.removeEventListener("abort", stop); };
|
|
114
|
+
const bound = setTimeout(() => { expired = true; stop(); }, timeoutMs);
|
|
115
|
+
signal?.addEventListener("abort", stop, { once: true });
|
|
116
|
+
locker.once("error", () => { cleanup(); reject(new OverrideLockError("override_lock_unavailable")); });
|
|
117
|
+
locker.once("close", (code) => {
|
|
118
|
+
cleanup();
|
|
119
|
+
if (code === 0)
|
|
120
|
+
resolve();
|
|
121
|
+
else
|
|
122
|
+
reject(new OverrideLockError(code === FLOCK_BUSY_EXIT || expired ? "override_lock_busy" : "override_lock_unavailable"));
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { constants, readdirSync, readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { open, statfs } from "node:fs/promises";
|
|
4
|
+
export function currentSetupPlatform(platform = process.platform) {
|
|
5
|
+
return platform === "linux" || platform === "darwin" ? platform : undefined;
|
|
6
|
+
}
|
|
7
|
+
/** A regular, root-owned executable that no group or other user can replace. */
|
|
8
|
+
export function isTrustedSystemBinary(path, statFile = statSync) {
|
|
9
|
+
try {
|
|
10
|
+
const file = statFile(path);
|
|
11
|
+
return file.isFile() && file.uid === 0 && (file.mode & 0o022) === 0 && (file.mode & 0o111) !== 0;
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function readLinuxProcessGroup(pid) {
|
|
18
|
+
const line = readFileSync(`/proc/${pid}/stat`, "utf8").trim();
|
|
19
|
+
const close = line.lastIndexOf(") ");
|
|
20
|
+
const state = line[close + 2];
|
|
21
|
+
if (close < 3 || !state || !/^[A-Za-z]$/.test(state) || line[close + 3] !== " ")
|
|
22
|
+
throw new Error("process_stat_invalid");
|
|
23
|
+
const fields = line.slice(close + 4).trim().split(/\s+/);
|
|
24
|
+
const group = Number(fields[1]);
|
|
25
|
+
if (!Number.isSafeInteger(group) || group < 0)
|
|
26
|
+
throw new Error("process_group_invalid");
|
|
27
|
+
return { group, state };
|
|
28
|
+
}
|
|
29
|
+
function linuxGroupState(group) {
|
|
30
|
+
let entries;
|
|
31
|
+
try {
|
|
32
|
+
entries = readdirSync("/proc", { withFileTypes: true });
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return "unknown";
|
|
36
|
+
}
|
|
37
|
+
for (const entry of entries) {
|
|
38
|
+
if (!entry.isDirectory() || !/^\d+$/.test(entry.name))
|
|
39
|
+
continue;
|
|
40
|
+
try {
|
|
41
|
+
const sample = readLinuxProcessGroup(Number(entry.name));
|
|
42
|
+
if (sample.group === group && sample.state !== "Z")
|
|
43
|
+
return "alive";
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
if (error.code !== "ENOENT")
|
|
47
|
+
return "unknown";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return "gone";
|
|
51
|
+
}
|
|
52
|
+
function linuxTableReadable(selfPid) {
|
|
53
|
+
let sawSelf = false;
|
|
54
|
+
try {
|
|
55
|
+
for (const entry of readdirSync("/proc", { withFileTypes: true })) {
|
|
56
|
+
if (!entry.isDirectory() || !/^\d+$/.test(entry.name))
|
|
57
|
+
continue;
|
|
58
|
+
try {
|
|
59
|
+
const pid = Number(entry.name);
|
|
60
|
+
readLinuxProcessGroup(pid);
|
|
61
|
+
if (pid === selfPid)
|
|
62
|
+
sawSelf = true;
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
if (error.code !== "ENOENT")
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
return sawSelf;
|
|
74
|
+
}
|
|
75
|
+
export const DARWIN_PS_PATH = "/bin/ps";
|
|
76
|
+
const PS_ARGUMENTS = ["-A", "-o", "pid=,pgid=,stat="];
|
|
77
|
+
const PS_TIMEOUT_MS = 2_000;
|
|
78
|
+
const PS_MAX_OUTPUT_BYTES = 1_048_576;
|
|
79
|
+
/** Parses `ps -A -o pid=,pgid=,stat=`; any unexpected line rejects the whole sample. */
|
|
80
|
+
export function parsePsTable(output) {
|
|
81
|
+
const body = output.endsWith("\n") ? output.slice(0, -1) : output;
|
|
82
|
+
if (body.length === 0)
|
|
83
|
+
return undefined;
|
|
84
|
+
const entries = [];
|
|
85
|
+
for (const line of body.split("\n")) {
|
|
86
|
+
const match = /^\s*(\d+)\s+(\d+)\s+(\S+)\s*$/.exec(line);
|
|
87
|
+
if (!match)
|
|
88
|
+
return undefined;
|
|
89
|
+
const pid = Number(match[1]);
|
|
90
|
+
const group = Number(match[2]);
|
|
91
|
+
if (!Number.isSafeInteger(pid) || !Number.isSafeInteger(group))
|
|
92
|
+
return undefined;
|
|
93
|
+
entries.push({ pid, group, state: match[3] });
|
|
94
|
+
}
|
|
95
|
+
return entries;
|
|
96
|
+
}
|
|
97
|
+
function runDarwinPs() {
|
|
98
|
+
if (!isTrustedSystemBinary(DARWIN_PS_PATH))
|
|
99
|
+
return Promise.resolve(undefined);
|
|
100
|
+
return new Promise((resolve) => {
|
|
101
|
+
try {
|
|
102
|
+
execFile(DARWIN_PS_PATH, [...PS_ARGUMENTS], { env: { LC_ALL: "C" }, timeout: PS_TIMEOUT_MS, maxBuffer: PS_MAX_OUTPUT_BYTES, encoding: "utf8" }, (error, stdout) => {
|
|
103
|
+
// A non-zero exit, timeout, output overflow or spawn failure is indeterminate.
|
|
104
|
+
resolve(error ? undefined : { exitCode: 0, stdout });
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
resolve(undefined);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
export function createProcessTable(platform, dependencies = {}) {
|
|
113
|
+
const selfPid = dependencies.selfPid ?? process.pid;
|
|
114
|
+
if (platform === "linux") {
|
|
115
|
+
return { pollMs: 10, groupState: async (group) => linuxGroupState(group), readable: async () => linuxTableReadable(selfPid) };
|
|
116
|
+
}
|
|
117
|
+
const runPs = dependencies.runPs ?? runDarwinPs;
|
|
118
|
+
const scan = async () => {
|
|
119
|
+
const output = await runPs();
|
|
120
|
+
if (!output || output.exitCode !== 0)
|
|
121
|
+
return undefined;
|
|
122
|
+
const entries = parsePsTable(output.stdout);
|
|
123
|
+
// A sample without the caller cannot be a complete process table.
|
|
124
|
+
return entries?.some((entry) => entry.pid === selfPid) ? entries : undefined;
|
|
125
|
+
};
|
|
126
|
+
return {
|
|
127
|
+
pollMs: 50,
|
|
128
|
+
async groupState(group) {
|
|
129
|
+
const entries = await scan();
|
|
130
|
+
if (!entries)
|
|
131
|
+
return "unknown";
|
|
132
|
+
return entries.some((entry) => entry.group === group && !entry.state.startsWith("Z")) ? "alive" : "gone";
|
|
133
|
+
},
|
|
134
|
+
async readable() { return (await scan()) !== undefined; },
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
// Kernel flock and directory fsync semantics are accepted only on the local
|
|
138
|
+
// Linux filesystems covered by the setup test matrix.
|
|
139
|
+
const SUPPORTED_LINUX_FILESYSTEMS = new Set([
|
|
140
|
+
0xef53, // ext2/3/4
|
|
141
|
+
0x58465342, // XFS
|
|
142
|
+
0x9123683e, // Btrfs
|
|
143
|
+
0x01021994, // tmpfs
|
|
144
|
+
0x794c7630, // overlayfs
|
|
145
|
+
0x2fc12fc1, // ZFS
|
|
146
|
+
0xf2f52010, // F2FS
|
|
147
|
+
0x24051905, // UBIFS
|
|
148
|
+
0xca451a4e, // bcachefs
|
|
149
|
+
]);
|
|
150
|
+
export async function assertSupportedFilesystem(path, platform, readStatfs = (target) => statfs(target)) {
|
|
151
|
+
const filesystem = await readStatfs(path);
|
|
152
|
+
if (platform === "linux") {
|
|
153
|
+
if (!SUPPORTED_LINUX_FILESYSTEMS.has(filesystem.type))
|
|
154
|
+
throw new Error("ownership_filesystem_unsupported");
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
// macOS assigns f_type dynamically, so the directory must share the type of
|
|
158
|
+
// `/`, the sealed APFS system volume on every macOS that Node.js 22 supports.
|
|
159
|
+
if (filesystem.type !== (await readStatfs("/")).type)
|
|
160
|
+
throw new Error("ownership_filesystem_unsupported");
|
|
161
|
+
}
|
|
162
|
+
/** From XNU `bsd/sys/fcntl.h`; Node's `fs.constants` does not export these. */
|
|
163
|
+
export const DARWIN_O_NONBLOCK = 0x4;
|
|
164
|
+
export const DARWIN_O_EXLOCK = 0x20;
|
|
165
|
+
/**
|
|
166
|
+
* Opens the lock file with an exclusive lock on its open file description,
|
|
167
|
+
* which has flock(2) semantics on macOS. A busy lock is retried until the
|
|
168
|
+
* acquisition bound; a failed attempt holds no descriptor.
|
|
169
|
+
*/
|
|
170
|
+
export async function openLockedDarwin(path, options) {
|
|
171
|
+
const openFile = options.openFile ?? ((target, flags, mode) => open(target, flags, mode));
|
|
172
|
+
const flags = constants.O_CREAT | constants.O_RDWR | constants.O_NOFOLLOW | DARWIN_O_EXLOCK | DARWIN_O_NONBLOCK;
|
|
173
|
+
const boundMs = Number.isFinite(options.timeoutMs) ? Math.max(1_000, Math.min(options.timeoutMs, 120_000)) : 120_000;
|
|
174
|
+
const deadline = Date.now() + boundMs;
|
|
175
|
+
const retryMs = options.retryMs ?? 50;
|
|
176
|
+
while (true) {
|
|
177
|
+
if (options.signal?.aborted)
|
|
178
|
+
throw new Error("operation_aborted");
|
|
179
|
+
try {
|
|
180
|
+
return await openFile(path, flags, 0o600);
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
const code = error.code;
|
|
184
|
+
if (code !== "EAGAIN" && code !== "EWOULDBLOCK")
|
|
185
|
+
throw new Error("ownership_lock_unavailable");
|
|
186
|
+
}
|
|
187
|
+
if (Date.now() >= deadline)
|
|
188
|
+
throw new Error("ownership_lock_busy");
|
|
189
|
+
await new Promise((resolve) => {
|
|
190
|
+
const done = () => { clearTimeout(timer); options.signal?.removeEventListener("abort", done); resolve(); };
|
|
191
|
+
const timer = setTimeout(done, retryMs);
|
|
192
|
+
options.signal?.addEventListener("abort", done, { once: true });
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -1,18 +1,24 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
2
|
import { constants } from "node:fs";
|
|
3
|
-
import { lstat, mkdir, open, readFile, rename, rm, unlink
|
|
3
|
+
import { lstat, mkdir, open, readFile, rename, rm, unlink } from "node:fs/promises";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
|
+
import { acquireOverrideLock, OverrideLockError } from "./override-lock.js";
|
|
5
6
|
import { result } from "./setup-result.js";
|
|
6
|
-
import { readSkillTree, shippedSkillsDirectory, SKILL_NAME_PATTERN, SkillTreeError, skillOverrideDirectory } from "./skill-library.js";
|
|
7
|
+
import { LEGACY_SKILL_TREE_FINGERPRINT_PATTERN, readSkillTree, releasedSkillFingerprint, shippedSkillsDirectory, SKILL_NAME_PATTERN, SKILL_TREE_FINGERPRINT_PATTERN, SkillTreeError, skillOverrideDirectory, } from "./skill-library.js";
|
|
7
8
|
/**
|
|
8
9
|
* `--customize-skill <name>`: copies a shipped skill into the operator's override directory as a
|
|
9
10
|
* starting point, and records the shipped fingerprint it was based on. The operator's files are
|
|
10
11
|
* never overwritten or edited; a changed original is only reported and acknowledged by moving
|
|
11
12
|
* the recorded base. Reports carry the skill name and a code, never a path or file content.
|
|
13
|
+
*
|
|
14
|
+
* Applying is a cross-process transaction: everything from reading the bases file to replacing it
|
|
15
|
+
* runs under the kernel lock of `override-lock.ts`, so two runs for different skills cannot lose
|
|
16
|
+
* each other's base. A dry run takes no lock and writes nothing.
|
|
12
17
|
*/
|
|
13
18
|
export const SKILL_BASES_FILE = ".pi-session-mcp-bases.json";
|
|
14
19
|
export const MAX_SKILL_BASES_BYTES = 64 * 1024;
|
|
15
|
-
|
|
20
|
+
/** Written by this version; version 1 (0.8.0) holds only legacy fingerprints and is still read. */
|
|
21
|
+
const BASES_SCHEMA_VERSION = 2;
|
|
16
22
|
export class SkillBasesError extends Error {
|
|
17
23
|
constructor() {
|
|
18
24
|
super("Skill bases file is not usable");
|
|
@@ -46,13 +52,17 @@ export async function readSkillBases(overrideDirectory) {
|
|
|
46
52
|
catch {
|
|
47
53
|
throw new SkillBasesError();
|
|
48
54
|
}
|
|
49
|
-
if (!isPlainObject(parsed) || Object.keys(parsed).sort().join() !== "bases,schemaVersion"
|
|
55
|
+
if (!isPlainObject(parsed) || Object.keys(parsed).sort().join() !== "bases,schemaVersion")
|
|
56
|
+
throw new SkillBasesError();
|
|
57
|
+
const version = parsed.schemaVersion;
|
|
58
|
+
if (version !== 1 && version !== BASES_SCHEMA_VERSION)
|
|
50
59
|
throw new SkillBasesError();
|
|
51
60
|
const bases = parsed.bases;
|
|
52
61
|
if (!isPlainObject(bases))
|
|
53
62
|
throw new SkillBasesError();
|
|
54
63
|
const entries = Object.entries(bases);
|
|
55
|
-
|
|
64
|
+
const validValue = (value) => typeof value === "string" && (LEGACY_SKILL_TREE_FINGERPRINT_PATTERN.test(value) || (version === BASES_SCHEMA_VERSION && SKILL_TREE_FINGERPRINT_PATTERN.test(value)));
|
|
65
|
+
if (!entries.every(([name, value]) => SKILL_NAME_PATTERN.test(name) && validValue(value)))
|
|
56
66
|
throw new SkillBasesError();
|
|
57
67
|
return Object.freeze(Object.fromEntries(entries));
|
|
58
68
|
}
|
|
@@ -62,10 +72,25 @@ function isPlainObject(value) {
|
|
|
62
72
|
const prototype = Object.getPrototypeOf(value);
|
|
63
73
|
return prototype === Object.prototype || prototype === null;
|
|
64
74
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
75
|
+
async function syncDirectory(path) {
|
|
76
|
+
const handle = await open(path, "r");
|
|
77
|
+
try {
|
|
78
|
+
await handle.sync();
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
await handle.close();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Atomic replacement: a private temporary file in the same directory, fsynced, then a rename and a
|
|
86
|
+
* directory fsync. Every provable legacy entry is migrated to its version 2 value on the way; an
|
|
87
|
+
* entry that no released tree names stays verbatim. A failure before the rename leaves the
|
|
88
|
+
* previous file untouched.
|
|
89
|
+
*/
|
|
90
|
+
async function writeSkillBases(overrideDirectory, bases, released, beforeRename) {
|
|
91
|
+
const migrated = Object.entries(bases).map(([name, value]) => [name, LEGACY_SKILL_TREE_FINGERPRINT_PATTERN.test(value) ? releasedSkillFingerprint(name, value, released) ?? value : value]);
|
|
92
|
+
const sorted = Object.fromEntries(migrated.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)));
|
|
93
|
+
const content = Buffer.from(`${JSON.stringify({ schemaVersion: BASES_SCHEMA_VERSION, bases: sorted })}\n`, "utf8");
|
|
69
94
|
if (content.length > MAX_SKILL_BASES_BYTES)
|
|
70
95
|
throw new SkillBasesError();
|
|
71
96
|
const temporary = join(overrideDirectory, `.${SKILL_BASES_FILE.slice(1)}.${randomBytes(6).toString("hex")}.tmp`);
|
|
@@ -78,12 +103,14 @@ async function writeSkillBases(overrideDirectory, bases) {
|
|
|
78
103
|
finally {
|
|
79
104
|
await handle.close();
|
|
80
105
|
}
|
|
106
|
+
await beforeRename?.();
|
|
81
107
|
await rename(temporary, join(overrideDirectory, SKILL_BASES_FILE));
|
|
82
108
|
}
|
|
83
109
|
catch (error) {
|
|
84
110
|
await unlink(temporary).catch(() => undefined);
|
|
85
111
|
throw error;
|
|
86
112
|
}
|
|
113
|
+
await syncDirectory(overrideDirectory);
|
|
87
114
|
}
|
|
88
115
|
function isMissing(error) {
|
|
89
116
|
return typeof error === "object" && error !== null && error.code === "ENOENT";
|
|
@@ -96,7 +123,40 @@ function report(request, status, code) {
|
|
|
96
123
|
// A planned change reports `planned` overall, like a registration dry-run.
|
|
97
124
|
return result(request.apply ? "apply" : "dry-run", [finding], status === "planned" ? "planned" : undefined);
|
|
98
125
|
}
|
|
99
|
-
|
|
126
|
+
const OUTDATED = Object.freeze({ kind: "record", planned: "skill_override_outdated", applied: "skill_base_updated" });
|
|
127
|
+
/** Pure with respect to the library: reads the override tree, never changes anything. */
|
|
128
|
+
async function decide(name, overridePath, shipped, bases, released) {
|
|
129
|
+
try {
|
|
130
|
+
if (!(await lstat(overridePath)).isDirectory())
|
|
131
|
+
return { kind: "failed", code: "skill_override_invalid" };
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
if (!isMissing(error))
|
|
135
|
+
throw error;
|
|
136
|
+
return { kind: "copy", planned: "skill_customize_planned", applied: "skill_customized" };
|
|
137
|
+
}
|
|
138
|
+
let override;
|
|
139
|
+
try {
|
|
140
|
+
override = await readSkillTree(overridePath);
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
if (error instanceof SkillTreeError)
|
|
144
|
+
return { kind: "failed", code: "skill_override_invalid" };
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
const base = Object.hasOwn(bases, name) ? bases[name] : undefined;
|
|
148
|
+
// Without a base an identical override is a customization whose bases commit failed earlier.
|
|
149
|
+
if (base === undefined)
|
|
150
|
+
return override.fingerprint === shipped.fingerprint ? { kind: "record", planned: "skill_customize_planned", applied: "skill_customized" } : OUTDATED;
|
|
151
|
+
if (SKILL_TREE_FINGERPRINT_PATTERN.test(base))
|
|
152
|
+
return base === shipped.fingerprint ? { kind: "current" } : OUTDATED;
|
|
153
|
+
// A legacy base only names a released original when a trusted reference tree has that value.
|
|
154
|
+
const original = releasedSkillFingerprint(name, base, released);
|
|
155
|
+
if (original === undefined)
|
|
156
|
+
return { kind: "record", planned: "skill_base_unverified", applied: "skill_base_updated" };
|
|
157
|
+
return original === shipped.fingerprint ? { kind: "record", planned: "skill_base_migration_planned", applied: "skill_base_migrated" } : OUTDATED;
|
|
158
|
+
}
|
|
159
|
+
export async function customizeSkill(request, dependencies = {}) {
|
|
100
160
|
const done = (status, code) => report(request, status, code);
|
|
101
161
|
if (!SKILL_NAME_PATTERN.test(request.name))
|
|
102
162
|
return done("failed", "skill_not_shipped");
|
|
@@ -112,69 +172,94 @@ export async function customizeSkill(request) {
|
|
|
112
172
|
return done("failed", "skill_customize_failed");
|
|
113
173
|
}
|
|
114
174
|
const overrideDirectory = skillOverrideDirectory(request.configPath);
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
175
|
+
const overridePath = join(overrideDirectory, request.name);
|
|
176
|
+
const read = async () => {
|
|
177
|
+
try {
|
|
178
|
+
return await readSkillBases(overrideDirectory);
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
if (error instanceof SkillBasesError)
|
|
182
|
+
return undefined;
|
|
183
|
+
throw error;
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
if (!request.apply) {
|
|
187
|
+
// No lock and no write: the report is a snapshot of what an apply would decide.
|
|
188
|
+
const bases = await read();
|
|
189
|
+
if (bases === undefined)
|
|
121
190
|
return done("failed", "skill_bases_corrupt");
|
|
122
|
-
|
|
191
|
+
const decision = await decide(request.name, overridePath, shipped, bases, dependencies.released);
|
|
192
|
+
if (decision.kind === "failed")
|
|
193
|
+
return done("failed", decision.code);
|
|
194
|
+
return decision.kind === "current" ? done("unchanged", "skill_override_current") : done("planned", decision.planned);
|
|
123
195
|
}
|
|
124
|
-
|
|
125
|
-
let
|
|
196
|
+
await mkdir(overrideDirectory, { recursive: true, mode: 0o700 });
|
|
197
|
+
let lock;
|
|
126
198
|
try {
|
|
127
|
-
|
|
128
|
-
if (!info.isDirectory())
|
|
129
|
-
return done("failed", "skill_override_invalid");
|
|
199
|
+
lock = await acquireOverrideLock(overrideDirectory, dependencies.lock);
|
|
130
200
|
}
|
|
131
201
|
catch (error) {
|
|
132
|
-
if (
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
}
|
|
136
|
-
if (!exists) {
|
|
137
|
-
if (!request.apply)
|
|
138
|
-
return done("planned", "skill_customize_planned");
|
|
139
|
-
await copyShippedSkill(shipped.files, overrideDirectory, overridePath, request.name);
|
|
140
|
-
await writeSkillBases(overrideDirectory, { ...bases, [request.name]: shipped.fingerprint });
|
|
141
|
-
return done("ok", "skill_customized");
|
|
202
|
+
if (error instanceof OverrideLockError)
|
|
203
|
+
return done("failed", error.code === "override_lock_busy" ? "skill_customize_lock_busy" : "skill_customize_lock_unavailable");
|
|
204
|
+
throw error;
|
|
142
205
|
}
|
|
143
206
|
try {
|
|
144
|
-
await
|
|
207
|
+
await dependencies.afterLock?.();
|
|
208
|
+
// Read only now: a snapshot from before the lock may miss a base another run just wrote.
|
|
209
|
+
const bases = await read();
|
|
210
|
+
if (bases === undefined)
|
|
211
|
+
return done("failed", "skill_bases_corrupt");
|
|
212
|
+
const decision = await decide(request.name, overridePath, shipped, bases, dependencies.released);
|
|
213
|
+
if (decision.kind === "failed")
|
|
214
|
+
return done("failed", decision.code);
|
|
215
|
+
if (decision.kind === "current")
|
|
216
|
+
return done("unchanged", "skill_override_current");
|
|
217
|
+
if (decision.kind === "copy")
|
|
218
|
+
await copyShippedSkill(shipped.files, overrideDirectory, overridePath, request.name);
|
|
219
|
+
await writeSkillBases(overrideDirectory, { ...bases, [request.name]: shipped.fingerprint }, dependencies.released, dependencies.beforeBasesRename);
|
|
220
|
+
return done("ok", decision.applied);
|
|
145
221
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
return done("failed", "skill_override_invalid");
|
|
149
|
-
throw error;
|
|
222
|
+
finally {
|
|
223
|
+
await lock.release();
|
|
150
224
|
}
|
|
151
|
-
if (bases[request.name] === shipped.fingerprint)
|
|
152
|
-
return done("unchanged", "skill_override_current");
|
|
153
|
-
if (!request.apply)
|
|
154
|
-
return done("planned", "skill_override_outdated");
|
|
155
|
-
// Only the recorded base moves: it acknowledges that the operator reviewed the changed original.
|
|
156
|
-
await writeSkillBases(overrideDirectory, { ...bases, [request.name]: shipped.fingerprint });
|
|
157
|
-
return done("ok", "skill_base_updated");
|
|
158
225
|
}
|
|
159
226
|
catch {
|
|
160
227
|
return done("failed", "skill_customize_failed");
|
|
161
228
|
}
|
|
162
229
|
}
|
|
163
230
|
async function copyShippedSkill(files, overrideDirectory, overridePath, name) {
|
|
164
|
-
await mkdir(overrideDirectory, { recursive: true, mode: 0o700 });
|
|
165
231
|
const temporary = join(overrideDirectory, `.${name}.${randomBytes(6).toString("hex")}.tmp`);
|
|
166
232
|
try {
|
|
167
233
|
await mkdir(temporary, { mode: 0o755 });
|
|
168
234
|
for (const file of files) {
|
|
169
235
|
const destination = join(temporary, ...file.path.split("/"));
|
|
170
236
|
await mkdir(dirname(destination), { recursive: true });
|
|
171
|
-
await
|
|
237
|
+
const handle = await open(destination, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o644);
|
|
238
|
+
try {
|
|
239
|
+
await handle.writeFile(file.bytes);
|
|
240
|
+
await handle.sync();
|
|
241
|
+
}
|
|
242
|
+
finally {
|
|
243
|
+
await handle.close();
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
// Every directory entry of the temporary tree is durable before the rename and the bases commit.
|
|
247
|
+
const directories = new Set([temporary]);
|
|
248
|
+
for (const file of files) {
|
|
249
|
+
let cursor = dirname(join(temporary, ...file.path.split("/")));
|
|
250
|
+
while (cursor !== temporary) {
|
|
251
|
+
directories.add(cursor);
|
|
252
|
+
cursor = dirname(cursor);
|
|
253
|
+
}
|
|
172
254
|
}
|
|
255
|
+
for (const directory of [...directories].sort((left, right) => right.length - left.length))
|
|
256
|
+
await syncDirectory(directory);
|
|
173
257
|
// The override must not appear while the copy runs, and an operator's directory is never replaced.
|
|
174
258
|
if (await lstat(overridePath).then(() => true, (error) => { if (isMissing(error))
|
|
175
259
|
return false; throw error; }))
|
|
176
260
|
throw new Error("override appeared");
|
|
177
261
|
await rename(temporary, overridePath);
|
|
262
|
+
await syncDirectory(overrideDirectory);
|
|
178
263
|
}
|
|
179
264
|
catch (error) {
|
|
180
265
|
await rm(temporary, { recursive: true, force: true }).catch(() => undefined);
|