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
@@ -5,12 +5,15 @@ import { dirname, join, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { isEntryPoint } from "./entry-point.js";
7
7
  import { serveRefusal } from "./refusal-server.js";
8
+ import { acquireWriterLease, adoptInheritedLease, WriterLeaseError } from "./writer-lease.js";
9
+ import { ensureWriterAbsent, writerContainerName } from "./writer-fence.js";
8
10
  import { createProcessRunner } from "./setup-process.js";
9
11
  import { ensureWriterEventsDirectory, podmanInfo, serveArgs } from "./writer-boundary.js";
10
12
  import { expiresWithin, REFRESH_INTERVAL_MS, REFRESH_THRESHOLD_MINUTES, refreshHostCredential, selectedOAuthProviders, selectBoundaryCredentials, writeBoundaryCredentials } from "./writer-credentials.js";
11
13
  import { currentAcceptanceFingerprint, readWriterState, WRITER_NAME_PATTERN, writerEventDeliveryEnabled, writerPaths } from "./writer-state.js";
12
14
  export { currentAcceptanceFingerprint } from "./writer-state.js";
13
15
  export { renderWrapper, wrapperDigest } from "./writer-wrapper.js";
16
+ export const LAUNCHER_CLEANUP_BOUND_MS = 40_000;
14
17
  function launchFailure(stage) {
15
18
  process.stderr.write(JSON.stringify({ level: "error", event: "writer_launch_failed", stage, code: "writer_launch_failed" }) + "\n");
16
19
  }
@@ -27,10 +30,35 @@ export async function launchEntry(argv, deps) {
27
30
  }
28
31
  }
