pi-session-mcp 0.11.9 → 0.12.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/.pi-session-mcp-cli/doctor.js +89 -4
- package/.pi-session-mcp-cli/execution-modes.js +16 -4
- package/.pi-session-mcp-cli/override-lock.js +4 -4
- package/.pi-session-mcp-cli/setup-process.js +5 -4
- package/.pi-session-mcp-cli/writer-boundary.js +10 -4
- package/.pi-session-mcp-cli/writer-fence.js +50 -0
- package/.pi-session-mcp-cli/writer-lease.js +140 -0
- package/.pi-session-mcp-cli/writer-lifecycle.js +55 -23
- package/.pi-session-mcp-cli/writer-state.js +4 -2
- package/CHANGELOG.md +69 -0
- package/README.md +3 -2
- package/dist/capabilities.d.ts +2 -0
- package/dist/capabilities.js +4 -1
- package/dist/child-stdio-transport.d.ts +21 -0
- package/dist/child-stdio-transport.js +88 -0
- package/dist/client-adapters/types.d.ts +1 -0
- package/dist/doctor.d.ts +3 -1
- package/dist/doctor.js +89 -4
- package/dist/execution-modes.d.ts +12 -2
- package/dist/execution-modes.js +16 -4
- package/dist/main.js +9 -2
- package/dist/override-lock.d.ts +2 -0
- package/dist/override-lock.js +4 -4
- package/dist/pi-adapter.d.ts +30 -0
- package/dist/pi-adapter.js +11 -0
- package/dist/refusal-server.d.ts +1 -1
- package/dist/refusal-server.js +24 -2
- package/dist/server.d.ts +2 -2
- package/dist/server.js +18 -53
- package/dist/session-registry.d.ts +9 -2
- package/dist/session-registry.js +17 -12
- package/dist/setup-cli.js +7 -2
- package/dist/setup-hooks.js +0 -2
- package/dist/setup-modes.js +7 -1
- package/dist/setup-process.d.ts +3 -1
- package/dist/setup-process.js +5 -4
- package/dist/setup-writers.d.ts +5 -0
- package/dist/setup-writers.js +200 -92
- package/dist/tool-schemas.d.ts +339 -0
- package/dist/tool-schemas.js +49 -0
- package/dist/wake-hook.d.ts +4 -5
- package/dist/wake-hook.js +3 -6
- package/dist/writer-acceptance.js +10 -1
- package/dist/writer-boundary.d.ts +6 -4
- package/dist/writer-boundary.js +10 -4
- package/dist/writer-fence.d.ts +12 -0
- package/dist/writer-fence.js +50 -0
- package/dist/writer-launch.d.ts +9 -1
- package/dist/writer-launch.js +127 -30
- package/dist/writer-lease.d.ts +28 -0
- package/dist/writer-lease.js +140 -0
- package/dist/writer-lifecycle.d.ts +7 -1
- package/dist/writer-lifecycle.js +55 -23
- package/dist/writer-relay.d.ts +33 -0
- package/dist/writer-relay.js +359 -0
- package/dist/writer-state.d.ts +13 -1
- package/dist/writer-state.js +4 -2
- package/docs/acceptance.md +47 -0
- package/docs/adr/0012-leased-writer-pool.md +357 -0
- package/docs/architecture.md +61 -13
- package/docs/client-setup.md +11 -15
- package/docs/doctor-design.md +10 -4
- package/docs/execution-modes.md +87 -44
- package/docs/installation.md +34 -22
- package/docs/isolation-setup.md +88 -102
- package/docs/roadmap.md +5 -0
- package/docs/threat-model.md +7 -4
- package/docs/tool-contracts.md +29 -12
- package/package.json +1 -1
- package/scripts/isolation-acceptance.mjs +7 -1
- package/skills/pi-session-implementation/SKILL.md +1 -0
- package/skills/pi-session-orchestration/SKILL.md +6 -5
- package/skills/pi-session-orchestration/references/start-errors.md +5 -1
|
@@ -6,6 +6,7 @@ import { createProcessRunner } from "./setup-process.js";
|
|
|
6
6
|
import { podmanInfo, isSafeRefName } from "./writer-boundary.js";
|
|
7
7
|
import { containedBuiltinSkills, containedProfileRefused } from "./writer-lifecycle.js";
|
|
8
8
|
import { readWriterState, writerAcceptanceCurrent, writerName, writerPaths } from "./writer-state.js";
|
|
9
|
+
import { LEASE_DIRECTORY } from "./writer-lease.js";
|
|
9
10
|
import { loadConfig } from "./config.js";
|
|
10
11
|
import { containerRuntimeUsable } from "./container-runtime.js";
|
|
11
12
|
import { PiExtensionError } from "./extension-spec.js";
|
|
@@ -31,7 +32,7 @@ async function readPrefix(path, maxBytes) {
|
|
|
31
32
|
}
|
|
32
33
|
}
|
|
33
34
|
const defaultFileSystem = { readFile, stat, access };
|
|
34
|
-
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", "workspace_gitfile", "workspace_mode_unset", "workspace_host_writes_accepted", "container_runtime_unavailable", "container_profile_refused", "writer_not_ready", "writer_dirty", "writer_uncollected_bundle", "extension_missing", "extension_version_mismatch", "extension_unpinned", "skill_missing", "skill_unreadable", "skill_duplicate", "skill_override_outdated", "event_spool_root"];
|
|
35
|
+
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", "workspace_gitfile", "workspace_mode_unset", "workspace_base_unset", "workspace_host_writes_accepted", "container_runtime_unavailable", "container_profile_refused", "writer_lease_directory", "writer_not_ready", "writer_not_migrated", "writer_off_default_base", "writer_dirty", "writer_git_identity_missing", "writer_uncollected_bundle", "extension_missing", "extension_version_mismatch", "extension_unpinned", "skill_missing", "skill_unreadable", "skill_duplicate", "skill_override_outdated", "event_spool_root"];
|
|
35
36
|
function failure(id, subject, remediation) { return Object.freeze({ id, severity: "error", subject, remediation }); }
|
|
36
37
|
function warning(id, subject, remediation) { return Object.freeze({ id, severity: "warning", subject, remediation }); }
|
|
37
38
|
function ok(id, subject) { return Object.freeze({ id, severity: "ok", subject }); }
|
|
@@ -147,7 +148,7 @@ async function hasGitfilePointer(fs, workspace) {
|
|
|
147
148
|
return false;
|
|
148
149
|
}
|
|
149
150
|
}
|
|
150
|
-
async function
|
|
151
|
+
async function defaultRootInfo(path) {
|
|
151
152
|
try {
|
|
152
153
|
const info = await lstat(path);
|
|
153
154
|
return { isDirectory: info.isDirectory(), uid: info.uid, mode: info.mode };
|
|
@@ -158,6 +159,30 @@ async function defaultEventSpoolRoot(path) {
|
|
|
158
159
|
throw error;
|
|
159
160
|
}
|
|
160
161
|
}
|
|
162
|
+
/**
|
|
163
|
+
* Offline check of the host-only writer lease directory. A missing directory is fine because the
|
|
164
|
+
* first lease creates it. An existing entry must be a real directory of the current user with mode
|
|
165
|
+
* 0700: the rule `acquireWriterLease` applies before it opens a lease file. Nothing is created,
|
|
166
|
+
* repaired or read, and no path or lease file name appears in the report.
|
|
167
|
+
*/
|
|
168
|
+
async function writerLeaseCheck(home, probe, uid) {
|
|
169
|
+
let info;
|
|
170
|
+
try {
|
|
171
|
+
info = await probe(join(home, ...LEASE_DIRECTORY));
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return failure("writer_lease_directory", "writer lease", "grant access to the writer lease directory");
|
|
175
|
+
}
|
|
176
|
+
if (info === undefined)
|
|
177
|
+
return undefined;
|
|
178
|
+
// Lease files must keep their inodes: a moved or recreated directory would let a second holder in.
|
|
179
|
+
if (!info.isDirectory)
|
|
180
|
+
return failure("writer_lease_directory", "writer lease", "close every writer session and setup run, then replace the entry with a private directory owned by the current user");
|
|
181
|
+
if ((uid !== undefined && info.uid !== uid) || (info.mode & 0o7777) !== 0o700) {
|
|
182
|
+
return failure("writer_lease_directory", "writer lease", "repair the lease directory in place (owner and chmod 700); never move or delete it or its lock files");
|
|
183
|
+
}
|
|
184
|
+
return ok("writer_lease_directory", "writer lease");
|
|
185
|
+
}
|
|
161
186
|
/**
|
|
162
187
|
* Offline check of the event spool's runtime directories, run only when event delivery is enabled.
|
|
163
188
|
* Nothing is created or repaired, and no path appears in the report. A missing runtime directory
|
|
@@ -208,6 +233,44 @@ async function defaultWriterRuntime() {
|
|
|
208
233
|
}
|
|
209
234
|
} };
|
|
210
235
|
}
|
|
236
|
+
async function writerGitIdentity(clone) {
|
|
237
|
+
try {
|
|
238
|
+
const handle = await open(join(clone, ".git", "config"), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
239
|
+
try {
|
|
240
|
+
const info = await handle.stat();
|
|
241
|
+
if (!info.isFile() || info.size > 65_536)
|
|
242
|
+
return false;
|
|
243
|
+
const buffer = Buffer.alloc(65_536);
|
|
244
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
245
|
+
if ((await handle.stat()).size > 65_536)
|
|
246
|
+
return false;
|
|
247
|
+
let user = false, name = false, email = false;
|
|
248
|
+
for (const line of buffer.subarray(0, bytesRead).toString("utf8").split("\n")) {
|
|
249
|
+
const section = /^\s*\[([^\]]+)\]/.exec(line);
|
|
250
|
+
if (section) {
|
|
251
|
+
user = section[1].trim().toLowerCase() === "user";
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
if (!user)
|
|
255
|
+
continue;
|
|
256
|
+
const key = /^\s*(name|email)\s*=\s*(.*?)\s*$/i.exec(line);
|
|
257
|
+
if (!key || !key[2] || /^(?:""|[#;])/.test(key[2]))
|
|
258
|
+
continue;
|
|
259
|
+
if (key[1].toLowerCase() === "name")
|
|
260
|
+
name = true;
|
|
261
|
+
else
|
|
262
|
+
email = true;
|
|
263
|
+
}
|
|
264
|
+
return name && email;
|
|
265
|
+
}
|
|
266
|
+
finally {
|
|
267
|
+
await handle.close();
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
211
274
|
async function writerDirty(clone, state) {
|
|
212
275
|
try {
|
|
213
276
|
const git = join(clone, ".git");
|
|
@@ -280,6 +343,7 @@ async function writerDirty(clone, state) {
|
|
|
280
343
|
export async function runDoctor(dependencies = {}) {
|
|
281
344
|
const fs = dependencies.fileSystem ?? defaultFileSystem;
|
|
282
345
|
const root = dependencies.packageRoot ?? process.cwd();
|
|
346
|
+
const home = dependencies.home ?? homedir();
|
|
283
347
|
const checks = [];
|
|
284
348
|
let metadata;
|
|
285
349
|
try {
|
|
@@ -418,9 +482,13 @@ export async function runDoctor(dependencies = {}) {
|
|
|
418
482
|
let runtime;
|
|
419
483
|
let rawConfig, rawConfigLoaded = false, rawConfigReadable = false;
|
|
420
484
|
let availableBuiltinSkills = new Set();
|
|
485
|
+
const offDefaultBase = new Map();
|
|
421
486
|
for (const [alias, entry] of config.workspaceModes) {
|
|
422
487
|
if (entry.mode !== "container" || !entry.container)
|
|
423
488
|
continue;
|
|
489
|
+
checks.push(entry.container.base === undefined
|
|
490
|
+
? warning("workspace_base_unset", alias, `record the workspace default base with pi-session-mcp-setup --repo ${alias} --base <branch> --apply`)
|
|
491
|
+
: ok("workspace_base_unset", alias));
|
|
424
492
|
if (!rawConfigLoaded) {
|
|
425
493
|
rawConfigLoaded = true;
|
|
426
494
|
try {
|
|
@@ -445,7 +513,7 @@ export async function runDoctor(dependencies = {}) {
|
|
|
445
513
|
checks.push(ok("container_profile_refused", alias));
|
|
446
514
|
}
|
|
447
515
|
for (let index = 1; index <= entry.container.writers; index++) {
|
|
448
|
-
const name = writerName(alias, index),
|
|
516
|
+
const name = writerName(alias, index), paths = writerPaths(home, name);
|
|
449
517
|
const remediation = `run pi-session-mcp-setup --repo ${alias} --apply, or --writer ${name} --recreate --apply`;
|
|
450
518
|
let state, ready = false;
|
|
451
519
|
try {
|
|
@@ -459,6 +527,11 @@ export async function runDoctor(dependencies = {}) {
|
|
|
459
527
|
checks.push(ready ? ok("writer_not_ready", name) : failure("writer_not_ready", name, remediation));
|
|
460
528
|
if (!state)
|
|
461
529
|
continue;
|
|
530
|
+
checks.push(state.launch === "relay" ? ok("writer_not_migrated", name)
|
|
531
|
+
: warning("writer_not_migrated", name, `run pi-session-mcp-setup --repo ${alias} --apply to migrate the writer to the relay`));
|
|
532
|
+
if (entry.container.base !== undefined && ready && state.base.ref !== `refs/heads/${entry.container.base}`) {
|
|
533
|
+
offDefaultBase.set(alias, (offDefaultBase.get(alias) ?? 0) + 1);
|
|
534
|
+
}
|
|
462
535
|
checks.push(await writerDirty(paths.clone, state)
|
|
463
536
|
? warning("writer_dirty", name, "collect writer work before resetting or recreating the writer") : ok("writer_dirty", name));
|
|
464
537
|
let bundles = false;
|
|
@@ -471,9 +544,18 @@ export async function runDoctor(dependencies = {}) {
|
|
|
471
544
|
catch (error) {
|
|
472
545
|
bundles = !isMissing(error);
|
|
473
546
|
}
|
|
547
|
+
checks.push(await writerGitIdentity(paths.clone) ? ok("writer_git_identity_missing", name)
|
|
548
|
+
: warning("writer_git_identity_missing", name, "set user.name and user.email in the operator checkout, then reset the writer"));
|
|
474
549
|
checks.push(bundles ? warning("writer_uncollected_bundle", name, "collect or review outstanding writer bundles") : ok("writer_uncollected_bundle", name));
|
|
475
550
|
}
|
|
476
551
|
}
|
|
552
|
+
for (const [alias, entry] of config.workspaceModes) {
|
|
553
|
+
if (entry.mode !== "container" || !entry.container || entry.container.base === undefined)
|
|
554
|
+
continue;
|
|
555
|
+
const count = offDefaultBase.get(alias) ?? 0;
|
|
556
|
+
checks.push(count === 0 ? ok("writer_off_default_base", alias)
|
|
557
|
+
: warning("writer_off_default_base", alias, `${count} ${count === 1 ? "writer is" : "writers are"} off the workspace default base; finish each with pi-session-mcp-setup --writer <name> --finish --apply once its work is collected`));
|
|
558
|
+
}
|
|
477
559
|
}
|
|
478
560
|
if (config) {
|
|
479
561
|
// This advisory check needs positive evidence, like the gitfile check: unreadable or
|
|
@@ -546,7 +628,10 @@ export async function runDoctor(dependencies = {}) {
|
|
|
546
628
|
}
|
|
547
629
|
}
|
|
548
630
|
if (config?.eventDelivery === "spool")
|
|
549
|
-
checks.push(await eventSpoolCheck(dependencies.env ?? process.env, dependencies.eventSpoolRoot ??
|
|
631
|
+
checks.push(await eventSpoolCheck(dependencies.env ?? process.env, dependencies.eventSpoolRoot ?? defaultRootInfo, dependencies.uid ?? process.getuid?.()));
|
|
632
|
+
const lease = await writerLeaseCheck(home, dependencies.writerLeaseRoot ?? defaultRootInfo, dependencies.uid ?? process.getuid?.());
|
|
633
|
+
if (lease)
|
|
634
|
+
checks.push(lease);
|
|
550
635
|
if (config)
|
|
551
636
|
checks.push(...await skillOverrideChecks(config.skillOverrideDirectory, shippedSkillsDirectory(dependencies.packageRoot), dependencies.releasedSkillTrees));
|
|
552
637
|
const rank = new Map(CHECK_ORDER.map((id, index) => [id, index]));
|
|
@@ -3,7 +3,16 @@ import { dirname, join } from "node:path";
|
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { ConfigError } from "./config.js";
|
|
5
5
|
import { ImmutableMap } from "./execution-profile.js";
|
|
6
|
+
import { isSafeRefName } from "./writer-boundary.js";
|
|
6
7
|
export const EXECUTION_MODES_FILE = "execution-modes.json";
|
|
8
|
+
/** A base is a plain local branch name: safe, not `HEAD`, no ref prefix, no commit ID, at most 200 characters. */
|
|
9
|
+
export function isShortBranchName(value) {
|
|
10
|
+
return value.length <= 200
|
|
11
|
+
&& isSafeRefName(value)
|
|
12
|
+
&& value !== "HEAD"
|
|
13
|
+
&& !value.startsWith("refs/")
|
|
14
|
+
&& !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(value);
|
|
15
|
+
}
|
|
7
16
|
export function executionModesPath(configPath) {
|
|
8
17
|
return join(dirname(configPath), EXECUTION_MODES_FILE);
|
|
9
18
|
}
|
|
@@ -17,7 +26,9 @@ const entrySchema = z.discriminatedUnion("mode", [
|
|
|
17
26
|
profiles: z.array(z.string()).min(1),
|
|
18
27
|
image: z.union([z.literal("node"), z.string().regex(/^[0-9a-f]{64}$/)]).default("node"),
|
|
19
28
|
network: z.enum(["pasta", "none"]).default("pasta"),
|
|
20
|
-
cache: z.enum(["none", "go"]).default("none"),
|
|
29
|
+
cache: z.enum(["none", "go", "uv"]).default("none"),
|
|
30
|
+
tags: z.enum(["none", "reachable"]).default("none"),
|
|
31
|
+
base: z.string().refine(isShortBranchName).optional(),
|
|
21
32
|
}).strict(),
|
|
22
33
|
]);
|
|
23
34
|
export const executionModesSchema = z.object({
|
|
@@ -39,9 +50,10 @@ export function resolveExecutionModes(raw, workspaceAliases, profiles) {
|
|
|
39
50
|
throw new ConfigError("invalid_execution_modes");
|
|
40
51
|
}
|
|
41
52
|
}
|
|
42
|
-
const { mode, ...settings } = entry;
|
|
53
|
+
const { mode, base, ...settings } = entry;
|
|
43
54
|
entries.set(alias, Object.freeze({ mode, container: Object.freeze({
|
|
44
55
|
...settings, profiles: Object.freeze([...settings.profiles]),
|
|
56
|
+
...(base === undefined ? {} : { base }),
|
|
45
57
|
}) }));
|
|
46
58
|
}
|
|
47
59
|
else {
|
|
@@ -66,6 +78,6 @@ export async function loadExecutionModes(configPath, workspaceAliases, profiles)
|
|
|
66
78
|
}
|
|
67
79
|
return resolveExecutionModes(raw, workspaceAliases, profiles);
|
|
68
80
|
}
|
|
69
|
-
export function codingAllowed(entry) {
|
|
70
|
-
return entry.mode === "unset" || entry.mode === "host";
|
|
81
|
+
export function codingAllowed(entry, relay) {
|
|
82
|
+
return entry.mode === "unset" || entry.mode === "host" || (entry.mode === "container" && relay);
|
|
71
83
|
}
|
|
@@ -72,7 +72,7 @@ export async function acquireOverrideLock(overrideDirectory, options = {}) {
|
|
|
72
72
|
},
|
|
73
73
|
};
|
|
74
74
|
}
|
|
75
|
-
function flockBinary(options) {
|
|
75
|
+
export function flockBinary(options) {
|
|
76
76
|
const trusted = options.isTrustedBinary ?? ((path) => isTrustedSystemBinary(path));
|
|
77
77
|
return (options.flockCandidates ?? FLOCK_CANDIDATES).find((candidate) => trusted(candidate));
|
|
78
78
|
}
|
|
@@ -86,7 +86,7 @@ async function openLockFile(lockPath, platform, timeoutMs, signal) {
|
|
|
86
86
|
throw new OverrideLockError(error.message === "ownership_lock_busy" ? "override_lock_busy" : "override_lock_unavailable");
|
|
87
87
|
}
|
|
88
88
|
}
|
|
89
|
-
function lockWithFlock(flockPath, lockFd, timeoutMs, signal) {
|
|
89
|
+
export function lockWithFlock(flockPath, lockFd, timeoutMs, signal, nonBlocking = false) {
|
|
90
90
|
return new Promise((resolve, reject) => {
|
|
91
91
|
if (signal?.aborted) {
|
|
92
92
|
reject(new OverrideLockError("override_lock_unavailable"));
|
|
@@ -97,7 +97,7 @@ function lockWithFlock(flockPath, lockFd, timeoutMs, signal) {
|
|
|
97
97
|
// flock parses fractional seconds with the locale's decimal separator, so only whole
|
|
98
98
|
// seconds go on the command line. The exact bound is the timer below, which stops flock.
|
|
99
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 });
|
|
100
|
+
locker = spawn(flockPath, ["-E", String(FLOCK_BUSY_EXIT), ...(nonBlocking ? ["-n"] : ["-w", wholeSeconds]), "3"], { stdio: ["ignore", "ignore", "ignore", lockFd], detached: true });
|
|
101
101
|
}
|
|
102
102
|
catch {
|
|
103
103
|
reject(new OverrideLockError("override_lock_unavailable"));
|
|
@@ -111,7 +111,7 @@ function lockWithFlock(flockPath, lockFd, timeoutMs, signal) {
|
|
|
111
111
|
catch { /* already gone */ }
|
|
112
112
|
} };
|
|
113
113
|
const cleanup = () => { clearTimeout(bound); signal?.removeEventListener("abort", stop); };
|
|
114
|
-
const bound = setTimeout(() => { expired = true; stop(); }, timeoutMs);
|
|
114
|
+
const bound = nonBlocking ? undefined : setTimeout(() => { expired = true; stop(); }, timeoutMs);
|
|
115
115
|
signal?.addEventListener("abort", stop, { once: true });
|
|
116
116
|
locker.once("error", () => { cleanup(); reject(new OverrideLockError("override_lock_unavailable")); });
|
|
117
117
|
locker.once("close", (code) => {
|
|
@@ -56,7 +56,7 @@ export function createProcessRunner(options = {}) {
|
|
|
56
56
|
if (!table)
|
|
57
57
|
return { exitCode: 126, stdout: "", stderr: "" };
|
|
58
58
|
return new Promise((resolve) => {
|
|
59
|
-
const child = spawn(command, [...args], { stdio: ["ignore", "pipe", "pipe"], env: runOptions?.env ? { ...(options.env ?? process.env), ...runOptions.env } : options.env, cwd: runOptions?.cwd, detached: process.platform !== "win32" });
|
|
59
|
+
const child = spawn(command, [...args], { stdio: ["ignore", "pipe", "pipe", ...(runOptions?.inheritFds ?? [])], env: runOptions?.env ? { ...(options.env ?? process.env), ...runOptions.env } : options.env, cwd: runOptions?.cwd, detached: process.platform !== "win32" });
|
|
60
60
|
let stdout = "";
|
|
61
61
|
let stderr = "";
|
|
62
62
|
let overflow = false;
|
|
@@ -132,9 +132,9 @@ export function createProcessRunner(options = {}) {
|
|
|
132
132
|
} };
|
|
133
133
|
}
|
|
134
134
|
/** Generalized bounded stdio transport for a generated writer wrapper. */
|
|
135
|
-
export function createCommandMcpStdioLauncher(command, env) {
|
|
135
|
+
export function createCommandMcpStdioLauncher(command, env, options) {
|
|
136
136
|
return async (signal) => {
|
|
137
|
-
const child = spawn(command[0], command.slice(1), { stdio: ["pipe", "pipe", "pipe"], env, detached: process.platform !== "win32" });
|
|
137
|
+
const child = spawn(command[0], command.slice(1), { stdio: ["pipe", "pipe", "pipe", ...(options?.inheritFds ?? [])], env, detached: process.platform !== "win32" });
|
|
138
138
|
let buffer = "", closed = false, overflow = false;
|
|
139
139
|
child.stdout.on("data", (chunk) => { if (!overflow) {
|
|
140
140
|
buffer += chunk.toString("utf8");
|
|
@@ -415,7 +415,8 @@ export async function acquireSetupGuardian(lockFd, timeoutMs, signal, validateFe
|
|
|
415
415
|
} });
|
|
416
416
|
const runner = {
|
|
417
417
|
run(command, args, runSignal, runOptions) {
|
|
418
|
-
|
|
418
|
+
// The guardian child cannot pass descriptors through; refuse instead of dropping a caller's lease.
|
|
419
|
+
if (runOptions?.env !== undefined || runOptions?.inheritFds !== undefined || released || unsafe || pending || runSignal?.aborted || !child.connected)
|
|
419
420
|
return Promise.resolve({ exitCode: 126, stdout: "", stderr: "" });
|
|
420
421
|
const id = nextId++;
|
|
421
422
|
return new Promise((resolve) => {
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { constants } from "node:fs";
|
|
2
2
|
import { chmod, lstat, mkdir, open, opendir, readdir, rmdir, unlink } from "node:fs/promises";
|
|
3
|
-
import { dirname, join } from "node:path";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
4
|
import { DEFAULT_COMMAND_TIMEOUT_MS } from "./client-adapters/command.js";
|
|
5
5
|
import { CONTAINER_EVENTS_DIR, EVENTS_DIR_ENV, writerEventsDir } from "./turn-events.js";
|
|
6
|
+
import { writerContainerName, writerLabel } from "./writer-fence.js";
|
|
6
7
|
export const NODE_IMAGE_REF = "docker.io/library/node@sha256:c4d5523090a817b7aa86d2111241fdd4f66d1e27782b44160e6aa63b357ecb2d";
|
|
7
8
|
export const IMAGE_PULL_TIMEOUT_MS = 900_000;
|
|
8
9
|
export const BOUNDARY_GIT = "git -c safe.directory=* -c core.fsmonitor=false -c core.hooksPath=/dev/null";
|
|
@@ -15,10 +16,11 @@ export class BoundaryError extends Error {
|
|
|
15
16
|
}
|
|
16
17
|
}
|
|
17
18
|
/** `eventsDir` is the dedicated host spool directory of this writer; pass it only when event delivery is enabled for the writer. */
|
|
18
|
-
export function serveArgs(paths, state, command, releaseRoot, eventsDir) {
|
|
19
|
+
export function serveArgs(paths, state, command, releaseRoot, eventsDir, options) {
|
|
19
20
|
const args = [
|
|
20
21
|
"run", "-i", "--rm", "--pull=never", "--userns=keep-id", "--read-only",
|
|
21
22
|
"--cap-drop=all", "--security-opt=no-new-privileges", `--network=${state.network}`,
|
|
23
|
+
"--label", writerLabel(options?.name ?? basename(paths.root)),
|
|
22
24
|
"--tmpfs", "/tmp:rw,nosuid,nodev,size=512m",
|
|
23
25
|
"--tmpfs", "/pi/home:rw,nosuid,nodev,mode=0700,size=64m,U",
|
|
24
26
|
"-v", `${paths.clone}:${paths.clone}:rw`,
|
|
@@ -33,10 +35,14 @@ export function serveArgs(paths, state, command, releaseRoot, eventsDir) {
|
|
|
33
35
|
"-e", "PI_CODING_AGENT_DIR=/pi/agent",
|
|
34
36
|
"-e", "PI_SESSION_MCP_WRITER_GUARD=/pi/config/writer-guard.json",
|
|
35
37
|
];
|
|
38
|
+
if (options?.name !== undefined)
|
|
39
|
+
args.push("--name", writerContainerName(options.name));
|
|
36
40
|
if (eventsDir !== undefined)
|
|
37
41
|
args.push("-v", `${eventsDir}:${CONTAINER_EVENTS_DIR}:rw`, "-e", `${EVENTS_DIR_ENV}=${CONTAINER_EVENTS_DIR}`);
|
|
38
42
|
if (state.cache === "go")
|
|
39
43
|
args.push("-v", `${paths.go}:/pi/go:rw`, "-e", "GOPATH=/pi/go", "-e", "GOCACHE=/pi/go/cache", "-e", "GOTOOLCHAIN=local");
|
|
44
|
+
if (state.cache === "uv")
|
|
45
|
+
args.push("-v", `${paths.uv}:/pi/uv:rw`, "-e", "UV_CACHE_DIR=/pi/uv/cache", "-e", "npm_config_cache=/pi/uv/npm");
|
|
40
46
|
return [...args, state.imageId, ...command];
|
|
41
47
|
}
|
|
42
48
|
export class EventsDirectoryError extends Error {
|
|
@@ -225,10 +231,10 @@ export async function ensureWriterEventsDirectory(home, name, uid, now = Date.no
|
|
|
225
231
|
}
|
|
226
232
|
return target;
|
|
227
233
|
}
|
|
228
|
-
export function throwawayArgs(paths, imageId, opts, script, args) {
|
|
234
|
+
export function throwawayArgs(name, paths, imageId, opts, script, args) {
|
|
229
235
|
const result = [
|
|
230
236
|
"run", "--rm", "-i", "--pull=never", "--network=none", "--userns=keep-id", "--read-only",
|
|
231
|
-
"--cap-drop=all", "--security-opt=no-new-privileges",
|
|
237
|
+
"--cap-drop=all", "--security-opt=no-new-privileges", "--label", writerLabel(name),
|
|
232
238
|
"--tmpfs", "/tmp:rw,nosuid,nodev,size=1g", "-e", "HOME=/tmp",
|
|
233
239
|
"-v", `${paths.clone}:${paths.clone}:${opts.clone}`,
|
|
234
240
|
"--workdir", paths.clone,
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { BoundaryError } from "./writer-boundary.js";
|
|
4
|
+
export const FENCE_STOP_TIMEOUT_MS = 20_000;
|
|
5
|
+
const containersSchema = z.array(z.object({ Id: z.string().min(1), Names: z.array(z.string()) }));
|
|
6
|
+
export function writerContainerName(name) { return `pi-writer-${name}`; }
|
|
7
|
+
export function writerLabel(name) { return `pi-session-mcp.writer=${name}`; }
|
|
8
|
+
/** The caller holds the lease; no writer data may change until both inventories are empty. */
|
|
9
|
+
export async function ensureWriterAbsent(runner, paths, name, options) {
|
|
10
|
+
try {
|
|
11
|
+
const timeoutMs = options.stopTimeoutMs ?? FENCE_STOP_TIMEOUT_MS;
|
|
12
|
+
if (process.platform !== "linux" || !Number.isSafeInteger(timeoutMs) || timeoutMs <= 0)
|
|
13
|
+
throw new BoundaryError("writer_fenced");
|
|
14
|
+
const deadline = performance.now() + timeoutMs;
|
|
15
|
+
const remaining = () => {
|
|
16
|
+
const left = Math.ceil(deadline - performance.now());
|
|
17
|
+
if (left <= 0)
|
|
18
|
+
throw new BoundaryError("writer_fenced");
|
|
19
|
+
return left;
|
|
20
|
+
};
|
|
21
|
+
const podman = async (args) => {
|
|
22
|
+
const result = await runner.run("podman", args, AbortSignal.timeout(remaining()), { inheritFds: [options.leaseFd] });
|
|
23
|
+
remaining();
|
|
24
|
+
if (result.exitCode !== 0 || result.stderr === "output_limit")
|
|
25
|
+
throw new BoundaryError("writer_fenced");
|
|
26
|
+
return result.stdout;
|
|
27
|
+
};
|
|
28
|
+
const list = async (filter) => containersSchema.parse(JSON.parse(await podman(["ps", "-a", "--filter", filter, "--format", "json"])));
|
|
29
|
+
const removed = new Set();
|
|
30
|
+
for (;;) {
|
|
31
|
+
const labelled = await list(`label=${writerLabel(name)}`);
|
|
32
|
+
// Podman's volume filter matches the mount destination; every writer container, old releases
|
|
33
|
+
// included, mounts the clone at its own host path, so this also finds unlabelled ones.
|
|
34
|
+
const mounted = await list(`volume=${paths.clone}`);
|
|
35
|
+
const ids = new Set([...labelled, ...mounted].map((container) => container.Id));
|
|
36
|
+
if (ids.size === 0)
|
|
37
|
+
return;
|
|
38
|
+
for (const id of ids) {
|
|
39
|
+
if (removed.has(id))
|
|
40
|
+
continue;
|
|
41
|
+
await podman(["rm", "-f", "-t", String(Math.ceil(remaining() / 1000)), id]);
|
|
42
|
+
removed.add(id);
|
|
43
|
+
}
|
|
44
|
+
await delay(Math.min(100, remaining()));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
throw new BoundaryError("writer_fenced");
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { close, constants, fstat } from "node:fs";
|
|
2
|
+
import { chmod, lstat, mkdir, open } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { flockBinary, lockWithFlock, OverrideLockError } from "./override-lock.js";
|
|
6
|
+
import { assertSupportedFilesystem } from "./setup-platform.js";
|
|
7
|
+
import { WRITER_NAME_PATTERN } from "./writer-state.js";
|
|
8
|
+
export const LEASE_DIRECTORY = [".local", "state", "pi-session-mcp", "leases"];
|
|
9
|
+
export class WriterLeaseError extends Error {
|
|
10
|
+
code;
|
|
11
|
+
constructor(code) {
|
|
12
|
+
super("Writer lease is not available");
|
|
13
|
+
this.code = code;
|
|
14
|
+
this.name = "WriterLeaseError";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export function writerLeasePath(home, name) {
|
|
18
|
+
if (!WRITER_NAME_PATTERN.test(name))
|
|
19
|
+
throw new WriterLeaseError("writer_lease_unavailable");
|
|
20
|
+
return join(home, ...LEASE_DIRECTORY, `${name}.lock`);
|
|
21
|
+
}
|
|
22
|
+
function checkFile(file) {
|
|
23
|
+
if (!file.isFile() || file.uid !== process.getuid() || (file.mode & 0o7777) !== 0o600) {
|
|
24
|
+
throw new WriterLeaseError("writer_lease_unavailable");
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
async function checkPath(path, descriptor) {
|
|
28
|
+
checkFile(descriptor);
|
|
29
|
+
const linked = await lstat(path);
|
|
30
|
+
checkFile(linked);
|
|
31
|
+
if (descriptor.dev !== linked.dev || descriptor.ino !== linked.ino)
|
|
32
|
+
throw new WriterLeaseError("writer_lease_unavailable");
|
|
33
|
+
}
|
|
34
|
+
async function prepare(home, name, options, create) {
|
|
35
|
+
if (process.platform !== "linux" || options.signal?.aborted)
|
|
36
|
+
throw new WriterLeaseError("writer_lease_unavailable");
|
|
37
|
+
const path = writerLeasePath(home, name);
|
|
38
|
+
const binary = flockBinary(options);
|
|
39
|
+
if (!binary)
|
|
40
|
+
throw new WriterLeaseError("writer_lease_unavailable");
|
|
41
|
+
const directory = join(home, ...LEASE_DIRECTORY);
|
|
42
|
+
// The umask may narrow the requested modes; a directory this call created gets its exact mode.
|
|
43
|
+
if (create && await mkdir(directory, { recursive: true, mode: 0o700 }) !== undefined)
|
|
44
|
+
await chmod(directory, 0o700);
|
|
45
|
+
const info = await lstat(directory);
|
|
46
|
+
if (!info.isDirectory() || info.uid !== process.getuid() || (info.mode & 0o7777) !== 0o700)
|
|
47
|
+
throw new WriterLeaseError("writer_lease_unavailable");
|
|
48
|
+
await assertSupportedFilesystem(directory, "linux", options.readStatfs);
|
|
49
|
+
return { path, binary };
|
|
50
|
+
}
|
|
51
|
+
/** Creates the file exclusively and sets its exact mode, or opens the existing one; never follows a link. */
|
|
52
|
+
async function openLeaseFile(path) {
|
|
53
|
+
try {
|
|
54
|
+
const created = await open(path, constants.O_NOFOLLOW | constants.O_CREAT | constants.O_EXCL | constants.O_RDWR, 0o600);
|
|
55
|
+
try {
|
|
56
|
+
await created.chmod(0o600);
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
await created.close().catch(() => undefined);
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
return created;
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
if (error.code !== "EEXIST")
|
|
66
|
+
throw error;
|
|
67
|
+
return open(path, constants.O_NOFOLLOW | constants.O_RDWR);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function lease(fd, closeDescriptor) {
|
|
71
|
+
let released = false;
|
|
72
|
+
return {
|
|
73
|
+
fd,
|
|
74
|
+
async release() {
|
|
75
|
+
if (released)
|
|
76
|
+
return;
|
|
77
|
+
released = true;
|
|
78
|
+
// Never unlock explicitly: mutating children can still hold this open file description.
|
|
79
|
+
await closeDescriptor().catch(() => undefined);
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
export async function acquireWriterLease(home, name, options) {
|
|
84
|
+
let handle;
|
|
85
|
+
try {
|
|
86
|
+
if (!Number.isFinite(options.waitMs) || options.waitMs < 0)
|
|
87
|
+
throw new WriterLeaseError("writer_lease_unavailable");
|
|
88
|
+
const { path, binary } = await prepare(home, name, options, true);
|
|
89
|
+
handle = await openLeaseFile(path);
|
|
90
|
+
checkFile(await handle.stat());
|
|
91
|
+
await lockWithFlock(binary, handle.fd, options.waitMs, options.signal, options.waitMs === 0);
|
|
92
|
+
await checkPath(path, await handle.stat());
|
|
93
|
+
const held = handle;
|
|
94
|
+
return lease(held.fd, () => held.close());
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
await handle?.close().catch(() => undefined);
|
|
98
|
+
throw new WriterLeaseError(error instanceof OverrideLockError && error.code === "override_lock_busy" ? "writer_busy" : "writer_lease_unavailable");
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Adopts a descriptor a parent passed down while it holds the lease. A fresh open must find the
|
|
103
|
+
* lock busy before the descriptor is touched; if the holder released in between, the descriptor's
|
|
104
|
+
* own `flock -n` takes the lock instead, which still leaves exactly one exclusive holder. Adopt the
|
|
105
|
+
* same descriptor once: `release()` closes that descriptor number.
|
|
106
|
+
*/
|
|
107
|
+
export async function adoptInheritedLease(home, name, fd, options = {}) {
|
|
108
|
+
let probe;
|
|
109
|
+
try {
|
|
110
|
+
if (!Number.isInteger(fd) || fd < 0)
|
|
111
|
+
throw new WriterLeaseError("writer_lease_unavailable");
|
|
112
|
+
const { path, binary } = await prepare(home, name, options, false);
|
|
113
|
+
await checkPath(path, await promisify(fstat)(fd));
|
|
114
|
+
probe = await open(path, constants.O_NOFOLLOW | constants.O_RDWR);
|
|
115
|
+
await checkPath(path, await probe.stat());
|
|
116
|
+
// A fresh open must be excluded BEFORE touching the inherited description; otherwise
|
|
117
|
+
// flock on an unlocked inherited fd would itself acquire a lock and falsely prove ownership.
|
|
118
|
+
let busy = false;
|
|
119
|
+
try {
|
|
120
|
+
await lockWithFlock(binary, probe.fd, 0, options.signal, true);
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
if (!(error instanceof OverrideLockError) || error.code !== "override_lock_busy")
|
|
124
|
+
throw error;
|
|
125
|
+
busy = true;
|
|
126
|
+
}
|
|
127
|
+
if (!busy)
|
|
128
|
+
throw new WriterLeaseError("writer_lease_unavailable");
|
|
129
|
+
await lockWithFlock(binary, fd, 0, options.signal, true);
|
|
130
|
+
await checkPath(path, await promisify(fstat)(fd));
|
|
131
|
+
return lease(fd, () => promisify(close)(fd));
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
// Rejection does not take ownership of the caller's descriptor.
|
|
135
|
+
throw new WriterLeaseError("writer_lease_unavailable");
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
await probe?.close().catch(() => undefined);
|
|
139
|
+
}
|
|
140
|
+
}
|