pi-session-mcp 0.14.0 → 0.14.2

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 (41) hide show
  1. package/.pi-session-mcp-cli/writer-boundary.js +2 -1
  2. package/.pi-session-mcp-cli/writer-lifecycle.js +99 -18
  3. package/.pi-session-mcp-cli/writer-models-store.js +64 -0
  4. package/.pi-session-mcp-cli/writer-tree-removal.js +75 -0
  5. package/CHANGELOG.md +95 -0
  6. package/README.md +1 -1
  7. package/dist/capabilities.d.ts +13 -0
  8. package/dist/capabilities.js +17 -13
  9. package/dist/model-catalog.d.ts +18 -5
  10. package/dist/model-catalog.js +8 -5
  11. package/dist/sdk-pi-adapter.d.ts +6 -1
  12. package/dist/sdk-pi-adapter.js +35 -7
  13. package/dist/writer-boundary.d.ts +1 -1
  14. package/dist/writer-boundary.js +2 -1
  15. package/dist/writer-guard.js +1 -1
  16. package/dist/writer-launch.d.ts +2 -0
  17. package/dist/writer-launch.js +3 -0
  18. package/dist/writer-lifecycle.d.ts +8 -0
  19. package/dist/writer-lifecycle.js +99 -18
  20. package/dist/writer-models-store.d.ts +16 -0
  21. package/dist/writer-models-store.js +64 -0
  22. package/dist/writer-relay.js +12 -1
  23. package/dist/writer-tree-removal.d.ts +16 -0
  24. package/dist/writer-tree-removal.js +75 -0
  25. package/docs/acceptance.md +314 -0
  26. package/docs/adr/0012-leased-writer-pool.md +10 -6
  27. package/docs/adr/0013-hardened-read-only-profiles.md +30 -0
  28. package/docs/adr/0014-profile-probe-and-model-catalog.md +1 -1
  29. package/docs/doctor-design.md +1 -1
  30. package/docs/execution-modes.md +66 -10
  31. package/docs/installation.md +9 -0
  32. package/docs/isolation-setup.md +12 -2
  33. package/docs/roadmap.md +21 -0
  34. package/docs/sprints/2026-10-24-s8-writer-migration.md +5 -3
  35. package/docs/sprints/2026-10-29-s12-audit-fixes.md +174 -0
  36. package/docs/sprints/2026-10-30-s13-index-only-work.md +199 -0
  37. package/docs/threat-model.md +10 -1
  38. package/docs/tool-contracts.md +1 -1
  39. package/package.json +1 -1
  40. package/skills/pi-session-orchestration/SKILL.md +1 -1
  41. package/skills/pi-session-profiles/SKILL.md +5 -1
