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.
Files changed (73) hide show
  1. package/.pi-session-mcp-cli/doctor.js +89 -4
  2. package/.pi-session-mcp-cli/execution-modes.js +16 -4
  3. package/.pi-session-mcp-cli/override-lock.js +4 -4
  4. package/.pi-session-mcp-cli/setup-process.js +5 -4
  5. package/.pi-session-mcp-cli/writer-boundary.js +10 -4
  6. package/.pi-session-mcp-cli/writer-fence.js +50 -0
  7. package/.pi-session-mcp-cli/writer-lease.js +140 -0
  8. package/.pi-session-mcp-cli/writer-lifecycle.js +55 -23
  9. package/.pi-session-mcp-cli/writer-state.js +4 -2
  10. package/CHANGELOG.md +69 -0
  11. package/README.md +3 -2
  12. package/dist/capabilities.d.ts +2 -0
  13. package/dist/capabilities.js +4 -1
  14. package/dist/child-stdio-transport.d.ts +21 -0
  15. package/dist/child-stdio-transport.js +88 -0
  16. package/dist/client-adapters/types.d.ts +1 -0
  17. package/dist/doctor.d.ts +3 -1
  18. package/dist/doctor.js +89 -4
  19. package/dist/execution-modes.d.ts +12 -2
  20. package/dist/execution-modes.js +16 -4
  21. package/dist/main.js +9 -2
  22. package/dist/override-lock.d.ts +2 -0
  23. package/dist/override-lock.js +4 -4
  24. package/dist/pi-adapter.d.ts +30 -0
  25. package/dist/pi-adapter.js +11 -0
  26. package/dist/refusal-server.d.ts +1 -1
  27. package/dist/refusal-server.js +24 -2
  28. package/dist/server.d.ts +2 -2
  29. package/dist/server.js +18 -53
  30. package/dist/session-registry.d.ts +9 -2
  31. package/dist/session-registry.js +17 -12
  32. package/dist/setup-cli.js +7 -2
  33. package/dist/setup-hooks.js +0 -2
  34. package/dist/setup-modes.js +7 -1
  35. package/dist/setup-process.d.ts +3 -1
  36. package/dist/setup-process.js +5 -4
  37. package/dist/setup-writers.d.ts +5 -0
  38. package/dist/setup-writers.js +200 -92
  39. package/dist/tool-schemas.d.ts +339 -0
  40. package/dist/tool-schemas.js +49 -0
  41. package/dist/wake-hook.d.ts +4 -5
  42. package/dist/wake-hook.js +3 -6
  43. package/dist/writer-acceptance.js +10 -1
  44. package/dist/writer-boundary.d.ts +6 -4
  45. package/dist/writer-boundary.js +10 -4
  46. package/dist/writer-fence.d.ts +12 -0
  47. package/dist/writer-fence.js +50 -0
  48. package/dist/writer-launch.d.ts +9 -1
  49. package/dist/writer-launch.js +127 -30
  50. package/dist/writer-lease.d.ts +28 -0
  51. package/dist/writer-lease.js +140 -0
  52. package/dist/writer-lifecycle.d.ts +7 -1
  53. package/dist/writer-lifecycle.js +55 -23
  54. package/dist/writer-relay.d.ts +33 -0
  55. package/dist/writer-relay.js +359 -0
  56. package/dist/writer-state.d.ts +13 -1
  57. package/dist/writer-state.js +4 -2
  58. package/docs/acceptance.md +47 -0
  59. package/docs/adr/0012-leased-writer-pool.md +357 -0
  60. package/docs/architecture.md +61 -13
  61. package/docs/client-setup.md +11 -15
  62. package/docs/doctor-design.md +10 -4
  63. package/docs/execution-modes.md +87 -44
  64. package/docs/installation.md +34 -22
  65. package/docs/isolation-setup.md +88 -102
  66. package/docs/roadmap.md +5 -0
  67. package/docs/threat-model.md +7 -4
  68. package/docs/tool-contracts.md +29 -12
  69. package/package.json +1 -1
  70. package/scripts/isolation-acceptance.mjs +7 -1
  71. package/skills/pi-session-implementation/SKILL.md +1 -0
  72. package/skills/pi-session-orchestration/SKILL.md +6 -5
  73. 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 defaultEventSpoolRoot(path) {
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), home = dependencies.home ?? homedir(), paths = writerPaths(home, name);
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 ?? defaultEventSpoolRoot, dependencies.uid ?? process.getuid?.()));
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
- if (runOptions?.env !== undefined || released || unsafe || pending || runSignal?.aborted || !child.connected)
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
+ }