29
32
  export async function launch(argv, deps) {
30
- const refuse = async (code) => { await serveRefusal(code); return 0; };
31
33
  const name = argv[0];
32
- if (!name || !WRITER_NAME_PATTERN.test(name))
33
- return refuse("writer_not_ready");
34
+ if (!name || !WRITER_NAME_PATTERN.test(name)) {
35
+ await serveRefusal("writer_not_ready");
36
+ return 0;
37
+ }
38
+ const inherited = deps.env.PI_WRITER_LEASE_FD;
39
+ if (inherited !== undefined && (!Number.isSafeInteger(Number(inherited)) || Number(inherited) < 0 || String(Number(inherited)) !== inherited)) {
40
+ await serveRefusal("writer_not_ready");
41
+ return 0;
42
+ }
43
+ let lease;
44
+ try {
45
+ lease = inherited === undefined
46
+ ? await (deps.lease?.acquire ?? acquireWriterLease)(deps.home, name, { waitMs: 0 })
47
+ : await (deps.lease?.adopt ?? adoptInheritedLease)(deps.home, name, Number(inherited));
48
+ }
49
+ catch (error) {
50
+ await serveRefusal(error instanceof WriterLeaseError && error.code === "writer_busy" ? "writer_busy" : "writer_not_ready");
51
+ return 0;
52
+ }
53
+ try {
54
+ return await launchLeased(argv, deps, name, lease);
55
+ }
56
+ finally {
57
+ await lease.release();
58
+ }
59
+ }
60
+ async function launchLeased(argv, deps, name, lease) {
61
+ const refuse = async (code) => { await serveRefusal(code); return 0; };
34
62
  const info = await podmanInfo(deps.runner);
35
63
  if (!info.usable || !info.version)
36
64
  return refuse("container_runtime_unavailable");
@@ -48,8 +76,15 @@ export async function launch(argv, deps) {
48
76
  catch {
49
77
  return refuse("writer_not_ready");
50
78
  }
79
+ const ensureAbsent = () => (deps.ensureAbsent ?? ensureWriterAbsent)(deps.runner, paths, name, { leaseFd: lease.fd });
80
+ try {
81
+ await ensureAbsent();
82
+ }
83
+ catch {
84
+ return refuse("writer_fenced");
85
+ }
51
86
  let eventsDir;
52
- if (await writerEventDeliveryEnabled(paths.config)) {
87
+ if (state.launch !== "relay" && await writerEventDeliveryEnabled(paths.config)) {
53
88
  try {
54
89
  eventsDir = await ensureWriterEventsDirectory(deps.home, name, (deps.uid ?? (() => process.getuid()))());
55
90
  }
@@ -90,46 +125,108 @@ export async function launch(argv, deps) {
90
125
  const command = argv.length > 1 ? argv.slice(1) : ["node", "/opt/pi-session-mcp/dist/main.js"];
91
126
  let child;
92
127
  try {
93
- child = deps.spawnPodman(serveArgs(paths, state, command, deps.releaseRoot, eventsDir));
128
+ child = deps.spawnPodman(serveArgs(paths, state, command, deps.releaseRoot, eventsDir, { name }), [lease.fd]);
94
129
  }
95
130
  catch {
96
131
  launchFailure("spawn");
132
+ await ensureAbsent();
97
133
  return 1;
98
134
  }
99
- let busy = false;
135
+ let outstanding;
136
+ let stopping = false;
100
137
  const timer = enabled ? deps.setInterval(() => {
101
- if (busy)
138
+ if (stopping || outstanding)
102
139
  return;
103
- busy = true;
104
- void refresh().catch(() => {
140
+ outstanding = refresh().catch(() => {
105
141
  process.stderr.write(JSON.stringify({ level: "warning", event: "writer_refresh_failed", code: "writer_refresh_failed" }) + "\n");
106
- }).finally(() => { busy = false; });
142
+ }).finally(() => { outstanding = undefined; });
107
143
  }, REFRESH_INTERVAL_MS) : undefined;
108
- return new Promise((resolveExit) => {
109
- const interrupt = () => { child.kill("SIGINT"); };
110
- const terminate = () => { child.kill("SIGTERM"); };
111
- let finished = false;
112
- const finish = (code) => {
113
- if (finished)
114
- return;
115
- finished = true;
116
- if (timer !== undefined)
117
- deps.clearInterval(timer);
118
- process.off("SIGINT", interrupt);
119
- process.off("SIGTERM", terminate);
120
- resolveExit(code);
144
+ let closed = false;
145
+ let exitCode = 1;
146
+ let spawnFailed = false;
147
+ let resolveClosed;
148
+ const childClosed = new Promise((resolve) => { resolveClosed = resolve; });
149
+ let cleanup;
150
+ let resolveExit;
151
+ let rejectExit;
152
+ const finished = new Promise((resolve, reject) => { resolveExit = resolve; rejectExit = reject; });
153
+ const stop = (signal) => {
154
+ if (cleanup)
155
+ return cleanup;
156
+ stopping = true;
157
+ if (timer !== undefined)
158
+ deps.clearInterval(timer);
159
+ cleanup = (async () => {
160
+ let timeout;
161
+ const bounded = new Promise((resolve) => { timeout = setTimeout(resolve, LAUNCHER_CLEANUP_BOUND_MS); });
162
+ try {
163
+ await outstanding;
164
+ await Promise.race([childClosed, bounded]);
165
+ if (!closed) {
166
+ await deps.runner.run("podman", ["rm", "-f", "-t", "0", writerContainerName(name)], AbortSignal.timeout(20_000), { inheritFds: [lease.fd] }).catch(() => undefined);
167
+ }
168
+ await ensureAbsent();
169
+ return exitCode;
170
+ }
171
+ finally {
172
+ clearTimeout(timeout);
173
+ process.off("SIGINT", interrupt);
174
+ process.off("SIGTERM", terminate);
175
+ process.off("SIGHUP", terminate);
176
+ process.stdin.off("end", endOfInput);
177
+ if (child.stdin) {
178
+ process.stdin.unpipe(child.stdin);
179
+ child.stdin.destroy();
180
+ process.stdin.pause();
181
+ }
182
+ // A wedged Podman client still retains its inherited lease, but must not pin this launcher.
183
+ if (!closed)
184
+ child.unref?.();
185
+ }
186
+ })();
187
+ void cleanup.then(resolveExit, rejectExit);
188
+ // Publish the shared cleanup promise before forwarding a signal can produce close.
189
+ if (signal && !closed)
190
+ child.kill(signal);
191
+ return cleanup;
192
+ };
193
+ const interrupt = () => { void stop("SIGINT"); };
194
+ const terminate = () => { void stop("SIGTERM"); };
195
+ // End of input is passed on through the pipe, not as a signal: a relayed server shuts down on EOF and a
196
+ // finite acceptance probe needs EOF to run at all. Cleanup starts and waits, bounded, for the child to close.
197
+ const endOfInput = () => { void stop(); };
198
+ process.on("SIGINT", interrupt);
199
+ process.on("SIGTERM", terminate);
200
+ process.on("SIGHUP", terminate);
201
+ process.stdin.once("end", endOfInput);
202
+ {
203
+ // Signal/EOF cleanup and child-exit cleanup share exactly one completion.
204
+ const complete = () => { void stop(); };
205
+ const onClose = (code, signal) => {
206
+ closed = true;
207
+ exitCode = spawnFailed ? 1 : code ?? (signal === "SIGINT" ? 130 : signal === "SIGTERM" ? 143 : 1);
208
+ resolveClosed();
209
+ complete();
121
210
  };
122
- process.on("SIGINT", interrupt);
123
- process.on("SIGTERM", terminate);
124
- child.once("error", () => { if (!finished)
125
- launchFailure("spawn"); finish(1); });
126
- child.once("close", (code, signal) => finish(code ?? (signal === "SIGINT" ? 130 : signal === "SIGTERM" ? 143 : 1)));
127
- });
211
+ child.once("error", () => { spawnFailed = true; launchFailure("spawn"); complete(); });
212
+ child.once("close", onClose);
213
+ // Attach before piping: EOF can arrive immediately, and must not steal MCP input.
214
+ if (child.stdin) {
215
+ // Podman can close its pipe before the host notices; cleanup still owns the fence.
216
+ child.stdin.once("error", terminate);
217
+ process.stdin.pipe(child.stdin);
218
+ }
219
+ if (process.stdin.readableEnded) {
220
+ child.stdin?.end();
221
+ endOfInput();
222
+ }
223
+ }
224
+ return finished;
128
225
  }
129
226
  if (isEntryPoint(import.meta.url, process.argv[1])) {
130
227
  void launchEntry(process.argv.slice(2), {
131
228
  home: homedir(), releaseRoot: resolve(dirname(fileURLToPath(import.meta.url)), ".."),
132
- runner: createProcessRunner(), spawnPodman: (args) => spawn("podman", args, { stdio: "inherit" }),
229
+ runner: createProcessRunner(), spawnPodman: (args, inheritFds) => spawn("podman", args, { stdio: ["pipe", "inherit", "inherit", ...inheritFds] }),
133
230
  env: process.env, now: Date.now, setInterval, clearInterval,
134
231
  }).then((code) => { process.exitCode = code; });
135
232
  }
@@ -0,0 +1,28 @@
1
+ import { type StatfsReader } from "./setup-platform.js";
2
+ export declare const LEASE_DIRECTORY: readonly [".local", "state", "pi-session-mcp", "leases"];
3
+ export declare class WriterLeaseError extends Error {
4
+ readonly code: "writer_busy" | "writer_lease_unavailable";
5
+ constructor(code: "writer_busy" | "writer_lease_unavailable");
6
+ }
7
+ export interface WriterLease {
8
+ readonly fd: number;
9
+ release(): Promise<void>;
10
+ }
11
+ interface LeaseSeams {
12
+ readonly signal?: AbortSignal;
13
+ readonly flockCandidates?: readonly string[];
14
+ readonly isTrustedBinary?: (path: string) => boolean;
15
+ readonly readStatfs?: StatfsReader;
16
+ }
17
+ export declare function writerLeasePath(home: string, name: string): string;
18
+ export declare function acquireWriterLease(home: string, name: string, options: LeaseSeams & {
19
+ waitMs: number;
20
+ }): Promise<WriterLease>;
21
+ /**
22
+ * Adopts a descriptor a parent passed down while it holds the lease. A fresh open must find the
23
+ * lock busy before the descriptor is touched; if the holder released in between, the descriptor's
24
+ * own `flock -n` takes the lock instead, which still leaves exactly one exclusive holder. Adopt the
25
+ * same descriptor once: `release()` closes that descriptor number.
26
+ */
27
+ export declare function adoptInheritedLease(home: string, name: string, fd: number, options?: LeaseSeams): Promise<WriterLease>;
28
+ export {};
@@ -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
+ }
@@ -7,6 +7,7 @@ export interface WriterContext {
7
7
  runner: CommandRunner;
8
8
  checkout: string;
9
9
  alias: string;
10
+ leaseFd?: number;
10
11
  }
11
12
  export type WriterOutcome = {
12
13
  code: string;
@@ -37,6 +38,8 @@ export declare function renderContainedConfig(alias: string, clonePath: string,
37
38
  skills?: string[] | undefined;
38
39
  }>;
39
40
  };
41
+ /** The short branch name provisioning would record for `requested` (or its default base), normalized like the writer state. */
42
+ export declare function baseBranchName(ctx: WriterContext, requested?: string): Promise<string>;
40
43
  type ProvisionInput = {
41
44
  settings: ContainerSettings;
42
45
  rawConfig: unknown;
@@ -78,12 +81,15 @@ export type CollectOutcome = WriterOutcome & {
78
81
  export declare function collectWriter(ctx: WriterContext, name: string): Promise<CollectOutcome>;
79
82
  export declare function recreateWriter(ctx: WriterContext, name: string, opts: {
80
83
  discard: boolean;
84
+ baseRef?: string;
81
85
  }): Promise<WriterOutcome>;
82
86
  export declare function resetWriter(ctx: WriterContext, name: string, opts: {
83
87
  discard: boolean;
84
88
  baseRef?: string;
85
89
  }): Promise<WriterOutcome>;
86
- export declare function finishWriter(ctx: WriterContext, name: string): Promise<WriterOutcome>;
90
+ export declare function finishWriter(ctx: WriterContext, name: string, opts?: {
91
+ baseRef?: string;
92
+ }): Promise<WriterOutcome>;
87
93
  export declare function removeWriter(ctx: WriterContext, name: string, opts: {
88
94
  discard: boolean;
89
95
  }): Promise<WriterOutcome>;
@@ -1,13 +1,19 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { constants } from "node:fs";
3
3
  import { chmod, mkdir, open, readdir, rm, writeFile } from "node:fs/promises";
4
- import { join } from "node:path";
4
+ import { basename, join } from "node:path";
5
5
  import { configSchema } from "./config.js";
6
6
  import { renderWrapper } from "./writer-wrapper.js";
7
7
  import { builtinSkillName } from "./skill-catalog.js";
8
8
  import { resolveLibrarySkill, shippedLibrarySkill } from "./skill-library.js";
9
9
  import { BOUNDARY_GIT, BoundaryError, IMAGE_PULL_TIMEOUT_MS, isSafeRefName, podmanInfo, resolveImage, throwawayArgs, verifyCloneLayout } from "./writer-boundary.js";
10
10
  import { readWriterState, writerName, writerPaths, writeWriterState, WriterStateError } from "./writer-state.js";
11
+ function leasedContext(ctx) {
12
+ if (ctx.leaseFd === undefined)
13
+ return ctx;
14
+ const runner = ctx.runner;
15
+ return { ...ctx, runner: { run: (command, args, signal, options) => runner.run(command, args, signal, { ...options, inheritFds: [ctx.leaseFd] }) } };
16
+ }
11
17
  class LifecycleError extends Error {
12
18
  code;
13
19
  constructor(code) {
@@ -100,13 +106,23 @@ function buildContainedConfig(alias, clonePath, rawConfig, selected, availableBu
100
106
  }
101
107
  // CommandRunner has no per-call environment option. Hooks are disabled even for injected runners.
102
108
  async function git(ctx, args, optional = false) {
109
+ ctx = leasedContext(ctx);
103
110
  const result = await ctx.runner.run("git", ["-c", "core.hooksPath=/dev/null", "-C", ctx.checkout, ...args], AbortSignal.timeout(IMAGE_PULL_TIMEOUT_MS));
104
111
  if (result.exitCode !== 0) {
105
112
  if (optional)
106
113
  return undefined;
107
114
  throw new LifecycleError("writer_base_unavailable");
108
115
  }
109
- return result.stdout.trim();
116
+ return args[0] === "config" ? result.stdout.replace(/\n$/, "") : result.stdout.trim();
117
+ }
118
+ async function operatorIdentity(ctx) {
119
+ const name = await git(ctx, ["config", "--get", "user.name"], true);
120
+ const email = await git(ctx, ["config", "--get", "user.email"], true);
121
+ const valid = (value) => value !== undefined && value.length >= 1 && value.length <= 256 && !value.startsWith("-") && !/[\p{Cc}]/u.test(value);
122
+ return valid(name) && valid(email) ? [name, email] : ["", ""];
123
+ }
124
+ function identityScript(name, email) {
125
+ return `if [ -n "$${name}" ] && [ -n "$${email}" ]; then\n${BOUNDARY_GIT} config user.name "$${name}"\n${BOUNDARY_GIT} config user.email "$${email}"\nelse\n${BOUNDARY_GIT} config --unset-all user.name || :\n${BOUNDARY_GIT} config --unset-all user.email || :\nfi`;
110
126
  }
111
127
  async function collectGit(ctx, args) {
112
128
  try {
@@ -143,15 +159,21 @@ async function resolveBase(ctx, requested) {
143
159
  throw new LifecycleError("writer_base_unavailable");
144
160
  return { ref, short, commit };
145
161
  }
146
- async function baseBundle(ctx, paths, ref) {
162
+ /** The short branch name provisioning would record for `requested` (or its default base), normalized like the writer state. */
163
+ export async function baseBranchName(ctx, requested) {
164
+ return (await resolveBase(ctx, requested)).short;
165
+ }
166
+ async function baseBundle(ctx, paths, ref, commit, tags) {
167
+ // for-each-ref prints one full ref per line; `git tag` honours column.ui/column.tag and can print several per line.
168
+ const reachable = tags === "reachable" ? (await git(ctx, ["for-each-ref", "--format=%(refname)", "--merged", commit, "refs/tags"]))?.split("\n").filter((ref) => ref.startsWith("refs/tags/")) ?? [] : [];
147
169
  await rm(join(paths.bundles, "base.bundle"), { force: true });
148
- await git(ctx, ["bundle", "create", join(paths.bundles, "base.bundle"), ref]);
170
+ await git(ctx, ["bundle", "create", join(paths.bundles, "base.bundle"), ref, ...reachable]);
149
171
  await chmod(join(paths.bundles, "base.bundle"), 0o600);
150
172
  }
151
- async function cloneFromBundle(ctx, paths, imageId, base, remote) {
173
+ async function cloneFromBundle(ctx, paths, imageId, base, remote, tags) {
152
174
  // Clone only auto-fetches refs/heads; fetch the exact bundled ref before checkout.
153
- const script = `set -eu\nexport GIT_CONFIG_NOSYSTEM=1\n${BOUNDARY_GIT} clone --no-hardlinks /bundles/base.bundle "$1"\ncd "$1"\n${BOUNDARY_GIT} fetch --no-tags /bundles/base.bundle "$5"\n${BOUNDARY_GIT} checkout -B "$2" "$3"\nif [ -n "$4" ]; then ${BOUNDARY_GIT} remote set-url origin "$4"; else ${BOUNDARY_GIT} remote remove origin; fi`;
154
- const clone = await ctx.runner.run("podman", throwawayArgs(paths, imageId, { clone: "rw", bundles: true }, script, [paths.clone, base.short, base.commit, remote ?? "", base.ref]), AbortSignal.timeout(IMAGE_PULL_TIMEOUT_MS));
175
+ const script = `set -eu\nexport GIT_CONFIG_NOSYSTEM=1\n${BOUNDARY_GIT} clone --no-hardlinks /bundles/base.bundle "$1"\ncd "$1"\n${BOUNDARY_GIT} fetch --no-tags /bundles/base.bundle "$5"\n${BOUNDARY_GIT} checkout -B "$2" "$3"\nif [ -n "$4" ]; then ${BOUNDARY_GIT} remote set-url origin "$4"; else ${BOUNDARY_GIT} remote remove origin; fi\n${identityScript(6, 7)}\n${tags === "reachable" ? `${BOUNDARY_GIT} fetch --no-tags /bundles/base.bundle '+refs/tags/*:refs/tags/*'` : ""}`;
176
+ const clone = await ctx.runner.run("podman", throwawayArgs(basename(paths.root), paths, imageId, { clone: "rw", bundles: true }, script, [paths.clone, base.short, base.commit, remote ?? "", base.ref, ...await operatorIdentity(ctx)]), AbortSignal.timeout(IMAGE_PULL_TIMEOUT_MS));
155
177
  if (clone.exitCode !== 0)
156
178
  throw new LifecycleError("writer_clone_failed");
157
179
  await verifyCloneLayout(paths.clone);
@@ -198,7 +220,7 @@ async function storedConfig(paths) {
198
220
  async function sameSettings(paths, existing, settings, now) {
199
221
  const stored = await storedConfig(paths);
200
222
  return stored !== undefined && existing.imageId === now.imageId && existing.lifecycle === settings.lifecycle
201
- && existing.network === settings.network && existing.cache === settings.cache
223
+ && existing.network === settings.network && existing.cache === settings.cache && existing.tags === settings.tags
202
224
  && JSON.stringify(existing.profiles) === JSON.stringify(settings.profiles)
203
225
  && JSON.stringify(existing.providers) === JSON.stringify(now.providers) && canonical(withoutDeliveryOff(stored)) === canonical(withoutDeliveryOff(now.config));
204
226
  }
@@ -217,6 +239,7 @@ function withoutDeliveryOff(config) {
217
239
  * answers but cannot resolve the image.
218
240
  */
219
241
  export async function writerSettingsDrift(ctx, input) {
242
+ ctx = leasedContext(ctx);
220
243
  try {
221
244
  const name = writerName(ctx.alias, input.index), paths = writerPaths(ctx.home, name);
222
245
  const existing = await readWriterState(ctx.home, name);
@@ -233,6 +256,7 @@ export async function writerSettingsDrift(ctx, input) {
233
256
  }
234
257
  }
235
258
  export async function provisionWriter(ctx, input) {
259
+ ctx = leasedContext(ctx);
236
260
  try {
237
261
  const { index, settings } = input;
238
262
  const name = writerName(ctx.alias, index), paths = writerPaths(ctx.home, name);
@@ -271,16 +295,17 @@ export async function provisionWriter(ctx, input) {
271
295
  // With spool delivery the spool directory exists from provisioning on: launch would otherwise add it to the
272
296
  // writer root, a host directory the acceptance harness digests around its own first launch.
273
297
  const spool = config.events?.delivery === "spool" ? [paths.events] : [];
274
- for (const path of [paths.root, paths.clone, paths.config, paths.agent, paths.bundles, ...spool, ...(settings.cache === "go" ? [paths.go] : [])])
298
+ const cache = settings.cache === "go" ? [paths.go] : settings.cache === "uv" ? [paths.uv] : [];
299
+ for (const path of [paths.root, paths.clone, paths.config, paths.agent, paths.bundles, ...spool, ...cache])
275
300
  await privateDirectory(path);
276
301
  const state = {
277
302
  schemaVersion: 1, alias: ctx.alias, index, phase: "provisioning", lifecycle: settings.lifecycle,
278
- profiles: [...settings.profiles], providers, network: settings.network, cache: settings.cache, imageId,
303
+ profiles: [...settings.profiles], providers, network: settings.network, cache: settings.cache, tags: settings.tags, imageId,
279
304
  base: { ref: `refs/heads/${short}`, commit },
280
305
  };
281
306
  await writeWriterState(ctx.home, name, state);
282
- await baseBundle(ctx, paths, ref);
283
- await cloneFromBundle(ctx, paths, imageId, base, remote);
307
+ await baseBundle(ctx, paths, ref, commit, settings.tags);
308
+ await cloneFromBundle(ctx, paths, imageId, base, remote, settings.tags);
284
309
  await jsonFile(join(paths.config, "pi-session-mcp.json"), config);
285
310
  await jsonFile(join(paths.config, "execution-modes.json"), { schemaVersion: 1, workspaces: { [ctx.alias]: { mode: "host", acceptHostWrites: true } } });
286
311
  await jsonFile(join(paths.config, "writer-guard.json"), { schemaVersion: 1, workspace: ctx.alias, baseRef: state.base.ref, baseCommit: commit });
@@ -294,13 +319,14 @@ export async function provisionWriter(ctx, input) {
294
319
  }
295
320
  }
296
321
  export async function inspectWork(ctx, name) {
322
+ ctx = leasedContext(ctx);
297
323
  const state = await readWriterState(ctx.home, name);
298
324
  if (!state || state.alias !== ctx.alias)
299
325
  throw new LifecycleError("writer_state_invalid");
300
326
  const paths = writerPaths(ctx.home, name);
301
327
  await verifyCloneLayout(paths.clone);
302
328
  const script = `set -eu\nexport GIT_CONFIG_NOSYSTEM=1\ncp .git/index /tmp/index\nmkdir /tmp/objects\nexport GIT_INDEX_FILE=/tmp/index GIT_OBJECT_DIRECTORY=/tmp/objects GIT_ALTERNATE_OBJECT_DIRECTORIES="$1/.git/objects"\n${BOUNDARY_GIT} add -A\n${BOUNDARY_GIT} rev-parse --verify HEAD\n${BOUNDARY_GIT} for-each-ref --format='%(refname) %(objectname)' refs/heads refs/stash\n${BOUNDARY_GIT} write-tree\n${BOUNDARY_GIT} rev-parse 'HEAD^{tree}'\n${BOUNDARY_GIT} rev-parse "$2^{tree}"`;
303
- const result = await ctx.runner.run("podman", throwawayArgs(paths, state.imageId, { clone: "ro", bundles: false }, script, [paths.clone, state.base.commit]), AbortSignal.timeout(IMAGE_PULL_TIMEOUT_MS));
329
+ const result = await ctx.runner.run("podman", throwawayArgs(name, paths, state.imageId, { clone: "ro", bundles: false }, script, [paths.clone, state.base.commit]), AbortSignal.timeout(IMAGE_PULL_TIMEOUT_MS));
304
330
  if (result.exitCode !== 0)
305
331
  throw new LifecycleError("writer_inspect_failed");
306
332
  const lines = result.stdout.trim().split("\n");
@@ -332,7 +358,7 @@ async function writerBase(ctx, name, state, requested) {
332
358
  // State normalizes remote branches to local heads. The original source ref remains
333
359
  // in the single-ref base bundle, including when no corresponding host head exists.
334
360
  const heads = await git(ctx, ["bundle", "list-heads", join(writerPaths(ctx.home, name).bundles, "base.bundle")], true);
335
- const match = /^(?:[0-9a-f]{40}|[0-9a-f]{64}) (refs\/(?:heads\/.+|remotes\/[^/]+\/.+))$/.exec(heads ?? "");
361
+ const match = /^(?:[0-9a-f]{40}|[0-9a-f]{64}) (refs\/(?:heads\/.+|remotes\/[^/]+\/.+))$/m.exec(heads ?? "");
336
362
  const source = match?.[1];
337
363
  if (source && isSafeRefName(source) && shortBranch(source) === shortBranch(state.base.ref))
338
364
  return resolveBase(ctx, source);
@@ -353,6 +379,7 @@ async function refreshPrivateFiles(ctx, name, state) {
353
379
  await jsonFile(join(paths.agent, "auth.json"), {});
354
380
  }
355
381
  export async function collectWriter(ctx, name) {
382
+ ctx = leasedContext(ctx);
356
383
  const refs = [], findings = [];
357
384
  try {
358
385
  const paths = writerPaths(ctx.home, name);
@@ -394,7 +421,7 @@ if [ -z "$(${BOUNDARY_GIT} --git-dir=/tmp/scratch for-each-ref --format='%(refna
394
421
  else
395
422
  ${BOUNDARY_GIT} --git-dir=/tmp/scratch bundle create /bundles/collect.bundle --all
396
423
  fi`;
397
- const result = await ctx.runner.run("podman", throwawayArgs(paths, state.imageId, { clone: "ro", bundles: true }, script, [paths.clone, state.base.ref, state.base.commit, shortBranch(state.base.ref)]), AbortSignal.timeout(IMAGE_PULL_TIMEOUT_MS));
424
+ const result = await ctx.runner.run("podman", throwawayArgs(name, paths, state.imageId, { clone: "ro", bundles: true }, script, [paths.clone, state.base.ref, state.base.commit, shortBranch(state.base.ref)]), AbortSignal.timeout(IMAGE_PULL_TIMEOUT_MS));
398
425
  if (result.exitCode !== 0)
399
426
  throw new LifecycleError("writer_collect_failed");
400
427
  const messages = result.stdout.split("\n");
@@ -437,7 +464,7 @@ async function recreate(ctx, name, state, base) {
437
464
  const remote = await git(ctx, ["remote", "get-url", "origin"], true);
438
465
  if (remote && /^[A-Za-z][A-Za-z0-9+.-]*:\/\/[^/]*@/.test(remote))
439
466
  throw new LifecycleError("remote_url_refused");
440
- await baseBundle(ctx, paths, base.ref);
467
+ await baseBundle(ctx, paths, base.ref, base.commit, state.tags);
441
468
  const { collected: _collected, ...retained } = state;
442
469
  const next = { ...retained, base: { ref: `refs/heads/${base.short}`, commit: base.commit } };
443
470
  await writeWriterState(ctx.home, name, { ...next, phase: "provisioning" });
@@ -445,7 +472,7 @@ async function recreate(ctx, name, state, base) {
445
472
  await rm(path, { recursive: true, force: true });
446
473
  await privateDirectory(path);
447
474
  }
448
- await cloneFromBundle(ctx, paths, state.imageId, base, remote);
475
+ await cloneFromBundle(ctx, paths, state.imageId, base, remote, state.tags);
449
476
  await refreshPrivateFiles(ctx, name, next);
450
477
  await writeWriterState(ctx.home, name, next);
451
478
  if (!(await inspectWork(ctx, name)).baseline)
@@ -453,26 +480,30 @@ async function recreate(ctx, name, state, base) {
453
480
  return { status: "ok", code: "writer_recreated" };
454
481
  }
455
482
  export async function recreateWriter(ctx, name, opts) {
483
+ ctx = leasedContext(ctx);
456
484
  try {
457
485
  const state = await requiredState(ctx, name);
458
486
  await requireCollected(ctx, name, state, opts.discard);
459
- return await recreate(ctx, name, state, await writerBase(ctx, name, state));
487
+ return await recreate(ctx, name, state, await writerBase(ctx, name, state, opts.baseRef));
460
488
  }
461
489
  catch (error) {
462
490
  return failure(error);
463
491
  }
464
492
  }
465
493
  export async function resetWriter(ctx, name, opts) {
494
+ ctx = leasedContext(ctx);
466
495
  try {
467
496
  const state = await requiredState(ctx, name);
468
497
  const base = await writerBase(ctx, name, state, opts.baseRef);
469
498
  await requireCollected(ctx, name, state, opts.discard);
470
499
  const paths = writerPaths(ctx.home, name);
471
500
  await verifyCloneLayout(paths.clone);
472
- await baseBundle(ctx, paths, base.ref);
501
+ await baseBundle(ctx, paths, base.ref, base.commit, state.tags);
473
502
  const script = `set -eu
474
503
  export GIT_CONFIG_NOSYSTEM=1
475
504
  ${BOUNDARY_GIT} checkout -f --detach
505
+ ${BOUNDARY_GIT} for-each-ref --format='%(refname)' refs/tags | while read -r ref; do ${BOUNDARY_GIT} update-ref -d "$ref"; done
506
+ ${state.tags === "reachable" ? `${BOUNDARY_GIT} fetch --no-tags /bundles/base.bundle '+refs/tags/*:refs/tags/*'` : ""}
476
507
  ${BOUNDARY_GIT} fetch --no-tags /bundles/base.bundle "+$1:$1"
477
508
  ${BOUNDARY_GIT} checkout -f -B "$2" "$3"
478
509
  ${BOUNDARY_GIT} reset --hard "$3"
@@ -480,8 +511,9 @@ ${BOUNDARY_GIT} for-each-ref --format='%(refname)' refs/heads | while read -r re
480
511
  if [ "$ref" != "refs/heads/$2" ]; then ${BOUNDARY_GIT} update-ref -d "$ref"; fi
481
512
  done
482
513
  ${BOUNDARY_GIT} stash clear
483
- ${BOUNDARY_GIT} clean -fd`;
484
- const result = await ctx.runner.run("podman", throwawayArgs(paths, state.imageId, { clone: "rw", bundles: true }, script, [base.ref, base.short, base.commit]), AbortSignal.timeout(IMAGE_PULL_TIMEOUT_MS));
514
+ ${BOUNDARY_GIT} clean -fd
515
+ ${identityScript(4, 5)}`;
516
+ const result = await ctx.runner.run("podman", throwawayArgs(name, paths, state.imageId, { clone: "rw", bundles: true }, script, [base.ref, base.short, base.commit, ...await operatorIdentity(ctx)]), AbortSignal.timeout(IMAGE_PULL_TIMEOUT_MS));
485
517
  if (result.exitCode !== 0)
486
518
  throw new LifecycleError("writer_reset_failed");
487
519
  const next = { ...state, base: { ref: `refs/heads/${base.short}`, commit: base.commit } };
@@ -503,13 +535,13 @@ ${BOUNDARY_GIT} clean -fd`;
503
535
  return failure(error);
504
536
  }
505
537
  }
506
- export async function finishWriter(ctx, name) {
538
+ export async function finishWriter(ctx, name, opts = {}) {
507
539
  try {
508
540
  const state = await requiredState(ctx, name);
509
541
  const collected = await collectWriter(ctx, name);
510
542
  if (collected.status === "failed")
511
543
  return collected;
512
- return state.lifecycle === "ephemeral" ? await recreateWriter(ctx, name, { discard: false }) : await resetWriter(ctx, name, { discard: false });
544
+ return state.lifecycle === "ephemeral" ? await recreateWriter(ctx, name, { discard: false, ...opts }) : await resetWriter(ctx, name, { discard: false, ...opts });
513
545
  }
514
546
  catch (error) {
515
547
  return failure(error);