pi-session-mcp 0.12.1 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/.pi-session-mcp-cli/client-adapters/mcp-verifier.js +2 -1
  2. package/.pi-session-mcp-cli/config.js +36 -3
  3. package/.pi-session-mcp-cli/doctor.js +52 -4
  4. package/.pi-session-mcp-cli/execution-modes.js +10 -2
  5. package/.pi-session-mcp-cli/execution-profile.js +1 -0
  6. package/.pi-session-mcp-cli/sandbox-shell.js +254 -0
  7. package/.pi-session-mcp-cli/session-tools.js +6 -2
  8. package/.pi-session-mcp-cli/writer-boundary.js +27 -0
  9. package/.pi-session-mcp-cli/writer-lifecycle.js +6 -4
  10. package/.pi-session-mcp-cli/writer-limits.js +19 -0
  11. package/.pi-session-mcp-cli/writer-state.js +5 -2
  12. package/CHANGELOG.md +147 -0
  13. package/CONTRIBUTING.md +8 -1
  14. package/README.md +5 -2
  15. package/dist/capabilities.d.ts +1 -0
  16. package/dist/capabilities.js +3 -1
  17. package/dist/client-adapters/mcp-verifier.js +2 -1
  18. package/dist/config.d.ts +12 -1
  19. package/dist/config.js +36 -3
  20. package/dist/confined-tools.d.ts +30 -0
  21. package/dist/confined-tools.js +122 -0
  22. package/dist/doctor.d.ts +5 -1
  23. package/dist/doctor.js +52 -4
  24. package/dist/execution-modes.d.ts +13 -1
  25. package/dist/execution-modes.js +10 -2
  26. package/dist/execution-profile.d.ts +4 -0
  27. package/dist/execution-profile.js +1 -0
  28. package/dist/main.js +10 -2
  29. package/dist/pi-adapter.d.ts +1 -1
  30. package/dist/pi-adapter.js +1 -0
  31. package/dist/sandbox-shell.d.ts +71 -0
  32. package/dist/sandbox-shell.js +254 -0
  33. package/dist/sdk-pi-adapter.d.ts +17 -1
  34. package/dist/sdk-pi-adapter.js +65 -10
  35. package/dist/session-tools.d.ts +4 -2
  36. package/dist/session-tools.js +6 -2
  37. package/dist/setup-cli.d.ts +1 -2
  38. package/dist/setup-cli.js +40 -9
  39. package/dist/setup-modes.d.ts +3 -0
  40. package/dist/setup-modes.js +18 -1
  41. package/dist/setup-writers-common.d.ts +78 -0
  42. package/dist/setup-writers-common.js +177 -0
  43. package/dist/setup-writers-plan.d.ts +34 -0
  44. package/dist/setup-writers-plan.js +105 -0
  45. package/dist/setup-writers-reconcile.d.ts +8 -0
  46. package/dist/setup-writers-reconcile.js +169 -0
  47. package/dist/setup-writers.d.ts +6 -30
  48. package/dist/setup-writers.js +19 -353
  49. package/dist/tool-schemas.d.ts +4 -0
  50. package/dist/tool-schemas.js +1 -0
  51. package/dist/wake-hook.d.ts +11 -0
  52. package/dist/wake-hook.js +51 -8
  53. package/dist/writer-boundary.d.ts +2 -0
  54. package/dist/writer-boundary.js +27 -0
  55. package/dist/writer-lifecycle.d.ts +1 -0
  56. package/dist/writer-lifecycle.js +6 -4
  57. package/dist/writer-limits.d.ts +13 -0
  58. package/dist/writer-limits.js +19 -0
  59. package/dist/writer-relay.js +1 -1
  60. package/dist/writer-state.d.ts +11 -0
  61. package/dist/writer-state.js +5 -2
  62. package/docs/acceptance.md +271 -0
  63. package/docs/adr/0009-execution-modes.md +3 -2
  64. package/docs/adr/0012-leased-writer-pool.md +3 -3
  65. package/docs/adr/0013-hardened-read-only-profiles.md +184 -0
  66. package/docs/architecture.md +1 -1
  67. package/docs/client-setup.md +3 -3
  68. package/docs/doctor-design.md +3 -1
  69. package/docs/execution-modes.md +60 -4
  70. package/docs/installation.md +25 -1
  71. package/docs/isolation-setup.md +31 -2
  72. package/docs/roadmap.md +14 -0
  73. package/docs/session-mcp.md +15 -0
  74. package/docs/threat-model.md +51 -0
  75. package/docs/tool-contracts.md +33 -4
  76. package/package.json +1 -1
  77. package/skills/pi-session-orchestration/SKILL.md +2 -2
  78. package/skills/pi-session-orchestration/references/start-errors.md +1 -1
