pi-session-mcp 0.7.1 → 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/config.js +47 -7
- package/.pi-session-mcp-cli/doctor.js +75 -2
- package/.pi-session-mcp-cli/execution-profile.js +1 -0
- 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/setup-result.js +8 -0
- package/.pi-session-mcp-cli/skill-catalog.js +35 -0
- package/.pi-session-mcp-cli/skill-customize.js +268 -0
- package/.pi-session-mcp-cli/skill-library.js +188 -0
- package/CHANGELOG.md +176 -0
- package/README.md +15 -3
- package/dist/capabilities.d.ts +1 -0
- package/dist/capabilities.js +2 -1
- package/dist/client-adapters/mcp-verifier.js +6 -2
- package/dist/config.d.ts +4 -0
- package/dist/config.js +47 -7
- package/dist/doctor.d.ts +7 -1
- package/dist/doctor.js +75 -2
- package/dist/execution-profile.d.ts +4 -0
- package/dist/execution-profile.js +1 -0
- package/dist/override-lock.d.ts +34 -0
- package/dist/override-lock.js +125 -0
- package/dist/pi-adapter.d.ts +7 -5
- package/dist/pi-adapter.js +1 -0
- package/dist/pi-skills.d.ts +17 -0
- package/dist/pi-skills.js +64 -0
- package/dist/sdk-pi-adapter.d.ts +7 -3
- package/dist/sdk-pi-adapter.js +82 -16
- package/dist/server.js +1 -0
- package/dist/session-registry.d.ts +8 -6
- package/dist/session-registry.js +3 -2
- package/dist/setup-cli.d.ts +2 -0
- package/dist/setup-cli.js +16 -0
- package/dist/setup-main.d.ts +1 -1
- package/dist/setup-main.js +18 -3
- package/dist/setup-ownership.d.ts +9 -0
- package/dist/setup-ownership.js +32 -5
- package/dist/setup-skills.d.ts +76 -0
- package/dist/setup-skills.js +586 -0
- package/dist/setup.d.ts +7 -0
- package/dist/setup.js +49 -6
- package/dist/skill-catalog.d.ts +26 -0
- package/dist/skill-catalog.js +35 -0
- package/dist/skill-customize.d.ts +41 -0
- package/dist/skill-customize.js +268 -0
- package/dist/skill-library.d.ts +106 -0
- package/dist/skill-library.js +188 -0
- package/docs/acceptance.md +593 -1
- package/docs/adr/0006-pi-extension-allowlist.md +3 -0
- package/docs/adr/0007-profile-skill-selection.md +228 -0
- package/docs/architecture.md +10 -6
- package/docs/client-setup-design.md +219 -1
- package/docs/client-setup.md +86 -3
- package/docs/doctor-design.md +9 -1
- package/docs/installation.md +128 -5
- package/docs/pi-extensions-design.md +28 -4
- package/docs/releasing.md +18 -9
- package/docs/session-mcp.md +7 -2
- package/docs/skills.md +616 -0
- package/docs/threat-model.md +19 -2
- package/docs/tool-contracts.md +9 -5
- package/package.json +2 -1
- package/pi-session-mcp.example.json +9 -3
- package/skills/pi-session-implementation/SKILL.md +42 -0
- package/skills/pi-session-orchestration/SKILL.md +94 -0
- package/skills/pi-session-review/SKILL.md +58 -0
|
@@ -4,16 +4,20 @@ import { dirname, resolve } from "node:path";
|
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { MAX_PROFILE_EXTENSIONS, parseExtensionSpec } from "./extension-spec.js";
|
|
6
6
|
import { isMcpServers, snapshotMcpServers } from "./mcp-config.js";
|
|
7
|
+
import { builtinSkillName, isSkillCatalogPath, MAX_PROFILE_SKILLS, MAX_SKILL_CATALOG_ENTRIES } from "./skill-catalog.js";
|
|
7
8
|
import { ALIAS_PATTERN, ImmutableMap, } from "./execution-profile.js";
|
|
9
|
+
import { builtinSkillPath, skillOverrideDirectory } from "./skill-library.js";
|
|
8
10
|
const alias = z.string().regex(ALIAS_PATTERN);
|
|
9
11
|
const permissionProfile = z.enum(["read-only", "coding"]);
|
|
10
12
|
const thinkingLevel = z.enum(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
|
|
11
13
|
const extensionSpec = z.string().refine((value) => parseExtensionSpec(value) !== undefined);
|
|
14
|
+
const skillCatalogPath = z.string().refine(isSkillCatalogPath);
|
|
12
15
|
const profileSchema = z.object({
|
|
13
16
|
default: z.boolean(), permissionProfile, provider: z.string().trim().min(1).max(256),
|
|
14
17
|
model: z.string().trim().min(1).max(256), thinkingLevel,
|
|
15
18
|
mcpServers: z.custom((value) => isMcpServers(value)).optional(),
|
|
16
19
|
extensions: z.array(extensionSpec).max(MAX_PROFILE_EXTENSIONS).optional(),
|
|
20
|
+
skills: z.array(alias).max(MAX_PROFILE_SKILLS).optional(),
|
|
17
21
|
}).strict().superRefine((profile, context) => {
|
|
18
22
|
if (profile.mcpServers !== undefined && !isMcpServers(profile.mcpServers, profile.permissionProfile)) {
|
|
19
23
|
context.addIssue({ code: "custom", path: ["mcpServers"], message: "MCP tool grants do not match the permission profile" });
|
|
@@ -24,6 +28,9 @@ const profileSchema = z.object({
|
|
|
24
28
|
context.addIssue({ code: "custom", path: ["extensions"], message: "An extension package may appear only once per profile" });
|
|
25
29
|
}
|
|
26
30
|
}
|
|
31
|
+
if (profile.skills !== undefined && new Set(profile.skills).size !== profile.skills.length) {
|
|
32
|
+
context.addIssue({ code: "custom", path: ["skills"], message: "A skill alias may appear only once per profile" });
|
|
33
|
+
}
|
|
27
34
|
});
|
|
28
35
|
const CONFIG_ERROR_MESSAGES = {
|
|
29
36
|
config_path_required: "Configuration path is required",
|
|
@@ -40,6 +47,7 @@ export class ConfigError extends Error {
|
|
|
40
47
|
}
|
|
41
48
|
export const configSchema = z.object({
|
|
42
49
|
workspaces: z.record(alias, z.string().min(1)),
|
|
50
|
+
skillCatalog: z.record(alias, skillCatalogPath).refine((catalog) => Object.keys(catalog).length <= MAX_SKILL_CATALOG_ENTRIES).optional(),
|
|
43
51
|
executionProfiles: z.record(alias, profileSchema).superRefine((profiles, context) => {
|
|
44
52
|
const entries = Object.entries(profiles);
|
|
45
53
|
if (entries.length === 0)
|
|
@@ -50,13 +58,39 @@ export const configSchema = z.object({
|
|
|
50
58
|
if (defaults.length === 1 && defaults[0][1].permissionProfile !== "read-only")
|
|
51
59
|
context.addIssue({ code: "custom", message: "The default execution profile must be read-only" });
|
|
52
60
|
}),
|
|
53
|
-
}).strict()
|
|
61
|
+
}).strict().superRefine((config, context) => {
|
|
62
|
+
// A profile may select only cataloged skills; there is no implicit or default skill.
|
|
63
|
+
const catalog = config.skillCatalog ?? {};
|
|
64
|
+
for (const [profileAlias, profile] of Object.entries(config.executionProfiles)) {
|
|
65
|
+
if ((profile.skills ?? []).some((skill) => !Object.hasOwn(catalog, skill))) {
|
|
66
|
+
context.addIssue({ code: "custom", path: ["executionProfiles", profileAlias, "skills"], message: "A selected skill must be a skillCatalog alias" });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
});
|
|
54
70
|
/** The only supported reload behavior: a running controller keeps the snapshot it loaded at startup. */
|
|
55
71
|
export const RELOAD_POLICY = "restart-required";
|
|
56
72
|
export const CONFIGURATION_FINGERPRINT_PATTERN = /^sha256:[0-9a-f]{64}$/;
|
|
57
73
|
function configurationFingerprint(bytes) {
|
|
58
74
|
return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
|
59
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* Resolves a profile's selection against the catalog. Catalog paths resolve like workspaces,
|
|
78
|
+
* against the configuration file's directory. Two selected aliases on the same path would
|
|
79
|
+
* load one skill twice under different public names, so they invalidate the configuration.
|
|
80
|
+
* A `builtin:<name>` value resolves to the library skill: the operator's override, else the shipped one.
|
|
81
|
+
*/
|
|
82
|
+
function resolveSkillSelection(selected, catalog, base, overrideDirectory) {
|
|
83
|
+
if (selected === undefined || selected.length === 0)
|
|
84
|
+
return undefined;
|
|
85
|
+
const resolveEntry = (value) => {
|
|
86
|
+
const builtin = builtinSkillName(value);
|
|
87
|
+
return builtin === undefined ? resolve(base, value) : builtinSkillPath(builtin, { overrideDirectory });
|
|
88
|
+
};
|
|
89
|
+
const skills = selected.map((skill) => Object.freeze({ alias: skill, path: resolveEntry(catalog[skill]) }));
|
|
90
|
+
if (new Set(skills.map((skill) => skill.path)).size !== skills.length)
|
|
91
|
+
throw new ConfigError("invalid_execution_profile_config");
|
|
92
|
+
return Object.freeze(skills);
|
|
93
|
+
}
|
|
60
94
|
/**
|
|
61
95
|
* The configuration path comes only from the caller or from `PI_SESSION_MCP_CONFIG`.
|
|
62
96
|
* There is no default file: a server's working directory is the client's project,
|
|
@@ -85,14 +119,20 @@ export async function loadConfig(path = process.env.PI_SESSION_MCP_CONFIG) {
|
|
|
85
119
|
throw error;
|
|
86
120
|
}
|
|
87
121
|
const base = dirname(absoluteConfig);
|
|
122
|
+
const overrideDirectory = skillOverrideDirectory(absoluteConfig);
|
|
88
123
|
const workspaces = new ImmutableMap(Object.entries(parsed.workspaces).map(([key, value]) => [key, resolve(base, value)]));
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
124
|
+
const catalog = parsed.skillCatalog ?? {};
|
|
125
|
+
const executionProfiles = new ImmutableMap(Object.entries(parsed.executionProfiles).map(([key, { mcpServers, extensions, skills, ...value }]) => {
|
|
126
|
+
const selection = resolveSkillSelection(skills, catalog, base, overrideDirectory);
|
|
127
|
+
return [key, Object.freeze({
|
|
128
|
+
alias: key, ...value,
|
|
129
|
+
...(mcpServers === undefined ? {} : { mcpServers: snapshotMcpServers(mcpServers) }),
|
|
130
|
+
...(extensions === undefined ? {} : { extensions: Object.freeze(extensions.map((spec) => parseExtensionSpec(spec))) }),
|
|
131
|
+
...(selection === undefined ? {} : { skills: selection }),
|
|
132
|
+
})];
|
|
133
|
+
}));
|
|
94
134
|
const defaultEntry = [...executionProfiles].find(([, profile]) => profile.default);
|
|
95
135
|
if (!defaultEntry)
|
|
96
136
|
throw new Error("Execution profile configuration is invalid");
|
|
97
|
-
return { workspaces, executionProfiles, defaultExecutionProfile: defaultEntry[0], configuration };
|
|
137
|
+
return { workspaces, executionProfiles, defaultExecutionProfile: defaultEntry[0], configuration, skillOverrideDirectory: overrideDirectory };
|
|
98
138
|
}
|
|
@@ -1,11 +1,14 @@
|
|
|
1
|
-
import { access, readFile, readdir, stat } from "node:fs/promises";
|
|
1
|
+
import { access, readFile, readdir, realpath, stat } from "node:fs/promises";
|
|
2
2
|
import { constants } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { loadConfig } from "./config.js";
|
|
5
5
|
import { PiExtensionError } from "./extension-spec.js";
|
|
6
6
|
import { readPackageMetadata } from "./package-metadata.js";
|
|
7
|
+
import { MAX_SKILL_FILE_BYTES } from "./skill-catalog.js";
|
|
8
|
+
import { readSkillBases, SkillBasesError } from "./skill-customize.js";
|
|
9
|
+
import { LEGACY_SKILL_TREE_FINGERPRINT_PATTERN, readSkillTree, releasedSkillFingerprint, shippedSkillsDirectory } from "./skill-library.js";
|
|
7
10
|
const defaultFileSystem = { readFile, stat, access };
|
|
8
|
-
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"];
|
|
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"];
|
|
9
12
|
function failure(id, subject, remediation) { return Object.freeze({ id, severity: "error", subject, remediation }); }
|
|
10
13
|
function warning(id, subject, remediation) { return Object.freeze({ id, severity: "warning", subject, remediation }); }
|
|
11
14
|
function ok(id, subject) { return Object.freeze({ id, severity: "ok", subject }); }
|
|
@@ -62,6 +65,44 @@ async function productionBuildInputMtimes(root) {
|
|
|
62
65
|
}
|
|
63
66
|
return mtimes;
|
|
64
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* Compares the shipped skills with the fingerprints recorded when the operator copied them.
|
|
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.
|
|
72
|
+
*/
|
|
73
|
+
async function skillOverrideChecks(overrideDirectory, shippedDirectory, released) {
|
|
74
|
+
if (overrideDirectory === undefined)
|
|
75
|
+
return [];
|
|
76
|
+
let bases;
|
|
77
|
+
try {
|
|
78
|
+
bases = await readSkillBases(overrideDirectory);
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
if (error instanceof SkillBasesError)
|
|
82
|
+
return [warning("skill_override_outdated", "skill:bases", "repair or remove the skill bases file")];
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
const checks = [];
|
|
86
|
+
for (const [name, base] of Object.entries(bases).sort(([a], [b]) => a.localeCompare(b))) {
|
|
87
|
+
let fingerprint;
|
|
88
|
+
try {
|
|
89
|
+
fingerprint = (await readSkillTree(join(shippedDirectory, name))).fingerprint;
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
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}`));
|
|
103
|
+
}
|
|
104
|
+
return checks;
|
|
105
|
+
}
|
|
65
106
|
export async function runDoctor(dependencies = {}) {
|
|
66
107
|
const fs = dependencies.fileSystem ?? defaultFileSystem;
|
|
67
108
|
const root = dependencies.packageRoot ?? process.cwd();
|
|
@@ -195,6 +236,38 @@ export async function runDoctor(dependencies = {}) {
|
|
|
195
236
|
}
|
|
196
237
|
}
|
|
197
238
|
}
|
|
239
|
+
if (config) {
|
|
240
|
+
for (const [alias, profile] of [...config.executionProfiles].sort(([a], [b]) => a.localeCompare(b))) {
|
|
241
|
+
const canonical = new Set();
|
|
242
|
+
for (const skill of profile.skills ?? []) {
|
|
243
|
+
const subject = `${alias}:${skill.alias}`;
|
|
244
|
+
let real;
|
|
245
|
+
try {
|
|
246
|
+
const info = await fs.stat(skill.path);
|
|
247
|
+
if (!info.isFile() || (info.size ?? 0) > MAX_SKILL_FILE_BYTES)
|
|
248
|
+
throw new Error("unusable skill file");
|
|
249
|
+
await fs.access(skill.path, constants.R_OK);
|
|
250
|
+
real = await (fs.realpath ?? realpath)(skill.path);
|
|
251
|
+
}
|
|
252
|
+
catch (error) {
|
|
253
|
+
if (isMissing(error))
|
|
254
|
+
checks.push(failure("skill_missing", subject, "create the skill file or fix its skillCatalog entry"));
|
|
255
|
+
else
|
|
256
|
+
checks.push(ok("skill_missing", subject), failure("skill_unreadable", subject, "point the skillCatalog entry to a readable SKILL.md within the size limit"));
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
checks.push(ok("skill_missing", subject), ok("skill_unreadable", subject));
|
|
260
|
+
if (canonical.has(real))
|
|
261
|
+
checks.push(failure("skill_duplicate", subject, "select each skill file only once per profile"));
|
|
262
|
+
else {
|
|
263
|
+
canonical.add(real);
|
|
264
|
+
checks.push(ok("skill_duplicate", subject));
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if (config)
|
|
270
|
+
checks.push(...await skillOverrideChecks(config.skillOverrideDirectory, shippedSkillsDirectory(dependencies.packageRoot), dependencies.releasedSkillTrees));
|
|
198
271
|
const rank = new Map(CHECK_ORDER.map((id, index) => [id, index]));
|
|
199
272
|
checks.sort((a, b) => (rank.get(a.id) ?? 999) - (rank.get(b.id) ?? 999) || a.subject.localeCompare(b.subject));
|
|
200
273
|
const hasError = checks.some((check) => check.severity === "error");
|
|
@@ -9,6 +9,7 @@ export function toResolvedExecutionProfile(profile) {
|
|
|
9
9
|
thinkingLevel: profile.thinkingLevel,
|
|
10
10
|
...(profile.mcpServers === undefined ? {} : { mcpServers: snapshotMcpServers(profile.mcpServers) }),
|
|
11
11
|
...(profile.extensions === undefined ? {} : { extensions: profile.extensions }),
|
|
12
|
+
...(profile.skills === undefined ? {} : { skills: profile.skills }),
|
|
12
13
|
});
|
|
13
14
|
}
|
|
14
15
|
/** A small read-only map implementation which has no mutating methods at runtime. */
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export function result(operation, findings, status = findings.some((finding) => finding.status === "failed" || finding.status === "divergent" || finding.status === "unsupported") ? "failed" : "ok", exitCode = status === "failed" ? 1 : 0) {
|
|
2
|
+
const normalized = findings.map((finding) => Object.freeze({
|
|
3
|
+
target: Object.freeze({ ...finding.target }),
|
|
4
|
+
status: finding.status,
|
|
5
|
+
code: /^[a-z][a-z0-9_]{0,63}$/.test(finding.code) ? finding.code : "operation_failed",
|
|
6
|
+
}));
|
|
7
|
+
return Object.freeze({ schemaVersion: 1, operation, status, findings: Object.freeze(normalized), exitCode });
|
|
8
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { BUILTIN_SKILL_PREFIX, SKILL_NAME_PATTERN } from "./skill-library.js";
|
|
2
|
+
export const MAX_SKILL_CATALOG_ENTRIES = 64;
|
|
3
|
+
export const MAX_PROFILE_SKILLS = 16;
|
|
4
|
+
export const MAX_SKILL_PATH_LENGTH = 4096;
|
|
5
|
+
/** Upper bound for one selected `SKILL.md`, checked before the Pi SDK reads it. */
|
|
6
|
+
export const MAX_SKILL_FILE_BYTES = 256 * 1024;
|
|
7
|
+
export const SKILL_FILE_NAME = "SKILL.md";
|
|
8
|
+
/** `npm:`, `git+ssh:`, `https:`, `file:` and any other scheme-prefixed source. */
|
|
9
|
+
const SCHEME_PREFIX = /^[A-Za-z][A-Za-z0-9+.-]*:/;
|
|
10
|
+
const GLOB_CHARACTERS = /[*?[\]{}]/;
|
|
11
|
+
/** The library skill name of a `builtin:<name>` catalog value; undefined for every other value. */
|
|
12
|
+
export function builtinSkillName(value) {
|
|
13
|
+
if (!value.startsWith(BUILTIN_SKILL_PREFIX))
|
|
14
|
+
return undefined;
|
|
15
|
+
const name = value.slice(BUILTIN_SKILL_PREFIX.length);
|
|
16
|
+
return SKILL_NAME_PATTERN.test(name) ? name : undefined;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Whether a configured catalog value names exactly one local `SKILL.md`: its last path segment
|
|
20
|
+
* is `SKILL.md`, and it is no URL or other scheme-prefixed source, no `~` home shorthand, no
|
|
21
|
+
* glob and no Windows-style path. So it can never stand for a directory, a pattern or a
|
|
22
|
+
* remote source. Relative values are resolved later against the configuration file's directory.
|
|
23
|
+
* The one scheme-prefixed form accepted is `builtin:<name>`, which names a library skill.
|
|
24
|
+
*/
|
|
25
|
+
export function isSkillCatalogPath(value) {
|
|
26
|
+
if (value.length === 0 || value.length > MAX_SKILL_PATH_LENGTH)
|
|
27
|
+
return false;
|
|
28
|
+
if (value.includes("\0") || value.includes("\\") || value.startsWith("~"))
|
|
29
|
+
return false;
|
|
30
|
+
if (builtinSkillName(value) !== undefined)
|
|
31
|
+
return true;
|
|
32
|
+
if (SCHEME_PREFIX.test(value) || GLOB_CHARACTERS.test(value))
|
|
33
|
+
return false;
|
|
34
|
+
return value.split("/").at(-1) === SKILL_FILE_NAME;
|
|
35
|
+
}
|