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.
- package/.pi-session-mcp-cli/client-adapters/mcp-verifier.js +2 -1
- package/.pi-session-mcp-cli/config.js +36 -3
- package/.pi-session-mcp-cli/doctor.js +52 -4
- package/.pi-session-mcp-cli/execution-modes.js +10 -2
- package/.pi-session-mcp-cli/execution-profile.js +1 -0
- package/.pi-session-mcp-cli/sandbox-shell.js +254 -0
- package/.pi-session-mcp-cli/session-tools.js +6 -2
- package/.pi-session-mcp-cli/writer-boundary.js +27 -0
- package/.pi-session-mcp-cli/writer-lifecycle.js +6 -4
- package/.pi-session-mcp-cli/writer-limits.js +19 -0
- package/.pi-session-mcp-cli/writer-state.js +5 -2
- package/CHANGELOG.md +147 -0
- package/CONTRIBUTING.md +8 -1
- package/README.md +5 -2
- package/dist/capabilities.d.ts +1 -0
- package/dist/capabilities.js +3 -1
- package/dist/client-adapters/mcp-verifier.js +2 -1
- package/dist/config.d.ts +12 -1
- package/dist/config.js +36 -3
- package/dist/confined-tools.d.ts +30 -0
- package/dist/confined-tools.js +122 -0
- package/dist/doctor.d.ts +5 -1
- package/dist/doctor.js +52 -4
- package/dist/execution-modes.d.ts +13 -1
- package/dist/execution-modes.js +10 -2
- package/dist/execution-profile.d.ts +4 -0
- package/dist/execution-profile.js +1 -0
- package/dist/main.js +10 -2
- package/dist/pi-adapter.d.ts +1 -1
- package/dist/pi-adapter.js +1 -0
- package/dist/sandbox-shell.d.ts +71 -0
- package/dist/sandbox-shell.js +254 -0
- package/dist/sdk-pi-adapter.d.ts +17 -1
- package/dist/sdk-pi-adapter.js +65 -10
- package/dist/session-tools.d.ts +4 -2
- package/dist/session-tools.js +6 -2
- package/dist/setup-cli.d.ts +1 -2
- package/dist/setup-cli.js +40 -9
- package/dist/setup-modes.d.ts +3 -0
- package/dist/setup-modes.js +18 -1
- package/dist/setup-writers-common.d.ts +78 -0
- package/dist/setup-writers-common.js +177 -0
- package/dist/setup-writers-plan.d.ts +34 -0
- package/dist/setup-writers-plan.js +105 -0
- package/dist/setup-writers-reconcile.d.ts +8 -0
- package/dist/setup-writers-reconcile.js +169 -0
- package/dist/setup-writers.d.ts +6 -30
- package/dist/setup-writers.js +19 -353
- package/dist/tool-schemas.d.ts +4 -0
- package/dist/tool-schemas.js +1 -0
- package/dist/wake-hook.d.ts +11 -0
- package/dist/wake-hook.js +51 -8
- package/dist/writer-boundary.d.ts +2 -0
- package/dist/writer-boundary.js +27 -0
- package/dist/writer-lifecycle.d.ts +1 -0
- package/dist/writer-lifecycle.js +6 -4
- package/dist/writer-limits.d.ts +13 -0
- package/dist/writer-limits.js +19 -0
- package/dist/writer-relay.js +1 -1
- package/dist/writer-state.d.ts +11 -0
- package/dist/writer-state.js +5 -2
- package/docs/acceptance.md +271 -0
- package/docs/adr/0009-execution-modes.md +3 -2
- package/docs/adr/0012-leased-writer-pool.md +3 -3
- package/docs/adr/0013-hardened-read-only-profiles.md +184 -0
- package/docs/architecture.md +1 -1
- package/docs/client-setup.md +3 -3
- package/docs/doctor-design.md +3 -1
- package/docs/execution-modes.md +60 -4
- package/docs/installation.md +25 -1
- package/docs/isolation-setup.md +31 -2
- package/docs/roadmap.md +14 -0
- package/docs/session-mcp.md +15 -0
- package/docs/threat-model.md +51 -0
- package/docs/tool-contracts.md +33 -4
- package/package.json +1 -1
- package/skills/pi-session-orchestration/SKILL.md +2 -2
- package/skills/pi-session-orchestration/references/start-errors.md +1 -1
package/dist/sdk-pi-adapter.js
CHANGED
|
@@ -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 {
|
|
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 = [...
|
|
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
|
-
...(
|
|
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
|
-
...(
|
|
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
|
}
|
package/dist/session-tools.d.ts
CHANGED
|
@@ -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;
|
package/dist/session-tools.js
CHANGED
|
@@ -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: [...
|
|
48
|
+
return { ok: true, tools: [...profileTools(permissionProfile, shell), ...mcpToolNames, ...(permissionProfile === "coding" ? extensionTools : grantedExtensionTools)] };
|
|
45
49
|
}
|
package/dist/setup-cli.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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);
|
package/dist/setup-modes.d.ts
CHANGED
|
@@ -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>;
|
package/dist/setup-modes.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|