@@ -1,6 +1,7 @@
1
1
  import { createAgentSession, createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createBashToolDefinition, DefaultResourceLoader, getAgentDir, ModelRuntime, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
2
+ import { readFileSync, realpathSync } from "node:fs";
2
3
  import { homedir, userInfo } from "node:os";
3
- import { join } from "node:path";
4
+ import { isAbsolute, join, relative, sep } from "node:path";
4
5
  import { allowedReadRoots, createConfinedReadOnlyTools } from "./confined-tools.js";
5
6
  import { PiExtensionError } from "./extension-spec.js";
6
7
  import { classifyMcpStartupError, connectSessionMcpTools, } from "./mcp-session-tools.js";
@@ -298,7 +299,7 @@ function isTextBlock(block) {
298
299
  return candidate.type === "text" && typeof candidate.text === "string";
299
300
  }
300
301
  /** Independently validated skills replace package and extension discovery on every reload. */
301
- function phase0ResourceLoaderOptions(skills) {
302
+ function phase0ResourceLoaderOptions(skills, confinedCwd) {
302
303
  // Keep the closure's snapshot private: the SDK may annotate or mutate returned objects.
303
304
  const selected = skills.map((skill) => ({ ...skill, sourceInfo: { ...skill.sourceInfo } }));
304
305
  return {
@@ -307,6 +308,24 @@ function phase0ResourceLoaderOptions(skills) {
307
308
  noPromptTemplates: true,
308
309
  noThemes: true,
309
310
  noContextFiles: false,
311
+ ...(confinedCwd === undefined ? {} : {
312
+ agentsFilesOverride: (current) => ({
313
+ agentsFiles: current.agentsFiles.flatMap((file) => {
314
+ try {
315
+ const workspace = realpathSync(confinedCwd), real = realpathSync(file.path);
316
+ const path = relative(workspace, real);
317
+ if (path === ".." || path.startsWith(`..${sep}`) || isAbsolute(path))
318
+ return [];
319
+ // Re-read from the verified real path: the SDK read the link before this check.
320
+ return [{ path: file.path, content: readFileSync(real, "utf-8").replace(/^\uFEFF/, "") }];
321
+ }
322
+ catch {
323
+ // Unresolvable context files never enter a read-only session's model context.
324
+ return [];
325
+ }
326
+ }),
327
+ }),
328
+ }),
310
329
  systemPrompt: "phase-0-system-prompt-disabled",
311
330
  appendSystemPrompt: [],
312
331
  systemPromptOverride: () => undefined,
@@ -316,12 +335,17 @@ function phase0ResourceLoaderOptions(skills) {
316
335
  themesOverride: () => ({ themes: [], diagnostics: [] }),
317
336
  };
318
337
  }
319
- export async function createPhase0ResourceLoader(cwd, skills = []) {
338
+ export async function createPhase0ResourceLoader(cwd, skills = [], permissionProfile) {
320
339
  const settingsManager = SettingsManager.inMemory(undefined, { projectTrusted: false });
321
- const resourceLoader = new DefaultResourceLoader({ cwd, agentDir: cwd, settingsManager, ...phase0ResourceLoaderOptions(skills) });
340
+ const resourceLoader = new DefaultResourceLoader({ cwd, agentDir: cwd, settingsManager, ...phase0ResourceLoaderOptions(skills, permissionProfile === "read-only" ? cwd : undefined) });
322
341
  await resourceLoader.reload();
323
342
  return { resourceLoader, settingsManager };
324
343
  }
344
+ /**
345
+ * Created without the refresh-time availability pass, which would read and resolve every provider's credential. Pi's persisted
346
+ * catalog is restored per session for the selected provider only (`resolveVerifiedModel`). The extension path uses the same factory.
347
+ */
348
+ export const defaultModelRuntimeFactory = () => ModelRuntime.create({ refreshOnCreate: false });
325
349
  export class SdkPiSessionAdapter {
326
350
  runtimePromise;
327
351
  modelRuntimeFactory;
@@ -333,7 +357,7 @@ export class SdkPiSessionAdapter {
333
357
  sandbox;
334
358
  protectedReadPaths;
335
359
  constructor(options = {}) {
336
- this.modelRuntimeFactory = options.modelRuntimeFactory ?? (() => ModelRuntime.create({ refreshOnCreate: false }));
360
+ this.modelRuntimeFactory = options.modelRuntimeFactory ?? defaultModelRuntimeFactory;
337
361
  this.createSession = options.createSession ?? createAgentSession;
338
362
  this.diagnosticSink = options.diagnosticSink ?? (() => undefined);
339
363
  this.mcpConnectionFactory = options.mcpConnectionFactory ?? createStdioMcpConnection;
@@ -367,7 +391,7 @@ export class SdkPiSessionAdapter {
367
391
  let resourceLoader;
368
392
  let settingsManager;
369
393
  try {
370
- ({ resourceLoader, settingsManager } = await createPhase0ResourceLoader(input.cwd, skills));
394
+ ({ resourceLoader, settingsManager } = await createPhase0ResourceLoader(input.cwd, skills, executionProfile.permissionProfile));
371
395
  }
372
396
  catch {
373
397
  throw this.failure(executionProfile, "resource_loader", "pi_session_creation_failed");
@@ -426,7 +450,7 @@ export class SdkPiSessionAdapter {
426
450
  agentDir: cwd,
427
451
  modelRuntime: runtime,
428
452
  settingsManager: SettingsManager.inMemory(undefined, { projectTrusted: false }),
429
- resourceLoaderOptions: { ...phase0ResourceLoaderOptions(skills), additionalExtensionPaths: directories },
453
+ resourceLoaderOptions: { ...phase0ResourceLoaderOptions(skills, executionProfile.permissionProfile === "read-only" ? cwd : undefined), additionalExtensionPaths: directories },
430
454
  }));
431
455
  this.checkResources(services.resourceLoader, skills, executionProfile, "extension_load");
432
456
  const loaded = services.resourceLoader.getExtensions();
@@ -578,6 +602,10 @@ export class SdkPiSessionAdapter {
578
602
  }
579
603
  if (!provider)
580
604
  throw this.failure(executionProfile, "provider", "unknown_provider");
605
+ // Restores the catalog Pi persisted (the pi.dev overlay) for this provider only, offline and bounded (#199). Best effort:
606
+ // on failure the shipped models remain, and the checks below decide.
607
+ await withAuthDeadline((signal) => runtime.refresh({ allowNetwork: false, providers: [executionProfile.provider], signal }))
608
+ .catch(() => undefined);
581
609
  let model;
582
610
  try {
583
611
  model = runtime.getModel(executionProfile.provider, executionProfile.model);
@@ -2,7 +2,7 @@ import type { CommandRunner } from "./client-adapters/types.js";
2
2
  import { writerPaths, type WriterState } from "./writer-state.js";
3
3
  export declare const NODE_IMAGE_REF = "docker.io/library/node@sha256:c4d5523090a817b7aa86d2111241fdd4f66d1e27782b44160e6aa63b357ecb2d";
4
4
  export declare const IMAGE_PULL_TIMEOUT_MS = 900000;
5
- export declare const BOUNDARY_GIT = "git -c safe.directory=* -c core.fsmonitor=false -c core.hooksPath=/dev/null";
5
+ export declare const BOUNDARY_GIT = "git -c safe.directory=* -c core.fsmonitor=false -c core.hooksPath=/dev/null -c core.useReplaceRefs=false";
6
6
  type WriterPaths = ReturnType<typeof writerPaths>;
7
7
  export declare class BoundaryError extends Error {
8
8
  readonly code: "image_unavailable" | "clone_verification_failed" | "writer_fenced";
@@ -6,7 +6,8 @@ import { CONTAINER_EVENTS_DIR, EVENTS_DIR_ENV, writerEventsDir } from "./turn-ev
6
6
  import { writerContainerName, writerLabel } from "./writer-fence.js";
7
7
  export const NODE_IMAGE_REF = "docker.io/library/node@sha256:c4d5523090a817b7aa86d2111241fdd4f66d1e27782b44160e6aa63b357ecb2d";
8
8
  export const IMAGE_PULL_TIMEOUT_MS = 900_000;
9
- export const BOUNDARY_GIT = "git -c safe.directory=* -c core.fsmonitor=false -c core.hooksPath=/dev/null";
9
+ // Replace refs in a clone are worker-controlled: they could make unexported work look reachable or clean (#193).
10
+ export const BOUNDARY_GIT = "git -c safe.directory=* -c core.fsmonitor=false -c core.hooksPath=/dev/null -c core.useReplaceRefs=false";
10
11
  export class BoundaryError extends Error {
11
12
  code;
12
13
  constructor(code) {
@@ -13,7 +13,7 @@ const guardSchema = z.object({
13
13
  }).strict();
14
14
  const run = promisify(execFile);
15
15
  const defaultGit = async (cwd) => {
16
- const git = async (...args) => (await run("git", ["-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", ...args], { cwd, timeout: 10_000, encoding: "utf8" })).stdout;
16
+ const git = async (...args) => (await run("git", ["-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", "-c", "core.useReplaceRefs=false", ...args], { cwd, timeout: 10_000, encoding: "utf8" })).stdout;
17
17
  const [status, head, refs] = await Promise.all([
18
18
  git("status", "--porcelain=v1", "-uall"),
19
19
  git("rev-parse", "HEAD"),
@@ -22,6 +22,8 @@ export interface LaunchDependencies {
22
22
  now(): number;
23
23
  /** Warms up one host OAuth credential through Pi's locked store; defaults to the SDK refresh, tests inject a fake. */
24
24
  warmupHostCredential?: (input: HostCredentialRefresh) => Promise<void>;
25
+ /** The host's persisted Pi model catalog copied into the writer's agent directory; defaults to the host agent directory below `home`. */
26
+ modelsStorePath?: string;
25
27
  setInterval(callback: () => void, ms: number): ReturnType<typeof setInterval>;
26
28
  clearInterval(timer: ReturnType<typeof setInterval>): void;
27
29
  }
@@ -10,6 +10,7 @@ import { ensureWriterAbsent, writerContainerName } from "./writer-fence.js";
10
10
  import { createProcessRunner } from "./setup-process.js";
11
11
  import { ensureWriterEventsDirectory, podmanInfo, serveArgs } from "./writer-boundary.js";
12
12
  import { expiresWithin, REFRESH_INTERVAL_MS, REFRESH_THRESHOLD_MINUTES, refreshHostCredential, selectedOAuthProviders, selectBoundaryCredentials, writeBoundaryCredentials } from "./writer-credentials.js";
13
+ import { copyModelsStore, hostModelsStorePath } from "./writer-models-store.js";
13
14
  import { currentAcceptanceFingerprint, readWriterState, WRITER_NAME_PATTERN, writerEventDeliveryEnabled, writerPaths } from "./writer-state.js";
14
15
  export { currentAcceptanceFingerprint } from "./writer-state.js";
15
16
  export { renderWrapper, wrapperDigest } from "./writer-wrapper.js";
@@ -122,6 +123,8 @@ async function launchLeased(argv, deps, name, lease) {
122
123
  catch {
123
124
  return refuse("writer_not_ready");
124
125
  }
126
+ // Best effort: without the host's persisted catalog the writer's Pi keeps the static model list.
127
+ await copyModelsStore(deps.modelsStorePath ?? hostModelsStorePath(deps.home), paths.agent);
125
128
  const command = argv.length > 1 ? argv.slice(1) : ["node", "/opt/pi-session-mcp/dist/main.js"];
126
129
  let child;
127
130
  try {
@@ -8,6 +8,8 @@ export interface WriterContext {
8
8
  checkout: string;
9
9
  alias: string;
10
10
  leaseFd?: number;
11
+ /** The host's persisted Pi model catalog copied into writers; defaults to the host agent directory below `home`. Tests inject it. */
12
+ modelsStorePath?: string;
11
13
  }
12
14
  export type WriterOutcome = {
13
15
  code: string;
@@ -66,11 +68,17 @@ export declare function provisionWriter(ctx: WriterContext, input: {
66
68
  export declare function inspectWork(ctx: WriterContext, name: string): Promise<{
67
69
  baseline: boolean;
68
70
  digest: string;
71
+ head?: string;
69
72
  branches: readonly {
70
73
  name: string;
71
74
  commit: string;
72
75
  }[];
73
76
  dirty: boolean;
77
+ stashes: readonly string[];
78
+ unknownRefs: number;
79
+ indexTree: string;
80
+ tree: string;
81
+ headTree: string;
74
82
  }>;
75
83
  export type CollectOutcome = WriterOutcome & {
76
84
  refs: readonly string[];
@@ -3,12 +3,15 @@ import { constants } from "node:fs";
3
3
  import { chmod, mkdir, open, readdir, rm, writeFile } from "node:fs/promises";
4
4
  import { basename, join } from "node:path";
5
5
  import { writerLimits } from "./writer-limits.js";
6
+ import { removeWriterTree } from "./writer-tree-removal.js";
7
+ import { copyModelsStore, hostModelsStorePath } from "./writer-models-store.js";
6
8
  import { configSchema } from "./config.js";
7
9
  import { renderWrapper } from "./writer-wrapper.js";
8
10
  import { builtinSkillName } from "./skill-catalog.js";
9
11
  import { resolveLibrarySkill, shippedLibrarySkill } from "./skill-library.js";
10
12
  import { BOUNDARY_GIT, BoundaryError, IMAGE_PULL_TIMEOUT_MS, isSafeRefName, podmanInfo, resolveImage, throwawayArgs, verifyCloneLayout } from "./writer-boundary.js";
11
13
  import { readWriterState, writerName, writerPaths, writeWriterState, WriterStateError } from "./writer-state.js";
14
+ const modelsStoreSource = (ctx) => ctx.modelsStorePath ?? hostModelsStorePath(ctx.home);
12
15
  function leasedContext(ctx) {
13
16
  if (ctx.leaseFd === undefined)
14
17
  return ctx;
@@ -292,7 +295,7 @@ export async function provisionWriter(ctx, input) {
292
295
  }
293
296
  }
294
297
  if (restart || existing?.phase === "provisioning")
295
- await rm(paths.root, { recursive: true, force: true });
298
+ await removeWriterTree(paths.root);
296
299
  // With spool delivery the spool directory exists from provisioning on: launch would otherwise add it to the
297
300
  // writer root, a host directory the acceptance harness digests around its own first launch.
298
301
  const spool = config.events?.delivery === "spool" ? [paths.events] : [];
@@ -312,6 +315,7 @@ export async function provisionWriter(ctx, input) {
312
315
  await jsonFile(join(paths.config, "execution-modes.json"), { schemaVersion: 1, workspaces: { [ctx.alias]: { mode: "host", acceptHostWrites: true } } });
313
316
  await jsonFile(join(paths.config, "writer-guard.json"), { schemaVersion: 1, workspace: ctx.alias, baseRef: state.base.ref, baseCommit: commit });
314
317
  await jsonFile(join(paths.agent, "auth.json"), {});
318
+ await copyModelsStore(modelsStoreSource(ctx), paths.agent);
315
319
  await writeFile(paths.wrapper, renderWrapper(ctx.nodePath, ctx.releaseRoot, name), { mode: 0o700, flag: "wx" });
316
320
  await writeWriterState(ctx.home, name, { ...state, phase: "not_ready" });
317
321
  return { code: "writer_provisioned", status: "ok" };
@@ -320,6 +324,21 @@ export async function provisionWriter(ctx, input) {
320
324
  return failure(error);
321
325
  }
322
326
  }
327
+ /**
328
+ * Distinct object names among `objects` that the operator checkout does not have. Objects that an operator ref
329
+ * names need no further call; each remaining one costs one `cat-file`, since the runner has no stdin for a batch.
330
+ */
331
+ async function missingObjects(ctx, objects) {
332
+ if (objects.length === 0)
333
+ return [];
334
+ const known = new Set((await git(ctx, ["for-each-ref", "--format=%(objectname)"], true))?.split("\n"));
335
+ const missing = [];
336
+ for (const object of new Set(objects)) {
337
+ if (!known.has(object) && await git(ctx, ["cat-file", "-e", object], true) === undefined)
338
+ missing.push(object);
339
+ }
340
+ return missing;
341
+ }
323
342
  export async function inspectWork(ctx, name) {
324
343
  ctx = leasedContext(ctx);
325
344
  const state = await readWriterState(ctx.home, name);
@@ -327,25 +346,40 @@ export async function inspectWork(ctx, name) {
327
346
  throw new LifecycleError("writer_state_invalid");
328
347
  const paths = writerPaths(ctx.home, name);
329
348
  await verifyCloneLayout(paths.clone);
330
- 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}"`;
349
+ const script = `set -eu\nexport GIT_CONFIG_NOSYSTEM=1\ncp .git/index /tmp/index\ncp .git/index /tmp/staged-index\nmkdir /tmp/objects\nexport GIT_INDEX_FILE=/tmp/index GIT_OBJECT_DIRECTORY=/tmp/objects GIT_ALTERNATE_OBJECT_DIRECTORIES="$1/.git/objects"\nstaged=$(GIT_INDEX_FILE=/tmp/staged-index ${BOUNDARY_GIT} write-tree)\n${BOUNDARY_GIT} add -A\n${BOUNDARY_GIT} rev-parse --verify HEAD\n${BOUNDARY_GIT} for-each-ref --format='%(refname) %(objectname)'\nif stash=$(${BOUNDARY_GIT} rev-parse -q --verify refs/stash); then\n log=$(${BOUNDARY_GIT} rev-list --walk-reflogs refs/stash)\n printf 'stash %s\\n' "$stash" $log\nfi\n${BOUNDARY_GIT} write-tree\nprintf '%s\\n' "$staged"\n${BOUNDARY_GIT} rev-parse 'HEAD^{tree}'\n${BOUNDARY_GIT} rev-parse "$2^{tree}"`;
331
350
  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));
332
351
  if (result.exitCode !== 0)
333
352
  throw new LifecycleError("writer_inspect_failed");
334
353
  const lines = result.stdout.trim().split("\n");
335
354
  const hash = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
336
- const head = lines.shift(), baseTree = lines.pop(), headTree = lines.pop(), tree = lines.pop();
337
- if (![head, baseTree, headTree, tree].every((value) => value !== undefined && hash.test(value)))
355
+ const head = lines.shift(), baseTree = lines.pop(), headTree = lines.pop(), indexTree = lines.pop(), tree = lines.pop();
356
+ if (![head, baseTree, headTree, indexTree, tree].every((value) => value !== undefined && hash.test(value)))
338
357
  throw new LifecycleError("writer_inspect_failed");
339
- const refs = lines.map((line) => {
358
+ const refs = [], stashes = [];
359
+ for (const line of lines) {
340
360
  const [name, commit, extra] = line.split(" ");
341
- if (!name || !isSafeRefName(name) || !commit || !hash.test(commit) || extra !== undefined || !(name.startsWith("refs/heads/") || name === "refs/stash"))
361
+ if (!commit || !hash.test(commit) || extra !== undefined)
362
+ throw new LifecycleError("writer_inspect_failed");
363
+ if (name === "stash")
364
+ stashes.push(commit);
365
+ // Only branch names reach a command (as export names); other refs such as a tag `pkg@1.0.0+build` are
366
+ // only compared, so any ref name Git accepts must not make inspection fail.
367
+ else if (name && (name.startsWith("refs/heads/") ? isSafeRefName(name) : name.startsWith("refs/")))
368
+ refs.push({ name, commit });
369
+ else
342
370
  throw new LifecycleError("writer_inspect_failed");
343
- return { name, commit };
344
- });
371
+ }
372
+ const branches = refs.filter(({ name }) => name.startsWith("refs/heads/"));
373
+ // Refs outside branches and the stash are not exported (#202): they must name objects the operator
374
+ // checkout already has, or the clone holds work that only it can recover.
375
+ const others = refs.filter(({ name }) => !name.startsWith("refs/heads/") && name !== "refs/stash");
376
+ const unknownRefs = (await missingObjects(ctx, others.map(({ commit }) => commit))).length;
345
377
  return {
346
- baseline: head === state.base.commit && refs.length === 1 && refs[0].name === state.base.ref && refs[0].commit === state.base.commit && tree === baseTree,
347
- dirty: tree !== headTree, branches: refs.filter(({ name }) => name.startsWith("refs/heads/")),
348
- digest: `sha256:${createHash("sha256").update(result.stdout).digest("hex")}`,
378
+ baseline: head === state.base.commit && branches.length === 1 && branches[0].name === state.base.ref && branches[0].commit === state.base.commit
379
+ && !refs.some(({ name }) => name === "refs/stash") && stashes.length === 0 && unknownRefs === 0 && tree === baseTree && indexTree === baseTree,
380
+ dirty: tree !== headTree || indexTree !== headTree, head: head, branches, stashes, unknownRefs,
381
+ indexTree: indexTree, tree: tree, headTree: headTree,
382
+ digest: `sha256:${createHash("sha256").update("inspect-v2\n").update(result.stdout).digest("hex")}`,
349
383
  };
350
384
  }
351
385
  async function requiredState(ctx, name) {
@@ -379,6 +413,7 @@ async function refreshPrivateFiles(ctx, name, state) {
379
413
  await rm(path, { force: true });
380
414
  await jsonFile(join(paths.config, "writer-guard.json"), { schemaVersion: 1, workspace: ctx.alias, baseRef: state.base.ref, baseCommit: state.base.commit });
381
415
  await jsonFile(join(paths.agent, "auth.json"), {});
416
+ await copyModelsStore(modelsStoreSource(ctx), paths.agent);
382
417
  }
383
418
  export async function collectWriter(ctx, name) {
384
419
  ctx = leasedContext(ctx);
@@ -403,19 +438,49 @@ ${BOUNDARY_GIT} for-each-ref --format='%(refname) %(objectname)' refs/heads | wh
403
438
  ${BOUNDARY_GIT} --git-dir=/tmp/scratch update-ref "$ref" "$commit"
404
439
  fi
405
440
  done
441
+ # Every stash entry, newest first as in stash@{n}, under a derived name (#202). The tip covers a missing reflog.
442
+ if stash=$(${BOUNDARY_GIT} rev-parse -q --verify refs/stash); then
443
+ log=$(${BOUNDARY_GIT} rev-list --walk-reflogs refs/stash)
444
+ n=0 previous=
445
+ for commit in "$stash" $log; do
446
+ if [ "$commit" = "$previous" ]; then continue; fi
447
+ previous=$commit
448
+ if ${BOUNDARY_GIT} --git-dir=/tmp/scratch show-ref --verify --quiet "refs/heads/$4-stash-$n" \\
449
+ || ! ${BOUNDARY_GIT} --git-dir=/tmp/scratch update-ref "refs/heads/$4-stash-$n" "$commit" 2>/dev/null; then
450
+ printf '%s\\n' ref_name_refused
451
+ fi
452
+ n=$((n + 1))
453
+ done
454
+ fi
406
455
  cp .git/index /tmp/index
456
+ cp .git/index /tmp/staged-index
407
457
  export GIT_INDEX_FILE=/tmp/index GIT_OBJECT_DIRECTORY=/tmp/scratch/objects
458
+ staged=$(GIT_INDEX_FILE=/tmp/staged-index ${BOUNDARY_GIT} write-tree)
408
459
  ${BOUNDARY_GIT} add -A
409
460
  tree=$(${BOUNDARY_GIT} write-tree)
461
+ export GIT_AUTHOR_NAME=pi-writer GIT_AUTHOR_EMAIL=pi-writer@example.invalid
462
+ export GIT_COMMITTER_NAME=pi-writer GIT_COMMITTER_EMAIL=pi-writer@example.invalid
463
+ if [ "$staged" != "$(${BOUNDARY_GIT} rev-parse 'HEAD^{tree}')" ] && [ "$staged" != "$tree" ]; then
464
+ commit=$(printf '%s\\n' 'Index collected by pi-session-mcp' | ${BOUNDARY_GIT} commit-tree "$staged" -p HEAD)
465
+ branch=$(${BOUNDARY_GIT} symbolic-ref --short HEAD || printf '%s' "$4")
466
+ if ${BOUNDARY_GIT} --git-dir=/tmp/scratch show-ref --verify --quiet "refs/heads/$branch-index" \\
467
+ || ! ${BOUNDARY_GIT} --git-dir=/tmp/scratch update-ref "refs/heads/$branch-index" "$commit" 2>/dev/null; then
468
+ printf '%s\\n' ref_name_refused
469
+ fi
470
+ fi
410
471
  if [ "$tree" != "$(${BOUNDARY_GIT} rev-parse 'HEAD^{tree}')" ]; then
411
- export GIT_AUTHOR_NAME=pi-writer GIT_AUTHOR_EMAIL=pi-writer@example.invalid
412
- export GIT_COMMITTER_NAME=pi-writer GIT_COMMITTER_EMAIL=pi-writer@example.invalid
413
472
  commit=$(printf '%s\\n' 'WIP collected by pi-session-mcp' | ${BOUNDARY_GIT} commit-tree "$tree" -p HEAD)
414
473
  branch=$(${BOUNDARY_GIT} symbolic-ref --short HEAD || printf '%s' "$4")
415
- if ${BOUNDARY_GIT} --git-dir=/tmp/scratch show-ref --verify --quiet "refs/heads/$branch-wip"; then
474
+ # A taken name, or one that conflicts with an exported branch path, is refused rather than aborting the export.
475
+ if ${BOUNDARY_GIT} --git-dir=/tmp/scratch show-ref --verify --quiet "refs/heads/$branch-wip" \\
476
+ || ! ${BOUNDARY_GIT} --git-dir=/tmp/scratch update-ref "refs/heads/$branch-wip" "$commit" 2>/dev/null; then
477
+ printf '%s\\n' ref_name_refused
478
+ fi
479
+ elif ! ${BOUNDARY_GIT} symbolic-ref -q HEAD >/dev/null 2>&1 && [ -n "$(${BOUNDARY_GIT} rev-list -n 1 HEAD --not --branches)" ]; then
480
+ # Clean detached HEAD that no branch of the clone reaches: export its commit under a derived name (#193).
481
+ if ${BOUNDARY_GIT} --git-dir=/tmp/scratch show-ref --verify --quiet "refs/heads/$4-detached" \\
482
+ || ! ${BOUNDARY_GIT} --git-dir=/tmp/scratch update-ref "refs/heads/$4-detached" "$(${BOUNDARY_GIT} rev-parse --verify HEAD)" 2>/dev/null; then
416
483
  printf '%s\\n' ref_name_refused
417
- else
418
- ${BOUNDARY_GIT} --git-dir=/tmp/scratch update-ref "refs/heads/$branch-wip" "$commit"
419
484
  fi
420
485
  fi
421
486
  if [ -z "$(${BOUNDARY_GIT} --git-dir=/tmp/scratch for-each-ref --format='%(refname)')" ]; then
@@ -450,6 +515,22 @@ fi`;
450
515
  return { status: "failed", code: "writer_collect_incomplete", refs, refused: findings.length, findings };
451
516
  }
452
517
  const work = await inspectWork(ctx, name);
518
+ // Defence in depth (#193): whatever the boundary script exported or reported as nothing to collect,
519
+ // every commit the clone's work refers to must exist as a commit object in the operator checkout
520
+ // before a digest is recorded.
521
+ for (const sha of [work.head, ...work.branches.map((branch) => branch.commit), ...work.stashes]) {
522
+ if (sha === undefined || await git(ctx, ["cat-file", "-e", `${sha}^{commit}`], true) === undefined) {
523
+ return { status: "failed", code: "writer_collect_incomplete", refs, refused: findings.length, findings };
524
+ }
525
+ }
526
+ // Tags, notes and other refs are not exported: their objects must already be in the operator checkout (#202).
527
+ if (work.unknownRefs > 0)
528
+ return { status: "failed", code: "writer_collect_incomplete", refs, refused: findings.length, findings };
529
+ for (const tree of [work.indexTree, work.tree]) {
530
+ if (tree !== work.headTree && await git(ctx, ["cat-file", "-e", `${tree}^{tree}`], true) === undefined) {
531
+ return { status: "failed", code: "writer_collect_incomplete", refs, refused: findings.length, findings };
532
+ }
533
+ }
453
534
  await writeWriterState(ctx.home, name, { ...state, collected: { digest: work.digest, at: new Date().toISOString() } });
454
535
  return { status: "ok", code: refs.length === 0 ? "nothing_to_collect" : "writer_collected", refs, refused: findings.length, findings };
455
536
  }
@@ -471,7 +552,7 @@ async function recreate(ctx, name, state, base) {
471
552
  const next = { ...retained, base: { ref: `refs/heads/${base.short}`, commit: base.commit } };
472
553
  await writeWriterState(ctx.home, name, { ...next, phase: "provisioning" });
473
554
  for (const path of [paths.clone, paths.agent]) {
474
- await rm(path, { recursive: true, force: true });
555
+ await removeWriterTree(path);
475
556
  await privateDirectory(path);
476
557
  }
477
558
  await cloneFromBundle(ctx, paths, state.imageId, base, remote, state.tags);
@@ -560,7 +641,7 @@ export async function removeWriter(ctx, name, opts) {
560
641
  if (!work.baseline && state.collected?.digest !== work.digest)
561
642
  throw new LifecycleError("writer_uncollected");
562
643
  }
563
- await rm(paths.root, { recursive: true, force: true });
644
+ await removeWriterTree(paths.root);
564
645
  return { code: "writer_removed", status: "ok" };
565
646
  }
566
647
  catch (error) {
@@ -0,0 +1,16 @@
1
+ /** File name of Pi's persisted model catalog overlay, next to `auth.json` in an agent directory. */
2
+ export declare const MODELS_STORE_FILE = "models-store.json";
3
+ /** Upper bound for the copied catalog; a larger file is skipped rather than read into memory. */
4
+ export declare const MODELS_STORE_MAX_BYTES: number;
5
+ /** The host's persisted catalog overlay, resolved like the host `auth.json` the launcher reads. */
6
+ export declare const hostModelsStorePath: (home: string) => string;
7
+ /**
8
+ * Copies the host's persisted Pi model catalog into a writer's private agent directory, so the writer's Pi resolves the
9
+ * same models as the host (#199). The file holds model catalog data, not credentials, and is copied byte for byte: it is
10
+ * neither parsed nor printed. A missing, non-regular, symbolic-link or oversized source is skipped, and so is any failure:
11
+ * the destination is then removed, so neither a stale copy nor a file left by a worker in the writable agent directory
12
+ * survives, and the writer keeps the static model list shipped with the SDK. The destination is replaced by an atomic
13
+ * rename of a 0600 temporary file, so a link at the destination path is replaced, never followed. Returns whether a copy
14
+ * was made.
15
+ */
16
+ export declare function copyModelsStore(source: string, agentDir: string): Promise<boolean>;
@@ -0,0 +1,64 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { mkdir, open, rename, unlink } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ /** File name of Pi's persisted model catalog overlay, next to `auth.json` in an agent directory. */
6
+ export const MODELS_STORE_FILE = "models-store.json";
7
+ /** Upper bound for the copied catalog; a larger file is skipped rather than read into memory. */
8
+ export const MODELS_STORE_MAX_BYTES = 32 * 1024 * 1024;
9
+ /** The host's persisted catalog overlay, resolved like the host `auth.json` the launcher reads. */
10
+ export const hostModelsStorePath = (home) => join(home, ".pi", "agent", MODELS_STORE_FILE);
11
+ /**
12
+ * Copies the host's persisted Pi model catalog into a writer's private agent directory, so the writer's Pi resolves the
13
+ * same models as the host (#199). The file holds model catalog data, not credentials, and is copied byte for byte: it is
14
+ * neither parsed nor printed. A missing, non-regular, symbolic-link or oversized source is skipped, and so is any failure:
15
+ * the destination is then removed, so neither a stale copy nor a file left by a worker in the writable agent directory
16
+ * survives, and the writer keeps the static model list shipped with the SDK. The destination is replaced by an atomic
17
+ * rename of a 0600 temporary file, so a link at the destination path is replaced, never followed. Returns whether a copy
18
+ * was made.
19
+ */
20
+ export async function copyModelsStore(source, agentDir) {
21
+ const copied = await copy(source, agentDir);
22
+ if (!copied)
23
+ await unlink(join(agentDir, MODELS_STORE_FILE)).catch(() => undefined);
24
+ return copied;
25
+ }
26
+ async function copy(source, agentDir) {
27
+ let temporary;
28
+ try {
29
+ // O_NOFOLLOW refuses a symbolic link at the source path; the opened file is checked again after open.
30
+ const input = await open(source, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
31
+ let content;
32
+ try {
33
+ const stat = await input.stat();
34
+ if (!stat.isFile() || stat.size > MODELS_STORE_MAX_BYTES)
35
+ return false;
36
+ content = await input.readFile();
37
+ }
38
+ finally {
39
+ await input.close();
40
+ }
41
+ if (content.length > MODELS_STORE_MAX_BYTES)
42
+ return false;
43
+ await mkdir(agentDir, { recursive: true, mode: 0o700 });
44
+ temporary = join(agentDir, `.${MODELS_STORE_FILE}-${randomUUID()}.tmp`);
45
+ const output = await open(temporary, "wx", 0o600);
46
+ try {
47
+ await output.writeFile(content);
48
+ await output.sync();
49
+ }
50
+ finally {
51
+ await output.close();
52
+ }
53
+ await rename(temporary, join(agentDir, MODELS_STORE_FILE));
54
+ temporary = undefined;
55
+ return true;
56
+ }
57
+ catch {
58
+ return false;
59
+ }
60
+ finally {
61
+ if (temporary !== undefined)
62
+ await unlink(temporary).catch(() => undefined);
63
+ }
64
+ }
@@ -2,6 +2,7 @@ import { constants } from "node:fs";
2
2
  import { open } from "node:fs/promises";
3
3
  import { Client } from "@modelcontextprotocol/client";
4
4
  import { z } from "zod";
5
+ import { projectExecutionProfile } from "./capabilities.js";
5
6
  import { ChildStdioTransport } from "./child-stdio-transport.js";
6
7
  import { PiSessionCreationError, WriterUnavailableError } from "./pi-adapter.js";
7
8
  import { RegistryError } from "./session-registry.js";
@@ -71,6 +72,16 @@ const creationCodes = new Set([
71
72
  "extension_not_installed", "extension_version_mismatch", "extension_load_failed", "extension_tool_conflict",
72
73
  "extension_tool_missing", "skill_load_failed", "sandbox_unavailable", "pi_session_creation_failed",
73
74
  ]);
75
+ const exactFields = ["permissionProfile", "provider", "model", "thinkingLevel", "shell"];
76
+ const setFields = ["skills", "extensions", "grantedExtensionTools"];
77
+ const sameSet = (left, right) => {
78
+ const a = new Set(left), b = new Set(right);
79
+ return a.size === left.length && b.size === right.length && a.size === b.size && [...a].every(value => b.has(value));
80
+ };
81
+ /** Admission compares every public capability field; a duplicate on either side is a mismatch. */
82
+ function samePublishedProfile(host, inner) {
83
+ return exactFields.every(key => host[key] === inner[key]) && setFields.every(key => sameSet(host[key], inner[key]));
84
+ }
74
85
  class InnerError extends Error {
75
86
  code;
76
87
  constructor(code) {
@@ -306,7 +317,7 @@ export class WriterRelayAdapter {
306
317
  const capabilities = await connection.call("pi_capabilities_get", {}, capabilitiesSchema, remaining(), input.signal);
307
318
  const inner = capabilities.executionProfiles.find(profile => profile.alias === input.executionProfile.alias);
308
319
  const host = this.options.executionProfiles.get(input.executionProfile.alias);
309
- if (!host || !inner || ["permissionProfile", "provider", "model", "thinkingLevel"].some(key => inner[key] !== host[key])) {
320
+ if (!host || !inner || !samePublishedProfile(projectExecutionProfile(host), inner)) {
310
321
  counts.settingsChanged++;
311
322
  continue;
312
323
  }
@@ -0,0 +1,16 @@
1
+ /** Upper bound for the entries one permission walk visits: a Go module cache holds many thousands. */
2
+ export declare const TREE_WALK_LIMIT = 5000000;
3
+ /**
4
+ * Gives the current user rwx on every real directory below `root` (and on `root` itself) that lacks it. Go creates
5
+ * its module cache with directories that have no owner write bit, and Node's recursive `rm` cannot unlink inside
6
+ * such a directory. Nothing is followed: every entry is `lstat`ed, a symbolic link is never opened or `chmod`ed,
7
+ * and only directories owned by the current user are changed. The walk visits only entries below `root`.
8
+ */
9
+ export declare function makeTreeRemovable(root: string, limit?: number): Promise<void>;
10
+ /**
11
+ * Removes a writer's storage tree. Permissions are restored first. Then everything except the top-level `clone` and
12
+ * `state.json` is deleted, then `clone`, then `state.json`, then the root. A part-way failure therefore keeps
13
+ * `state.json`, and as long as anything else fails also `clone`, so a retry without `--discard` can still inspect the
14
+ * writer instead of finding one that setup can no longer address.
15
+ */
16
+ export declare function removeWriterTree(root: string): Promise<void>;
@@ -0,0 +1,75 @@
1
+ import { chmod, lstat, readdir, rm } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ /** Upper bound for the entries one permission walk visits: a Go module cache holds many thousands. */
4
+ export const TREE_WALK_LIMIT = 5_000_000;
5
+ const OWNER_RWX = 0o700;
6
+ const errorCode = (error) => (error instanceof Error && "code" in error ? String(error.code) : undefined);
7
+ /**
8
+ * Gives the current user rwx on every real directory below `root` (and on `root` itself) that lacks it. Go creates
9
+ * its module cache with directories that have no owner write bit, and Node's recursive `rm` cannot unlink inside
10
+ * such a directory. Nothing is followed: every entry is `lstat`ed, a symbolic link is never opened or `chmod`ed,
11
+ * and only directories owned by the current user are changed. The walk visits only entries below `root`.
12
+ */
13
+ export async function makeTreeRemovable(root, limit = TREE_WALK_LIMIT) {
14
+ const uid = process.getuid?.();
15
+ const pending = [root];
16
+ let visited = 0;
17
+ for (let path = pending.pop(); path !== undefined; path = pending.pop()) {
18
+ if (++visited > limit)
19
+ throw new Error("tree_walk_limit_exceeded");
20
+ let stat;
21
+ try {
22
+ stat = await lstat(path);
23
+ }
24
+ catch (error) {
25
+ if (errorCode(error) === "ENOENT")
26
+ continue;
27
+ throw error;
28
+ }
29
+ if (!stat.isDirectory())
30
+ continue; // Files and symbolic links need no change; removal unlinks a link without following it.
31
+ if (uid !== undefined && stat.uid === uid && (stat.mode & OWNER_RWX) !== OWNER_RWX)
32
+ await chmod(path, (stat.mode & 0o7777) | OWNER_RWX);
33
+ let names;
34
+ try {
35
+ names = await readdir(path);
36
+ }
37
+ catch (error) {
38
+ // A directory of another user stays unreadable; the removal that follows reports it.
39
+ if (errorCode(error) === "EACCES" || errorCode(error) === "ENOENT")
40
+ continue;
41
+ throw error;
42
+ }
43
+ for (const name of names)
44
+ pending.push(join(path, name));
45
+ }
46
+ }
47
+ /**
48
+ * Removes a writer's storage tree. Permissions are restored first. Then everything except the top-level `clone` and
49
+ * `state.json` is deleted, then `clone`, then `state.json`, then the root. A part-way failure therefore keeps
50
+ * `state.json`, and as long as anything else fails also `clone`, so a retry without `--discard` can still inspect the
51
+ * writer instead of finding one that setup can no longer address.
52
+ */
53
+ export async function removeWriterTree(root) {
54
+ let stat;
55
+ try {
56
+ stat = await lstat(root);
57
+ }
58
+ catch (error) {
59
+ if (errorCode(error) === "ENOENT")
60
+ return;
61
+ throw error;
62
+ }
63
+ if (!stat.isDirectory()) {
64
+ await rm(root, { force: true });
65
+ return;
66
+ }
67
+ await makeTreeRemovable(root);
68
+ for (const name of await readdir(root)) {
69
+ if (name !== "state.json" && name !== "clone")
70
+ await rm(join(root, name), { recursive: true, force: true });
71
+ }
72
+ await rm(join(root, "clone"), { recursive: true, force: true });
73
+ await rm(join(root, "state.json"), { force: true });
74
+ await rm(root, { recursive: true, force: true });
75
+ }