@@ -1,11 +1,15 @@
1
- import { createAgentSession, createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, DefaultResourceLoader, ModelRuntime, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
1
+ import { createAgentSession, createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createBashToolDefinition, DefaultResourceLoader, getAgentDir, ModelRuntime, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
2
+ import { homedir, userInfo } from "node:os";
3
+ import { join } from "node:path";
4
+ import { allowedReadRoots, createConfinedReadOnlyTools } from "./confined-tools.js";
2
5
  import { PiExtensionError } from "./extension-spec.js";
3
6
  import { classifyMcpStartupError, connectSessionMcpTools, } from "./mcp-session-tools.js";
4
7
  import { createStdioMcpConnection } from "./mcp-stdio-client.js";
5
8
  import { PiSessionCreationError, } from "./pi-adapter.js";
6
9
  import { resolvePiExtension } from "./pi-extensions.js";
7
10
  import { loadSelectedSkills, PiSkillError } from "./pi-skills.js";
8
- import { composeSessionTools, PROFILE_TOOLS } from "./session-tools.js";
11
+ import { createSandboxedBashOperations, probeSandbox } from "./sandbox-shell.js";
12
+ import { composeSessionTools, profileTools } from "./session-tools.js";
9
13
  export { PROFILE_TOOLS } from "./session-tools.js";
10
14
  export const AUTH_OPERATION_TIMEOUT_MS = 5_000;
11
15
  /** Server-side bound for SDK session construction. The SDK does not document cancellation. */
@@ -326,6 +330,8 @@ export class SdkPiSessionAdapter {
326
330
  mcpConnectionFactory;
327
331
  resolveExtension;
328
332
  extensionSdk;
333
+ sandbox;
334
+ protectedReadPaths;
329
335
  constructor(options = {}) {
330
336
  this.modelRuntimeFactory = options.modelRuntimeFactory ?? (() => ModelRuntime.create({ refreshOnCreate: false }));
331
337
  this.createSession = options.createSession ?? createAgentSession;
@@ -333,6 +339,16 @@ export class SdkPiSessionAdapter {
333
339
  this.mcpConnectionFactory = options.mcpConnectionFactory ?? createStdioMcpConnection;
334
340
  this.resolveExtension = options.resolveExtension ?? ((extension) => resolvePiExtension(extension));
335
341
  this.extensionSdk = options.extensionSdk ?? DEFAULT_EXTENSION_SDK;
342
+ this.sandbox = options.sandbox ?? {};
343
+ // The account database's home, not `$HOME`, which a caller's environment could point elsewhere.
344
+ const home = (() => { try {
345
+ return userInfo().homedir;
346
+ }
347
+ catch {
348
+ return homedir();
349
+ } })();
350
+ // Pi's agent directory holds its credentials; the SDK resolves it from `PI_CODING_AGENT_DIR` or below `~/.pi`.
351
+ this.protectedReadPaths = [home, join(home, ".pi"), getAgentDir(), ...(options.protectedReadPaths ?? [])];
336
352
  }
337
353
  async create(input) {
338
354
  const extensions = input.executionProfile.extensions ?? [];
@@ -356,8 +372,10 @@ export class SdkPiSessionAdapter {
356
372
  catch {
357
373
  throw this.failure(executionProfile, "resource_loader", "pi_session_creation_failed");
358
374
  }
375
+ const confined = await this.confinedTools(input, skills);
359
376
  const mcpTools = await this.connectMcpTools(input);
360
- const tools = [...PROFILE_TOOLS[executionProfile.permissionProfile], ...mcpTools.tools.map((tool) => tool.name)];
377
+ const tools = [...profileTools(executionProfile.permissionProfile, executionProfile.shell), ...mcpTools.tools.map((tool) => tool.name)];
378
+ const customTools = [...confined, ...mcpTools.tools];
361
379
  let session;
362
380
  try {
363
381
  ({ session } = await withCreationDeadline(() => this.createSession({
@@ -370,14 +388,14 @@ export class SdkPiSessionAdapter {
370
388
  resourceLoader,
371
389
  // With an explicit tools list, Pi also needs custom tool names here to activate them.
372
390
  tools,
373
- ...(mcpTools.tools.length === 0 ? {} : { customTools: mcpTools.tools }),
391
+ ...(customTools.length === 0 ? {} : { customTools }),
374
392
  }), (late) => disposeSdkSession(late.session)));
375
393
  }
376
394
  catch {
377
395
  await mcpTools.close();
378
396
  throw this.failure(executionProfile, "create_session", "pi_session_creation_failed");
379
397
  }
380
- return this.admit(session, executionProfile, tools, mcpTools, () => disposeSdkSession(session), mcpTools.tools.length > 0 ? "external_mcp_activation_mismatch" : undefined, "exact", skills);
398
+ return this.admit(session, executionProfile, tools, mcpTools, () => disposeSdkSession(session), mcpTools.tools.length > 0 ? "external_mcp_activation_mismatch" : undefined, "exact", skills, [], confined);
381
399
  }
382
400
  async createWithExtensions(input, extensions) {
383
401
  const { cwd, executionProfile } = input;
@@ -423,9 +441,11 @@ export class SdkPiSessionAdapter {
423
441
  throw this.failure(executionProfile, "extension_load", "extension_load_failed");
424
442
  }
425
443
  const model = await this.resolveVerifiedModel(services.modelRuntime, executionProfile);
444
+ const confined = await this.confinedTools(input, skills);
426
445
  const mcpTools = await this.connectMcpTools(input);
446
+ const customTools = [...confined, ...mcpTools.tools];
427
447
  const grantedTools = executionProfile.grantedExtensionTools ?? [];
428
- const composed = composeSessionTools(executionProfile.permissionProfile, mcpTools.tools.map((tool) => tool.name), extensionTools, grantedTools);
448
+ const composed = composeSessionTools(executionProfile.permissionProfile, mcpTools.tools.map((tool) => tool.name), extensionTools, grantedTools, executionProfile.shell);
429
449
  if (!composed.ok) {
430
450
  await mcpTools.close();
431
451
  throw this.failure(executionProfile, "extension_load", composed.code);
@@ -440,7 +460,7 @@ export class SdkPiSessionAdapter {
440
460
  model,
441
461
  thinkingLevel: executionProfile.thinkingLevel,
442
462
  tools,
443
- ...(mcpTools.tools.length === 0 ? {} : { customTools: mcpTools.tools }),
463
+ ...(customTools.length === 0 ? {} : { customTools }),
444
464
  })),
445
465
  services,
446
466
  diagnostics: services.diagnostics,
@@ -474,7 +494,7 @@ export class SdkPiSessionAdapter {
474
494
  // A `session_start` handler may register a tool that replaces a built-in or granted tool.
475
495
  // Tools an extension registers later, during a turn, are not re-checked: allowlisted
476
496
  // extensions are trusted code.
477
- const recheck = composeSessionTools(executionProfile.permissionProfile, mcpTools.tools.map((tool) => tool.name), registeredExtensionTools(services), grantedTools);
497
+ const recheck = composeSessionTools(executionProfile.permissionProfile, mcpTools.tools.map((tool) => tool.name), registeredExtensionTools(services), grantedTools, executionProfile.shell);
478
498
  if (!recheck.ok)
479
499
  throw this.failure(executionProfile, "extension_bind", recheck.code);
480
500
  if (recheck.tools.length !== tools.length || recheck.tools.some((name, index) => name !== tools[index]))
@@ -490,7 +510,35 @@ export class SdkPiSessionAdapter {
490
510
  await cleanUp();
491
511
  throw this.failure(executionProfile, "extension_bind", "extension_tool_conflict");
492
512
  }
493
- return this.admit(sessionRuntime.session, executionProfile, tools, mcpTools, disposeRuntime, mcpTools.tools.length > 0 ? "external_mcp_activation_mismatch" : "execution_selection_mismatch", "subset", skills, grantedTools);
513
+ return this.admit(sessionRuntime.session, executionProfile, tools, mcpTools, disposeRuntime, mcpTools.tools.length > 0 ? "external_mcp_activation_mismatch" : "execution_selection_mismatch", "subset", skills, grantedTools, confined);
514
+ }
515
+ /**
516
+ * The server's own tool definitions of a read-only session (ADR 0013); none for coding. The workspace
517
+ * root is the session directory: a root that cannot be resolved fails the start. With a sandboxed shell
518
+ * the sandbox is probed first, and a profile whose sandbox cannot start never gets a session.
519
+ */
520
+ async confinedTools(input, skills) {
521
+ const { executionProfile } = input;
522
+ if (executionProfile.permissionProfile !== "read-only")
523
+ return [];
524
+ let roots;
525
+ try {
526
+ roots = await allowedReadRoots(input.cwd, skills, this.protectedReadPaths);
527
+ }
528
+ catch {
529
+ throw this.failure(executionProfile, "create_session", "pi_session_creation_failed");
530
+ }
531
+ const tools = createConfinedReadOnlyTools(input.cwd, roots);
532
+ if (executionProfile.shell === "sandboxed") {
533
+ // The first root is the real path of the workspace.
534
+ if (!(await probeSandbox(roots[0], this.sandbox, input.signal)))
535
+ throw this.failure(executionProfile, "sandbox", "sandbox_unavailable");
536
+ tools.push(createBashToolDefinition(input.cwd, {
537
+ operations: createSandboxedBashOperations(roots[0], this.sandbox),
538
+ exposeSessionEnvironment: false,
539
+ }));
540
+ }
541
+ return tools;
494
542
  }
495
543
  async loadSkills(input) {
496
544
  try {
@@ -581,9 +629,16 @@ export class SdkPiSessionAdapter {
581
629
  /** Post-verifies a created session and wraps it; a mismatch disposes it before reporting. */
582
630
  async admit(session, executionProfile, tools, mcpTools, disposeSession, toolMismatch,
583
631
  // `subset` lets an extension narrow its own active tools; nothing outside the allowlist may be active.
584
- toolCheck = "exact", skills = [], requiredTools = []) {
632
+ toolCheck = "exact", skills = [], requiredTools = [],
633
+ // The server's own definitions must be the ones the session resolves for their names.
634
+ ownDefinitions = []) {
585
635
  try {
586
636
  this.checkResources(session.resourceLoader, skills, executionProfile, "postcheck");
637
+ for (const definition of ownDefinitions) {
638
+ if (session.getToolDefinition(definition.name) !== definition) {
639
+ throw this.failure(executionProfile, "postcheck", "pi_session_creation_failed");
640
+ }
641
+ }
587
642
  if (session.model?.provider !== executionProfile.provider || session.model.id !== executionProfile.model) {
588
643
  throw this.failure(executionProfile, "postcheck", "execution_selection_mismatch");
589
644
  }
@@ -1,5 +1,7 @@
1
- import type { PermissionProfile } from "./execution-profile.js";
1
+ import type { PermissionProfile, ShellMode } from "./execution-profile.js";
2
2
  export declare const PROFILE_TOOLS: Readonly<Record<PermissionProfile, readonly string[]>>;
3
+ /** The built-in tools of a profile: a read-only profile with a sandboxed shell also has `bash`. */
4
+ export declare function profileTools(permissionProfile: PermissionProfile, shell?: ShellMode): readonly string[];
3
5
  export declare const MAX_GRANTED_EXTENSION_TOOLS = 32;
4
6
  export declare function isAcceptableExtensionToolName(name: string): boolean;
5
7
  export type ComposedSessionTools = {
@@ -15,4 +17,4 @@ export type ComposedSessionTools = {
15
17
  * in both profiles so an extension can never shadow a built-in or granted MCP tool.
16
18
  * Unsafe names and names the SDK treats as MCP tools also fail closed.
17
19
  */
18
- export declare function composeSessionTools(permissionProfile: PermissionProfile, mcpToolNames: readonly string[], extensionToolNames: readonly (readonly string[])[], grantedExtensionTools?: readonly string[]): ComposedSessionTools;
20
+ export declare function composeSessionTools(permissionProfile: PermissionProfile, mcpToolNames: readonly string[], extensionToolNames: readonly (readonly string[])[], grantedExtensionTools?: readonly string[], shell?: ShellMode): ComposedSessionTools;
@@ -2,6 +2,10 @@ export const PROFILE_TOOLS = {
2
2
  "read-only": ["read", "grep", "find", "ls"],
3
3
  coding: ["read", "bash", "edit", "write", "grep", "find", "ls"],
4
4
  };
5
+ /** The built-in tools of a profile: a read-only profile with a sandboxed shell also has `bash`. */
6
+ export function profileTools(permissionProfile, shell) {
7
+ return permissionProfile === "read-only" && shell === "sandboxed" ? [...PROFILE_TOOLS["read-only"], "bash"] : PROFILE_TOOLS[permissionProfile];
8
+ }
5
9
  export const MAX_GRANTED_EXTENSION_TOOLS = 32;
6
10
  /**
7
11
  * Letters, digits, `_` and `-`, at most 64 characters, which fits the common provider
@@ -27,7 +31,7 @@ export function isAcceptableExtensionToolName(name) {
27
31
  * in both profiles so an extension can never shadow a built-in or granted MCP tool.
28
32
  * Unsafe names and names the SDK treats as MCP tools also fail closed.
29
33
  */
30
- export function composeSessionTools(permissionProfile, mcpToolNames, extensionToolNames, grantedExtensionTools = []) {
34
+ export function composeSessionTools(permissionProfile, mcpToolNames, extensionToolNames, grantedExtensionTools = [], shell) {
31
35
  const reserved = new Set([...PROFILE_TOOLS.coding, ...PROFILE_TOOLS["read-only"], ...mcpToolNames]);
32
36
  const extensionTools = [];
33
37
  for (const names of extensionToolNames) {
@@ -41,5 +45,5 @@ export function composeSessionTools(permissionProfile, mcpToolNames, extensionTo
41
45
  if (permissionProfile === "read-only" && grantedExtensionTools.some((name) => !extensionTools.includes(name))) {
42
46
  return { ok: false, code: "extension_tool_missing" };
43
47
  }
44
- return { ok: true, tools: [...PROFILE_TOOLS[permissionProfile], ...mcpToolNames, ...(permissionProfile === "coding" ? extensionTools : grantedExtensionTools)] };
48
+ return { ok: true, tools: [...profileTools(permissionProfile, shell), ...mcpToolNames, ...(permissionProfile === "coding" ? extensionTools : grantedExtensionTools)] };
45
49
  }
@@ -1,4 +1,4 @@
1
- import { SetupOrchestrator, type SetupRequest } from "./setup.js";
1
+ import type { SetupRequest } from "./setup.js";
2
2
  import type { SetupResult } from "./setup-result.js";
3
3
  import type { ModeRequest } from "./setup-modes.js";
4
4
  import type { WriterOperation } from "./setup-writers.js";
@@ -21,7 +21,6 @@ export interface SetupCliRequest extends SetupRequest {
21
21
  };
22
22
  }
23
23
  export declare function parseSetupArgs(argv: readonly string[]): SetupCliRequest;
24
- export declare function runSetupCli(argv: readonly string[], orchestrator: SetupOrchestrator): Promise<SetupResult>;
25
24
  /** Both report modes are projections of the same sanitized result. */
26
25
  export declare function formatSetupResult(setupResult: SetupResult): string;
27
26
  export declare function formatSetupHuman(setupResult: SetupResult): string;
package/dist/setup-cli.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { cpusSchema, cpuSharesSchema, memorySchema } from "./writer-limits.js";
1
2
  import { WRITER_NAME_PATTERN } from "./writer-state.js";
2
3
  export function parseSetupArgs(argv) {
3
4
  let operation;
@@ -11,6 +12,7 @@ export function parseSetupArgs(argv) {
11
12
  let discard = false, codex = false, isolationControl = false;
12
13
  const container = {};
13
14
  const modeFlags = new Set();
15
+ const clearLimits = [];
14
16
  for (let index = 0; index < argv.length; index += 1) {
15
17
  const argument = argv[index];
16
18
  if (["--collect", "--reset", "--recreate", "--finish"].includes(argument)) {
@@ -81,7 +83,7 @@ export function parseSetupArgs(argv) {
81
83
  customizeSkill = value;
82
84
  continue;
83
85
  }
84
- if (["--repo", "--mode", "--accept-host-writes", "--writers", "--lifecycle", "--profiles", "--image", "--network", "--cache", "--tags"].includes(argument)) {
86
+ if (["--repo", "--mode", "--accept-host-writes", "--writers", "--lifecycle", "--profiles", "--image", "--network", "--cache", "--tags", "--clean", "--cpus", "--cpu-shares", "--memory"].includes(argument)) {
85
87
  if (modeFlags.has(argument))
86
88
  throw new Error("repo_argument_repeated");
87
89
  modeFlags.add(argument);
@@ -89,8 +91,8 @@ export function parseSetupArgs(argv) {
89
91
  acceptHostWrites = true;
90
92
  continue;
91
93
  }
92
- const value = argv[++index];
93
- if (!value || value.startsWith("-"))
94
+ const value = argv[++index] ?? "";
95
+ if ((!value || value.startsWith("-")) && !["--cpus", "--cpu-shares", "--memory"].includes(argument))
94
96
  throw new Error("repo_argument_invalid");
95
97
  switch (argument) {
96
98
  case "--repo":
@@ -129,8 +131,40 @@ export function parseSetupArgs(argv) {
129
131
  throw new Error("tags_invalid");
130
132
  container.tags = value;
131
133
  break;
134
+ case "--clean":
135
+ if (value !== "untracked" && value !== "all")
136
+ throw new Error("clean_invalid");
137
+ container.clean = value;
138
+ break;
139
+ case "--cpus":
140
+ if (value === "none") {
141
+ clearLimits.push("cpus");
142
+ break;
143
+ }
144
+ if (!value || !/^[0-9]+(?:\.[0-9]{1,2})?$/.test(value) || !cpusSchema.safeParse(Number(value)).success)
145
+ throw new Error("cpus_invalid");
146
+ container.cpus = Number(value);
147
+ break;
148
+ case "--cpu-shares":
149
+ if (value === "none") {
150
+ clearLimits.push("cpuShares");
151
+ break;
152
+ }
153
+ if (!value || !/^[0-9]+$/.test(value) || !cpuSharesSchema.safeParse(Number(value)).success)
154
+ throw new Error("cpu_shares_invalid");
155
+ container.cpuShares = Number(value);
156
+ break;
157
+ case "--memory":
158
+ if (value === "none") {
159
+ clearLimits.push("memory");
160
+ break;
161
+ }
162
+ if (!memorySchema.safeParse(value).success)
163
+ throw new Error("memory_invalid");
164
+ container.memory = value;
165
+ break;
132
166
  case "--cache":
133
- if (value !== "none" && value !== "go" && value !== "uv")
167
+ if (value !== "none" && value !== "go" && value !== "uv" && value !== "npm")
134
168
  throw new Error("cache_invalid");
135
169
  container.cache = value;
136
170
  break;
@@ -163,9 +197,9 @@ export function parseSetupArgs(argv) {
163
197
  if (modeFlags.size > 0) {
164
198
  if (repo === undefined || targets.length > 0 || customizeSkill !== undefined || (operation !== undefined && operation !== "dry-run" && operation !== "apply"))
165
199
  throw new Error("repo_combination");
166
- if ((mode === "host" || mode === "read-only") && Object.keys(container).length > 0)
200
+ if ((mode === "host" || mode === "read-only") && (Object.keys(container).length > 0 || clearLimits.length > 0))
167
201
  throw new Error("repo_combination");
168
- const request = { alias: repo, acceptHostWrites, apply: operation === "apply", discard, codex, ...(baseRef ? { baseRef } : {}), ...(mode === undefined ? {} : { mode }), ...(Object.keys(container).length === 0 ? {} : { container }) };
202
+ const request = { alias: repo, acceptHostWrites, apply: operation === "apply", discard, codex, ...(baseRef ? { baseRef } : {}), ...(mode === undefined ? {} : { mode }), ...(Object.keys(container).length === 0 ? {} : { container }), ...(clearLimits.length === 0 ? {} : { clearLimits }) };
169
203
  return { ...(operation === undefined ? {} : { operation }), targets, json, repo: request };
170
204
  }
171
205
  if (customizeSkill !== undefined) {
@@ -176,9 +210,6 @@ export function parseSetupArgs(argv) {
176
210
  }
177
211
  return operation ? { operation, targets, json } : { targets, json };
178
212
  }
179
- export async function runSetupCli(argv, orchestrator) {
180
- return orchestrator.run(parseSetupArgs(argv));
181
- }
182
213
  /** Both report modes are projections of the same sanitized result. */
183
214
  export function formatSetupResult(setupResult) {
184
215
  return JSON.stringify(setupResult);
@@ -9,9 +9,12 @@ export interface ModeRequest {
9
9
  readonly discard?: boolean;
10
10
  readonly baseRef?: string;
11
11
  readonly codex?: boolean;
12
+ /** Limits to remove (`--cpus none` and so on); an omitted limit keeps the recorded one (#166). */
13
+ readonly clearLimits?: readonly ("cpus" | "cpuShares" | "memory")[];
12
14
  }
13
15
  /** A dry run writes nothing; apply serializes the entire read/validate/replace transaction. */
14
16
  export declare function changeExecutionMode(configPath: string, request: ModeRequest, dependencies?: {
17
+ cgroupControllers?: () => Promise<readonly string[]>;
15
18
  containerRuntime?: () => Promise<boolean>;
16
19
  beforeWrite?: (entry: import("./execution-modes.js").WorkspaceModeEntry) => Promise<SetupResult | undefined>;
17
20
  afterChange?: (output: SetupResult) => Promise<SetupResult>;
@@ -1,3 +1,6 @@
1
+ import { podmanCgroupControllers } from "./writer-boundary.js";
2
+ import { createProcessRunner } from "./setup-process.js";
3
+ import { writerLimits } from "./writer-limits.js";
1
4
  import { randomUUID } from "node:crypto";
2
5
  import { lstat, mkdir, open, readFile, rename, unlink } from "node:fs/promises";
3
6
  import { dirname, join } from "node:path";
@@ -65,7 +68,14 @@ export async function changeExecutionMode(configPath, request, dependencies = {}
65
68
  const previousBase = existing?.mode === "container" ? existing.base : undefined;
66
69
  const requestedBase = request.baseRef?.replace(/^refs\/heads\//, "");
67
70
  const base = requestedBase ?? previousBase ?? request.container?.base;
68
- return { ...request.container, mode, ...(base === undefined ? {} : { base }) };
71
+ // Limits are outside the settings drift check, so an omitted flag must not drop one silently (#166).
72
+ const limits = Object.fromEntries(["cpus", "cpuShares", "memory"].flatMap((key) => {
73
+ if (request.clearLimits?.includes(key))
74
+ return [];
75
+ const value = request.container?.[key] ?? (existing?.mode === "container" ? existing[key] : undefined);
76
+ return value === undefined ? [] : [[key, value]];
77
+ }));
78
+ return { ...request.container, ...limits, mode, ...(base === undefined ? {} : { base }) };
69
79
  })() : { mode };
70
80
  let proposed;
71
81
  try {
@@ -76,6 +86,13 @@ export async function changeExecutionMode(configPath, request, dependencies = {}
76
86
  return done("failed", "modes_file_invalid");
77
87
  }
78
88
  const resolved = resolveExecutionModes(proposed, aliases, config.executionProfiles).get(request.alias);
89
+ const limits = resolved.container && writerLimits(resolved.container);
90
+ if (limits) {
91
+ const controllers = await (dependencies.cgroupControllers ?? (() => podmanCgroupControllers(createProcessRunner())))().catch(() => []);
92
+ if (((limits.cpus !== undefined || limits.cpuShares !== undefined) && !controllers.includes("cpu"))
93
+ || (limits.memory !== undefined && !controllers.includes("memory")))
94
+ return done("failed", "cgroup_controller_unavailable");
95
+ }
79
96
  const refused = await dependencies.beforeWrite?.(resolved);
80
97
  if (refused)
81
98
  return refused;
@@ -0,0 +1,78 @@
1
+ import { runDoctor } from "./doctor.js";
2
+ import { type WorkspaceModeEntry } from "./execution-modes.js";
3
+ import { acquireOverrideLock } from "./override-lock.js";
4
+ import { createCommandMcpStdioLauncher } from "./setup-process.js";
5
+ import { DurableOwnershipStore, type OwnershipStore } from "./setup-ownership.js";
6
+ import { type WakeHookOutcome } from "./setup-hooks.js";
7
+ import type { SetupFinding, SetupTarget } from "./setup-result.js";
8
+ import { type WriterRegistrationIntent, type WriterRegistrationAdapter } from "./client-adapters/writer-registration.js";
9
+ import { verifyMcpCapabilities } from "./client-adapters/mcp-verifier.js";
10
+ import { acquireWriterLease } from "./writer-lease.js";
11
+ import { ensureWriterAbsent } from "./writer-fence.js";
12
+ import * as lifecycle from "./writer-lifecycle.js";
13
+ import * as acceptance from "./writer-acceptance.js";
14
+ /** Services, fencing, the Doctor gate and removal shared by mode changes, reconciliation and single-writer operations. */
15
+ export declare const SETUP_LEASE_WAIT_MS = 10000;
16
+ export declare const FENCE_CODES: readonly string[];
17
+ /**
18
+ * Failures of shared setup state rather than of one writer: they end the whole reconciliation (#180). The ownership
19
+ * store's own failures count; `ownership_unavailable` and `ownership_diverged` concern one writer's registration.
20
+ */
21
+ export declare const INFRASTRUCTURE_CODE: RegExp;
22
+ export declare const GLOBAL_ERRORS: Set<string>;
23
+ export interface WriterSetupDependencies {
24
+ configPath: string;
25
+ releaseRoot: string;
26
+ home?: string;
27
+ platform?: NodeJS.Platform;
28
+ signal?: AbortSignal;
29
+ runner?: lifecycle.WriterContext["runner"];
30
+ lifecycle?: typeof lifecycle;
31
+ acceptance?: typeof acceptance;
32
+ ownership?: OwnershipStore;
33
+ adapter?: WriterRegistrationAdapter;
34
+ doctor?: typeof runDoctor;
35
+ verify?: typeof verifyMcpCapabilities;
36
+ launch?: typeof createCommandMcpStdioLauncher;
37
+ lock?: typeof acquireOverrideLock;
38
+ lease?: typeof acquireWriterLease;
39
+ ensureAbsent?: typeof ensureWriterAbsent;
40
+ containerRuntime?: () => Promise<boolean>;
41
+ }
42
+ export declare const targetFor: (alias: string, name: string) => SetupTarget;
43
+ export declare const modesTarget: (alias: string) => SetupTarget;
44
+ export declare const codeFor: (error: unknown) => string;
45
+ export declare function services(deps: WriterSetupDependencies): {
46
+ home: string;
47
+ runner: import("./client-adapters/types.js").CommandRunner;
48
+ life: typeof lifecycle;
49
+ accept: typeof acceptance;
50
+ ownership: DurableOwnershipStore | OwnershipStore;
51
+ adapter: WriterRegistrationAdapter;
52
+ };
53
+ export declare function context(alias: string, deps: WriterSetupDependencies): Promise<lifecycle.WriterContext>;
54
+ export declare function locked<T>(deps: WriterSetupDependencies, action: () => Promise<T>): Promise<T>;
55
+ export declare function fenced<T>(ctx: lifecycle.WriterContext, name: string, deps: WriterSetupDependencies, action: (ctx: lifecycle.WriterContext) => Promise<T>): Promise<T>;
56
+ export declare function intent(ctx: lifecycle.WriterContext, name: string, client: "claude-code" | "codex"): WriterRegistrationIntent;
57
+ /**
58
+ * The wake hook of a writer's Claude Code registration, in the local settings of the operator's
59
+ * project. Claude Code only: a Codex writer has no hook. The writer's own configuration decides
60
+ * whether delivery is wanted; the entry is shared per project, see `localHookNeededByOthers`.
61
+ * Findings without news (`hook_not_enabled`, `hook_not_owned`) are not reported.
62
+ */
63
+ export declare function writerHook(ctx: lifecycle.WriterContext, name: string, deps: WriterSetupDependencies, operation: "apply" | "dry-run" | "verify" | "remove", enabled: boolean): Promise<WakeHookOutcome | undefined>;
64
+ export declare class DoctorGateError extends Error {
65
+ readonly findings: readonly SetupFinding[];
66
+ constructor(findings: readonly SetupFinding[]);
67
+ }
68
+ export declare function doctorGate(ctx: lifecycle.WriterContext, deps: WriterSetupDependencies): Promise<void>;
69
+ export declare function preflight(ctx: lifecycle.WriterContext, names: readonly string[], discard: boolean, deps: WriterSetupDependencies): Promise<void>;
70
+ export declare function remove(ctx: lifecycle.WriterContext, name: string, discard: boolean, deps: WriterSetupDependencies, extras?: WakeHookOutcome[]): Promise<lifecycle.WriterOutcome>;
71
+ export declare const writerIndex: (name: string) => number;
72
+ /** Which writers a mode entry keeps, which of those still need provisioning, and which existing writers it no longer wants. */
73
+ export declare function plan(ctx: lifecycle.WriterContext, entry: WorkspaceModeEntry): Promise<{
74
+ keep: string[];
75
+ missing: string[];
76
+ surplus: string[];
77
+ }>;
78
+ export type WriterPlan = Awaited<ReturnType<typeof plan>>;
@@ -0,0 +1,177 @@
1
+ import { chmod, mkdir } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { loadConfig } from "./config.js";
5
+ import { ALIAS_PATTERN } from "./execution-profile.js";
6
+ import { runDoctor } from "./doctor.js";
7
+ import { MAX_WRITERS } from "./execution-modes.js";
8
+ import { acquireOverrideLock, OverrideLockError } from "./override-lock.js";
9
+ import { createProcessRunner } from "./setup-process.js";
10
+ import { DurableOwnershipStore } from "./setup-ownership.js";
11
+ import { localHookNeededByOthers, ownsWakeHook, runWakeHook } from "./setup-hooks.js";
12
+ import { createWriterRegistrationAdapter, unregisterWriter } from "./client-adapters/writer-registration.js";
13
+ import { IMAGE_PULL_TIMEOUT_MS } from "./writer-boundary.js";
14
+ import { acquireWriterLease, WriterLeaseError } from "./writer-lease.js";
15
+ import { ensureWriterAbsent } from "./writer-fence.js";
16
+ import * as lifecycle from "./writer-lifecycle.js";
17
+ import * as acceptance from "./writer-acceptance.js";
18
+ import { readWriterState, writerName, writerPaths, writerRegistrationName, writersRoot, WRITER_NAME_PATTERN } from "./writer-state.js";
19
+ /** Services, fencing, the Doctor gate and removal shared by mode changes, reconciliation and single-writer operations. */
20
+ export const SETUP_LEASE_WAIT_MS = 10_000;
21
+ export const FENCE_CODES = ["writer_busy", "writer_fenced", "writer_lease_unavailable"];
22
+ /**
23
+ * Failures of shared setup state rather than of one writer: they end the whole reconciliation (#180). The ownership
24
+ * store's own failures count; `ownership_unavailable` and `ownership_diverged` concern one writer's registration.
25
+ */
26
+ export const INFRASTRUCTURE_CODE = /^(?:ownership_(?!unavailable$|diverged$)[a-z_]+|config_[a-z_]+|operation_aborted|platform_unsupported)$/;
27
+ export const GLOBAL_ERRORS = new Set(["node_version_unsupported", "package_metadata_unreadable", "build_missing", "build_stale", "version_mismatch", "config_missing", "config_unreadable", "config_invalid"]);
28
+ const safeAlias = (alias) => ALIAS_PATTERN.test(alias) ? alias : "invalid";
29
+ export const targetFor = (alias, name) => ({ client: "writer", scope: safeAlias(alias), alias: WRITER_NAME_PATTERN.test(name) || (alias === "host" && name === "control") ? name : "invalid" });
30
+ export const modesTarget = (alias) => ({ client: "modes", scope: "workspace", alias: safeAlias(alias) });
31
+ export const codeFor = (error) => error instanceof OverrideLockError || error instanceof WriterLeaseError ? error.code : error instanceof Error && /^[a-z][a-z0-9_]{0,63}$/.test(error.message) ? error.message : "writer_operation_failed";
32
+ export function services(deps) {
33
+ const home = deps.home ?? homedir();
34
+ const runner = deps.runner ?? createProcessRunner({ timeoutMs: IMAGE_PULL_TIMEOUT_MS, maxOutputBytes: 1_048_576, ...(deps.signal ? { signal: deps.signal } : {}) });
35
+ return { home, runner, life: deps.lifecycle ?? lifecycle, accept: deps.acceptance ?? acceptance,
36
+ ownership: deps.ownership ?? new DurableOwnershipStore(join(home, ".local", "state", "pi-session-mcp", "ownership.json"), { ...(deps.signal ? { signal: deps.signal } : {}) }),
37
+ adapter: deps.adapter ?? createWriterRegistrationAdapter(runner) };
38
+ }
39
+ export async function context(alias, deps) {
40
+ const config = await loadConfig(deps.configPath);
41
+ const checkout = config.workspaces.get(alias);
42
+ if (!checkout)
43
+ throw new Error("alias_unknown");
44
+ const { home, runner } = services(deps);
45
+ return { home, runner, checkout, alias, releaseRoot: deps.releaseRoot, nodePath: process.execPath };
46
+ }
47
+ export async function locked(deps, action) {
48
+ const directory = writersRoot(deps.home ?? homedir());
49
+ await mkdir(directory, { recursive: true, mode: 0o700 });
50
+ await chmod(directory, 0o700);
51
+ const lock = await (deps.lock ?? acquireOverrideLock)(directory, { ...(deps.signal ? { signal: deps.signal } : {}) });
52
+ try {
53
+ return await action();
54
+ }
55
+ finally {
56
+ await lock.release();
57
+ }
58
+ }
59
+ export async function fenced(ctx, name, deps, action) {
60
+ let lease;
61
+ try {
62
+ lease = await (deps.lease ?? acquireWriterLease)(ctx.home, name, { waitMs: SETUP_LEASE_WAIT_MS, ...(deps.signal ? { signal: deps.signal } : {}) });
63
+ }
64
+ catch (error) {
65
+ throw new Error(error instanceof WriterLeaseError && error.code === "writer_busy" ? "writer_busy" : "writer_lease_unavailable");
66
+ }
67
+ try {
68
+ try {
69
+ await (deps.ensureAbsent ?? ensureWriterAbsent)(ctx.runner, writerPaths(ctx.home, name), name, { leaseFd: lease.fd });
70
+ }
71
+ catch {
72
+ throw new Error("writer_fenced");
73
+ }
74
+ return await action({ ...ctx, leaseFd: lease.fd });
75
+ }
76
+ finally {
77
+ await lease.release();
78
+ }
79
+ }
80
+ export function intent(ctx, name, client) {
81
+ return { client, name: writerRegistrationName(name), wrapperPath: writerPaths(ctx.home, name).wrapper, projectDir: ctx.checkout };
82
+ }
83
+ /**
84
+ * The wake hook of a writer's Claude Code registration, in the local settings of the operator's
85
+ * project. Claude Code only: a Codex writer has no hook. The writer's own configuration decides
86
+ * whether delivery is wanted; the entry is shared per project, see `localHookNeededByOthers`.
87
+ * Findings without news (`hook_not_enabled`, `hook_not_owned`) are not reported.
88
+ */
89
+ export async function writerHook(ctx, name, deps, operation, enabled) {
90
+ const { ownership } = services(deps);
91
+ const roots = { packageRoot: deps.releaseRoot, home: ctx.home, cwd: ctx.checkout, env: process.env, nodePath: ctx.nodePath };
92
+ // Nothing wanted and nothing owned: not even the project's settings are looked at.
93
+ if (!enabled && !await ownsWakeHook("local", roots, ownership))
94
+ return undefined;
95
+ const request = (access, op) => runWakeHook({
96
+ operation: op, scope: "local", enabled, ownership: access,
97
+ roots,
98
+ keep: () => localHookNeededByOthers({ ownership: access, home: ctx.home, cwd: ctx.checkout, configPath: deps.configPath, except: { writer: name } }),
99
+ });
100
+ const outcome = operation === "apply" || operation === "remove" ? await ownership.transaction((access) => request(access, operation)) : await request(ownership, operation);
101
+ return outcome.code === "hook_not_enabled" || outcome.code === "hook_not_owned" ? undefined : outcome;
102
+ }
103
+ export class DoctorGateError extends Error {
104
+ findings;
105
+ constructor(findings) {
106
+ super("doctor_failed");
107
+ this.findings = findings;
108
+ }
109
+ }
110
+ export async function doctorGate(ctx, deps) {
111
+ const doctor = await (deps.doctor ?? runDoctor)({ home: ctx.home, packageRoot: deps.releaseRoot, configPath: deps.configPath, loadConfig: () => loadConfig(deps.configPath) });
112
+ const ownSubjects = new Set([ctx.alias, ...Array.from({ length: 16 }, (_, index) => writerName(ctx.alias, index + 1))]);
113
+ // A refused profile of this repository is its own finding: collect, finish and the profile change that fixes it must stay possible (#141).
114
+ // Another repository's writer that is not ready is untouched by this step and expected after an upgrade; counting it would deadlock
115
+ // the repository steps of two container repositories (#148).
116
+ const own = (check) => ownSubjects.has(check.subject)
117
+ || (check.id === "container_profile_refused" && check.subject.startsWith(`${ctx.alias}:`))
118
+ || (check.id === "writer_not_ready" && WRITER_NAME_PATTERN.test(check.subject));
119
+ const blocking = doctor.checks.filter((check) => check.severity === "error" && !own(check));
120
+ // One finding per check and alias: several `config_invalid` locations (#173) all map to the alias `invalid`.
121
+ const findings = blocking.map((check) => ({ status: "failed", code: check.id, target: { client: "doctor", scope: "check", alias: gateAlias(check.id, check.subject) } }));
122
+ const unique = findings.filter((finding, index) => findings.findIndex((other) => other.code === finding.code && other.target.alias === finding.target.alias) === index);
123
+ if (unique.length)
124
+ throw new DoctorGateError(unique);
125
+ }
126
+ /** The alias that leads a Doctor subject: `<profile>:<package>` and writer names report their alias part; global checks have none. */
127
+ function gateAlias(id, subject) {
128
+ if (GLOBAL_ERRORS.has(id))
129
+ return "invalid";
130
+ const head = subject.split(":")[0] ?? "";
131
+ return safeAlias(WRITER_NAME_PATTERN.test(head) ? head.slice(0, head.lastIndexOf("-w")) : head);
132
+ }
133
+ export async function preflight(ctx, names, discard, deps) {
134
+ if (discard)
135
+ return;
136
+ for (const name of names) {
137
+ const state = await readWriterState(ctx.home, name);
138
+ const work = await (deps.lifecycle ?? lifecycle).inspectWork(ctx, name);
139
+ if (!work.baseline && state?.collected?.digest !== work.digest)
140
+ throw new Error("writer_uncollected");
141
+ }
142
+ }
143
+ export async function remove(ctx, name, discard, deps, extras = []) {
144
+ await preflight(ctx, [name], discard, deps);
145
+ const { adapter, ownership, life } = services(deps);
146
+ // Remove registrations before deleting the wrapper used to prove ownership.
147
+ for (const client of ["claude-code", "codex"]) {
148
+ const expected = intent(ctx, name, client);
149
+ const owned = await ownership.get({ client, scope: client === "codex" ? "user" : "local", alias: expected.name });
150
+ if (owned)
151
+ await unregisterWriter(ownership, adapter, expected);
152
+ else if (["equivalent", "divergent"].includes(await adapter.inspect(expected)))
153
+ throw new Error("ownership_unavailable");
154
+ }
155
+ // After the registration is gone. A hook that cannot be edited is reported, not a reason to keep the
156
+ // writer: the operator's settings are not the writer's to hold hostage, and the record keeps the entry owned.
157
+ const hook = await writerHook(ctx, name, deps, "remove", false).catch(() => ({ status: "failed", code: "hook_install_failed" }));
158
+ if (hook)
159
+ extras.push(hook);
160
+ return life.removeWriter(ctx, name, { discard });
161
+ }
162
+ export const writerIndex = (name) => Number(name.slice(name.lastIndexOf("-w") + 2));
163
+ /** Which writers a mode entry keeps, which of those still need provisioning, and which existing writers it no longer wants. */
164
+ export async function plan(ctx, entry) {
165
+ const keep = [], missing = [], surplus = [];
166
+ for (let index = 1; index <= MAX_WRITERS; index++) {
167
+ const name = writerName(ctx.alias, index), state = await readWriterState(ctx.home, name);
168
+ if (entry.mode === "container" && index <= entry.container.writers) {
169
+ keep.push(name);
170
+ if (!state || state.phase === "provisioning")
171
+ missing.push(name);
172
+ }
173
+ else if (state)
174
+ surplus.push(name);
175
+ }
176
+ return { keep, missing, surplus };
177
+ }