pi-session-mcp 0.12.1 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.pi-session-mcp-cli/client-adapters/mcp-verifier.js +2 -1
- package/.pi-session-mcp-cli/config.js +15 -1
- package/.pi-session-mcp-cli/doctor.js +44 -1
- 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 +74 -0
- package/README.md +2 -1
- 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 +1 -0
- package/dist/config.js +15 -1
- package/dist/confined-tools.d.ts +29 -0
- package/dist/confined-tools.js +118 -0
- package/dist/doctor.d.ts +5 -1
- package/dist/doctor.js +44 -1
- 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 +6 -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 +64 -10
- package/dist/session-tools.d.ts +4 -2
- package/dist/session-tools.js +6 -2
- package/dist/setup-cli.js +40 -6
- package/dist/setup-modes.d.ts +3 -0
- package/dist/setup-modes.js +18 -1
- package/dist/setup-writers.js +33 -3
- package/dist/tool-schemas.d.ts +4 -0
- package/dist/tool-schemas.js +1 -0
- 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 +141 -0
- package/docs/adr/0013-hardened-read-only-profiles.md +182 -0
- package/docs/doctor-design.md +2 -0
- package/docs/execution-modes.md +59 -3
- package/docs/installation.md +25 -1
- package/docs/isolation-setup.md +31 -2
- package/docs/roadmap.md +6 -0
- package/docs/session-mcp.md +13 -0
- package/docs/threat-model.md +50 -0
- package/docs/tool-contracts.md +30 -3
- package/package.json +1 -1
- package/skills/pi-session-orchestration/references/start-errors.md +1 -1
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) {
|
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;
|
package/dist/setup-writers.js
CHANGED
|
@@ -15,7 +15,8 @@ import { localHookNeededByOthers, ownsWakeHook, runWakeHook } from "./setup-hook
|
|
|
15
15
|
import { result } from "./setup-result.js";
|
|
16
16
|
import { createWriterRegistrationAdapter, unregisterWriter } from "./client-adapters/writer-registration.js";
|
|
17
17
|
import { verifyMcpCapabilities } from "./client-adapters/mcp-verifier.js";
|
|
18
|
-
import {
|
|
18
|
+
import { sameWriterLimits, writerLimits } from "./writer-limits.js";
|
|
19
|
+
import { IMAGE_PULL_TIMEOUT_MS, podmanCgroupControllers } from "./writer-boundary.js";
|
|
19
20
|
import { acquireWriterLease, WriterLeaseError } from "./writer-lease.js";
|
|
20
21
|
import { ensureWriterAbsent } from "./writer-fence.js";
|
|
21
22
|
import * as lifecycle from "./writer-lifecycle.js";
|
|
@@ -209,6 +210,15 @@ async function reconcileUnlocked(configPath, alias, request, deps) {
|
|
|
209
210
|
add(name, drift);
|
|
210
211
|
return;
|
|
211
212
|
}
|
|
213
|
+
const state = await readWriterState(ctx.home, name);
|
|
214
|
+
if (!state)
|
|
215
|
+
throw new Error("writer_state_invalid");
|
|
216
|
+
const limits = writerLimits(entry.container);
|
|
217
|
+
if (!sameWriterLimits(state.limits, limits)) {
|
|
218
|
+
const { limits: _previous, ...rest } = state;
|
|
219
|
+
await writeWriterState(ctx.home, name, { ...rest, ...(limits ? { limits } : {}) });
|
|
220
|
+
add(name, { status: "ok", code: "writer_limits_updated" });
|
|
221
|
+
}
|
|
212
222
|
}
|
|
213
223
|
// Decide for both clients before removing anything, so a divergent registration of one client never
|
|
214
224
|
// costs the writer its owned registration of the other. A client that is not installed has nothing to keep.
|
|
@@ -311,7 +321,7 @@ export async function changeWriterMode(configPath, request, deps) {
|
|
|
311
321
|
return failed("alias_unknown");
|
|
312
322
|
let runtimeProbe;
|
|
313
323
|
const containerRuntime = () => runtimeProbe ??= (deps.containerRuntime ?? containerRuntimeUsable)().catch(() => false);
|
|
314
|
-
const modeDependencies = { containerRuntime };
|
|
324
|
+
const modeDependencies = { containerRuntime, cgroupControllers: () => podmanCgroupControllers(services(deps).runner) };
|
|
315
325
|
try {
|
|
316
326
|
let writerWork = request.mode === "container";
|
|
317
327
|
if (!writerWork) {
|
|
@@ -399,6 +409,24 @@ export async function changeWriterMode(configPath, request, deps) {
|
|
|
399
409
|
throw error;
|
|
400
410
|
}
|
|
401
411
|
}
|
|
412
|
+
// Settings drift before the write, so a kept writer with changed settings refuses the mode
|
|
413
|
+
// change itself and the modes file never records a mode no writer matches (#163). Read-only like
|
|
414
|
+
// the dry run, which fences nothing; reconcile re-checks under its fence. Missing writers have
|
|
415
|
+
// no settings yet.
|
|
416
|
+
if (entry.mode === "container") {
|
|
417
|
+
const { life } = services(deps);
|
|
418
|
+
const rawConfig = JSON.parse(await readFile(configPath, "utf8")), overrideDirectory = skillOverrideDirectory(configPath);
|
|
419
|
+
const drifted = [];
|
|
420
|
+
for (const name of planned.keep) {
|
|
421
|
+
if (planned.missing.includes(name))
|
|
422
|
+
continue;
|
|
423
|
+
const drift = await life.writerSettingsDrift(ctx, { index: writerIndex(name), settings: entry.container, rawConfig, overrideDirectory });
|
|
424
|
+
if (drift)
|
|
425
|
+
drifted.push({ target: targetFor(request.alias, name), ...drift });
|
|
426
|
+
}
|
|
427
|
+
if (drifted.length)
|
|
428
|
+
return result(operation, drifted);
|
|
429
|
+
}
|
|
402
430
|
}
|
|
403
431
|
else
|
|
404
432
|
await preflight(ctx, planned.surplus, request.discard ?? false, deps);
|
|
@@ -420,7 +448,9 @@ export async function changeWriterMode(configPath, request, deps) {
|
|
|
420
448
|
for (const name of planned.keep) {
|
|
421
449
|
// The same settings check as apply, so a drifted writer shows before anything is applied (#134).
|
|
422
450
|
const drift = await life.writerSettingsDrift(ctx, { index: writerIndex(name), settings: proposed.container, rawConfig, overrideDirectory });
|
|
423
|
-
|
|
451
|
+
const state = await readWriterState(ctx.home, name);
|
|
452
|
+
const updatingLimits = state && state.phase !== "provisioning" && !sameWriterLimits(state.limits, writerLimits(proposed.container));
|
|
453
|
+
kept.push({ target: targetFor(request.alias, name), ...(drift ?? { status: "planned", code: updatingLimits ? "writer_limits_update_planned" : "writer_planned" }) });
|
|
424
454
|
}
|
|
425
455
|
const refused = kept.some((finding) => finding.status === "failed" || finding.status === "divergent");
|
|
426
456
|
writers = result("dry-run", [...kept, ...planned.surplus.map((name) => ({ target: targetFor(request.alias, name), status: "planned", code: "writer_remove_planned" }))], refused ? "failed" : "planned");
|
package/dist/tool-schemas.d.ts
CHANGED
|
@@ -334,6 +334,10 @@ export declare const capabilitiesSchema: z.ZodObject<{
|
|
|
334
334
|
}>;
|
|
335
335
|
extensions: z.ZodArray<z.ZodString>;
|
|
336
336
|
grantedExtensionTools: z.ZodArray<z.ZodString>;
|
|
337
|
+
shell: z.ZodEnum<{
|
|
338
|
+
none: "none";
|
|
339
|
+
sandboxed: "sandboxed";
|
|
340
|
+
}>;
|
|
337
341
|
skills: z.ZodArray<z.ZodString>;
|
|
338
342
|
}, z.core.$strict>>;
|
|
339
343
|
}, z.core.$strict>;
|
package/dist/tool-schemas.js
CHANGED
|
@@ -44,6 +44,7 @@ export const capabilitiesSchema = z.object({
|
|
|
44
44
|
thinkingLevel: thinkingLevelSchema,
|
|
45
45
|
extensions: z.array(z.string()),
|
|
46
46
|
grantedExtensionTools: z.array(z.string()),
|
|
47
|
+
shell: z.enum(["none", "sandboxed"]),
|
|
47
48
|
skills: z.array(aliasSchema),
|
|
48
49
|
}).strict()),
|
|
49
50
|
}).strict();
|
|
@@ -46,6 +46,8 @@ export declare function podmanInfo(runner: CommandRunner): Promise<{
|
|
|
46
46
|
usable: boolean;
|
|
47
47
|
version?: string;
|
|
48
48
|
}>;
|
|
49
|
+
/** A failed or malformed host probe provides no usable controllers. */
|
|
50
|
+
export declare function podmanCgroupControllers(runner: CommandRunner): Promise<readonly string[]>;
|
|
49
51
|
export declare function resolveImage(runner: CommandRunner, image: string, pull: boolean): Promise<string>;
|
|
50
52
|
export declare function verifyCloneLayout(clone: string): Promise<void>;
|
|
51
53
|
export declare function isSafeRefName(name: string): boolean;
|
package/dist/writer-boundary.js
CHANGED
|
@@ -35,6 +35,12 @@ export function serveArgs(paths, state, command, releaseRoot, eventsDir, options
|
|
|
35
35
|
"-e", "PI_CODING_AGENT_DIR=/pi/agent",
|
|
36
36
|
"-e", "PI_SESSION_MCP_WRITER_GUARD=/pi/config/writer-guard.json",
|
|
37
37
|
];
|
|
38
|
+
if (state.limits?.cpus !== undefined)
|
|
39
|
+
args.push(`--cpus=${state.limits.cpus}`);
|
|
40
|
+
if (state.limits?.cpuShares !== undefined)
|
|
41
|
+
args.push(`--cpu-shares=${state.limits.cpuShares}`);
|
|
42
|
+
if (state.limits?.memory !== undefined)
|
|
43
|
+
args.push(`--memory=${state.limits.memory}`);
|
|
38
44
|
if (options?.name !== undefined)
|
|
39
45
|
args.push("--name", writerContainerName(options.name));
|
|
40
46
|
if (eventsDir !== undefined)
|
|
@@ -43,6 +49,8 @@ export function serveArgs(paths, state, command, releaseRoot, eventsDir, options
|
|
|
43
49
|
args.push("-v", `${paths.go}:/pi/go:rw`, "-e", "GOPATH=/pi/go", "-e", "GOCACHE=/pi/go/cache", "-e", "GOTOOLCHAIN=local");
|
|
44
50
|
if (state.cache === "uv")
|
|
45
51
|
args.push("-v", `${paths.uv}:/pi/uv:rw`, "-e", "UV_CACHE_DIR=/pi/uv/cache", "-e", "npm_config_cache=/pi/uv/npm");
|
|
52
|
+
if (state.cache === "npm")
|
|
53
|
+
args.push("-v", `${paths.npm}:/pi/npm:rw`, "-e", "npm_config_cache=/pi/npm/cache");
|
|
46
54
|
return [...args, state.imageId, ...command];
|
|
47
55
|
}
|
|
48
56
|
export class EventsDirectoryError extends Error {
|
|
@@ -261,6 +269,25 @@ export async function podmanInfo(runner) {
|
|
|
261
269
|
return { usable: false };
|
|
262
270
|
}
|
|
263
271
|
}
|
|
272
|
+
/** A failed or malformed host probe provides no usable controllers. */
|
|
273
|
+
export async function podmanCgroupControllers(runner) {
|
|
274
|
+
try {
|
|
275
|
+
const output = await podman(runner, ["info", "--format", "json"]);
|
|
276
|
+
if (output.exitCode !== 0)
|
|
277
|
+
return [];
|
|
278
|
+
const info = JSON.parse(output.stdout);
|
|
279
|
+
if (!info || typeof info !== "object" || !("host" in info))
|
|
280
|
+
return [];
|
|
281
|
+
const host = info.host;
|
|
282
|
+
if (!host || typeof host !== "object" || !("cgroupControllers" in host))
|
|
283
|
+
return [];
|
|
284
|
+
const controllers = host.cgroupControllers;
|
|
285
|
+
return Array.isArray(controllers) && controllers.every((value) => typeof value === "string") ? controllers : [];
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
return [];
|
|
289
|
+
}
|
|
290
|
+
}
|
|
264
291
|
export async function resolveImage(runner, image, pull) {
|
|
265
292
|
if (image !== "node" && !/^[0-9a-f]{64}$/.test(image))
|
|
266
293
|
throw new BoundaryError("image_unavailable");
|
|
@@ -35,6 +35,7 @@ export declare function renderContainedConfig(alias: string, clonePath: string,
|
|
|
35
35
|
mcpServers?: Readonly<Record<string, import("./mcp-config.js").McpServerConfig>> | undefined;
|
|
36
36
|
extensions?: string[] | undefined;
|
|
37
37
|
grantedExtensionTools?: string[] | undefined;
|
|
38
|
+
shell?: "sandboxed" | undefined;
|
|
38
39
|
skills?: string[] | undefined;
|
|
39
40
|
}>;
|
|
40
41
|
};
|
package/dist/writer-lifecycle.js
CHANGED
|
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { constants } from "node:fs";
|
|
3
3
|
import { chmod, mkdir, open, readdir, rm, writeFile } from "node:fs/promises";
|
|
4
4
|
import { basename, join } from "node:path";
|
|
5
|
+
import { writerLimits } from "./writer-limits.js";
|
|
5
6
|
import { configSchema } from "./config.js";
|
|
6
7
|
import { renderWrapper } from "./writer-wrapper.js";
|
|
7
8
|
import { builtinSkillName } from "./skill-catalog.js";
|
|
@@ -220,7 +221,7 @@ async function storedConfig(paths) {
|
|
|
220
221
|
async function sameSettings(paths, existing, settings, now) {
|
|
221
222
|
const stored = await storedConfig(paths);
|
|
222
223
|
return stored !== undefined && existing.imageId === now.imageId && existing.lifecycle === settings.lifecycle
|
|
223
|
-
&& existing.network === settings.network && existing.cache === settings.cache && existing.tags === settings.tags
|
|
224
|
+
&& existing.network === settings.network && existing.cache === settings.cache && existing.tags === settings.tags && existing.clean === settings.clean
|
|
224
225
|
&& JSON.stringify(existing.profiles) === JSON.stringify(settings.profiles)
|
|
225
226
|
&& JSON.stringify(existing.providers) === JSON.stringify(now.providers) && canonical(withoutDeliveryOff(stored)) === canonical(withoutDeliveryOff(now.config));
|
|
226
227
|
}
|
|
@@ -295,12 +296,13 @@ export async function provisionWriter(ctx, input) {
|
|
|
295
296
|
// With spool delivery the spool directory exists from provisioning on: launch would otherwise add it to the
|
|
296
297
|
// writer root, a host directory the acceptance harness digests around its own first launch.
|
|
297
298
|
const spool = config.events?.delivery === "spool" ? [paths.events] : [];
|
|
298
|
-
const cache = settings.cache === "
|
|
299
|
+
const cache = settings.cache === "none" ? [] : [paths[settings.cache]];
|
|
299
300
|
for (const path of [paths.root, paths.clone, paths.config, paths.agent, paths.bundles, ...spool, ...cache])
|
|
300
301
|
await privateDirectory(path);
|
|
301
302
|
const state = {
|
|
302
303
|
schemaVersion: 1, alias: ctx.alias, index, phase: "provisioning", lifecycle: settings.lifecycle,
|
|
303
|
-
profiles: [...settings.profiles], providers, network: settings.network, cache: settings.cache, tags: settings.tags, imageId,
|
|
304
|
+
profiles: [...settings.profiles], providers, network: settings.network, cache: settings.cache, tags: settings.tags, clean: settings.clean, imageId,
|
|
305
|
+
...(writerLimits(settings) ? { limits: writerLimits(settings) } : {}),
|
|
304
306
|
base: { ref: `refs/heads/${short}`, commit },
|
|
305
307
|
};
|
|
306
308
|
await writeWriterState(ctx.home, name, state);
|
|
@@ -511,7 +513,7 @@ ${BOUNDARY_GIT} for-each-ref --format='%(refname)' refs/heads | while read -r re
|
|
|
511
513
|
if [ "$ref" != "refs/heads/$2" ]; then ${BOUNDARY_GIT} update-ref -d "$ref"; fi
|
|
512
514
|
done
|
|
513
515
|
${BOUNDARY_GIT} stash clear
|
|
514
|
-
${BOUNDARY_GIT} clean -fd
|
|
516
|
+
${BOUNDARY_GIT} clean ${state.clean === "all" ? "-ffdx" : "-fd"}
|
|
515
517
|
${identityScript(4, 5)}`;
|
|
516
518
|
const result = await ctx.runner.run("podman", throwawayArgs(name, paths, state.imageId, { clone: "rw", bundles: true }, script, [base.ref, base.short, base.commit, ...await operatorIdentity(ctx)]), AbortSignal.timeout(IMAGE_PULL_TIMEOUT_MS));
|
|
517
519
|
if (result.exitCode !== 0)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export declare const cpusSchema: z.ZodNumber;
|
|
3
|
+
export declare const cpuSharesSchema: z.ZodNumber;
|
|
4
|
+
export declare const memorySchema: z.ZodString;
|
|
5
|
+
export declare const writerLimitsSchema: z.ZodObject<{
|
|
6
|
+
cpus: z.ZodOptional<z.ZodNumber>;
|
|
7
|
+
cpuShares: z.ZodOptional<z.ZodNumber>;
|
|
8
|
+
memory: z.ZodOptional<z.ZodString>;
|
|
9
|
+
}, z.core.$strict>;
|
|
10
|
+
export type WriterLimits = z.infer<typeof writerLimitsSchema>;
|
|
11
|
+
/** Omitted settings remove previous limits; never persist an empty limits object. */
|
|
12
|
+
export declare function writerLimits(settings: WriterLimits): WriterLimits | undefined;
|
|
13
|
+
export declare function sameWriterLimits(left: WriterLimits | undefined, right: WriterLimits | undefined): boolean;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const cpusSchema = z.number().positive().max(1024).multipleOf(0.01);
|
|
3
|
+
export const cpuSharesSchema = z.number().int().min(2).max(262144);
|
|
4
|
+
export const memorySchema = z.string().regex(/^[1-9][0-9]{0,6}[mg]$/);
|
|
5
|
+
export const writerLimitsSchema = z.object({
|
|
6
|
+
cpus: cpusSchema.optional(),
|
|
7
|
+
cpuShares: cpuSharesSchema.optional(),
|
|
8
|
+
memory: memorySchema.optional(),
|
|
9
|
+
}).strict();
|
|
10
|
+
/** Omitted settings remove previous limits; never persist an empty limits object. */
|
|
11
|
+
export function writerLimits(settings) {
|
|
12
|
+
const { cpus, cpuShares, memory } = settings;
|
|
13
|
+
if (cpus === undefined && cpuShares === undefined && memory === undefined)
|
|
14
|
+
return undefined;
|
|
15
|
+
return { ...(cpus === undefined ? {} : { cpus }), ...(cpuShares === undefined ? {} : { cpuShares }), ...(memory === undefined ? {} : { memory }) };
|
|
16
|
+
}
|
|
17
|
+
export function sameWriterLimits(left, right) {
|
|
18
|
+
return left?.cpus === right?.cpus && left?.cpuShares === right?.cpuShares && left?.memory === right?.memory;
|
|
19
|
+
}
|
package/dist/writer-relay.js
CHANGED
|
@@ -69,7 +69,7 @@ const creationCodes = new Set([
|
|
|
69
69
|
"model_unavailable", "thinking_level_unavailable", "execution_selection_mismatch", "external_mcp_unavailable",
|
|
70
70
|
"external_mcp_no_tools", "external_mcp_grant_tool_missing", "external_mcp_activation_mismatch",
|
|
71
71
|
"extension_not_installed", "extension_version_mismatch", "extension_load_failed", "extension_tool_conflict",
|
|
72
|
-
"extension_tool_missing", "skill_load_failed", "pi_session_creation_failed",
|
|
72
|
+
"extension_tool_missing", "skill_load_failed", "sandbox_unavailable", "pi_session_creation_failed",
|
|
73
73
|
]);
|
|
74
74
|
class InnerError extends Error {
|
|
75
75
|
code;
|
package/dist/writer-state.d.ts
CHANGED
|
@@ -33,12 +33,22 @@ export declare const writerStateSchema: z.ZodObject<{
|
|
|
33
33
|
cache: z.ZodEnum<{
|
|
34
34
|
go: "go";
|
|
35
35
|
none: "none";
|
|
36
|
+
npm: "npm";
|
|
36
37
|
uv: "uv";
|
|
37
38
|
}>;
|
|
38
39
|
tags: z.ZodDefault<z.ZodEnum<{
|
|
39
40
|
none: "none";
|
|
40
41
|
reachable: "reachable";
|
|
41
42
|
}>>;
|
|
43
|
+
clean: z.ZodDefault<z.ZodEnum<{
|
|
44
|
+
all: "all";
|
|
45
|
+
untracked: "untracked";
|
|
46
|
+
}>>;
|
|
47
|
+
limits: z.ZodOptional<z.ZodObject<{
|
|
48
|
+
cpus: z.ZodOptional<z.ZodNumber>;
|
|
49
|
+
cpuShares: z.ZodOptional<z.ZodNumber>;
|
|
50
|
+
memory: z.ZodOptional<z.ZodString>;
|
|
51
|
+
}, z.core.$strict>>;
|
|
42
52
|
imageId: z.ZodString;
|
|
43
53
|
base: z.ZodObject<{
|
|
44
54
|
ref: z.ZodString;
|
|
@@ -73,6 +83,7 @@ export declare function writerPaths(home: string, name: string): {
|
|
|
73
83
|
events: string;
|
|
74
84
|
go: string;
|
|
75
85
|
uv: string;
|
|
86
|
+
npm: string;
|
|
76
87
|
wrapper: string;
|
|
77
88
|
state: string;
|
|
78
89
|
acceptanceFailure: string;
|
package/dist/writer-state.js
CHANGED
|
@@ -5,6 +5,7 @@ import { lstat, mkdir, open, readFile, rename, unlink } from "node:fs/promises";
|
|
|
5
5
|
import { dirname, join } from "node:path";
|
|
6
6
|
import { z } from "zod";
|
|
7
7
|
import { PACKAGE_VERSION } from "./package-metadata.js";
|
|
8
|
+
import { writerLimitsSchema } from "./writer-limits.js";
|
|
8
9
|
import { ALIAS_PATTERN } from "./execution-profile.js";
|
|
9
10
|
export const WRITER_NAME_PATTERN = /^[a-z][a-z0-9_-]{0,63}-w([1-9]|1[0-6])$/;
|
|
10
11
|
export class WriterStateError extends Error {
|
|
@@ -31,8 +32,10 @@ export const writerStateSchema = z.object({
|
|
|
31
32
|
model: z.string().min(1).max(200),
|
|
32
33
|
}).strict()).refine((providers) => new Set(providers.map(({ provider }) => provider)).size === providers.length).readonly(),
|
|
33
34
|
network: z.enum(["pasta", "none"]),
|
|
34
|
-
cache: z.enum(["none", "go", "uv"]),
|
|
35
|
+
cache: z.enum(["none", "go", "uv", "npm"]),
|
|
35
36
|
tags: z.enum(["none", "reachable"]).default("none"),
|
|
37
|
+
clean: z.enum(["untracked", "all"]).default("untracked"),
|
|
38
|
+
limits: writerLimitsSchema.optional(),
|
|
36
39
|
imageId: hex64,
|
|
37
40
|
base: z.object({ ref: z.string(), commit: z.string().regex(/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/) }).strict(),
|
|
38
41
|
acceptance: z.object({
|
|
@@ -62,7 +65,7 @@ export function writerPaths(home, name) {
|
|
|
62
65
|
const root = join(writersRoot(home), name);
|
|
63
66
|
return {
|
|
64
67
|
root, clone: join(root, "clone"), agent: join(root, "agent"), bundles: join(root, "bundles"),
|
|
65
|
-
config: join(root, "config"), events: join(root, "events"), go: join(root, "go"), uv: join(root, "uv"), wrapper: join(root, "wrapper.sh"), state: join(root, "state.json"),
|
|
68
|
+
config: join(root, "config"), events: join(root, "events"), go: join(root, "go"), uv: join(root, "uv"), npm: join(root, "npm"), wrapper: join(root, "wrapper.sh"), state: join(root, "state.json"),
|
|
66
69
|
acceptanceFailure: join(root, "acceptance-failure.json"),
|
|
67
70
|
};
|
|
68
71
|
}
|
package/docs/acceptance.md
CHANGED
|
@@ -2457,6 +2457,147 @@ Not established for `0.11.6`:
|
|
|
2457
2457
|
wrapper's periodic credential copy (#136);
|
|
2458
2458
|
- the VS Code wake; macOS delivery.
|
|
2459
2459
|
|
|
2460
|
+
### v0.13.0 pre-release acceptance
|
|
2461
|
+
|
|
2462
|
+
`v0.13.0` was accepted on 2026-10-28 from 19:30 to 19:36 UTC on its release
|
|
2463
|
+
candidate `f7442b3`, on Linux (`x64`) with Node.js `26.12.1`, npm `12.2.1`,
|
|
2464
|
+
Codex CLI `0.162.1` and Claude Code `2.1.312`. Level 2 passed in the working
|
|
2465
|
+
tree and for the packed tarball, each with delivery off and with delivery on.
|
|
2466
|
+
Level 1 passed except `npm audit`. Later commits on the release pull request
|
|
2467
|
+
change only this record.
|
|
2468
|
+
|
|
2469
|
+
The minor release ships ADR 0013 (#169) and the writer settings changes #162,
|
|
2470
|
+
#163, #165 and #166. It changes the MCP contract (read-only confinement,
|
|
2471
|
+
profile field and capabilities field `shell`, start error
|
|
2472
|
+
`sandbox_unavailable`); the setup report schema is unchanged.
|
|
2473
|
+
|
|
2474
|
+
- Level 1 in a clean worktree of the candidate after `npm ci`: every command
|
|
2475
|
+
exited with `0`, except `npm audit`; `git status --short` printed nothing.
|
|
2476
|
+
- The test run, with `NODE_OPTIONS=--unhandled-rejections=strict`, reported
|
|
2477
|
+
125 files, 3936 passed and 14 skipped, and no unhandled error.
|
|
2478
|
+
- `npm audit` and `npm audit --omit=dev` exited with `1` and name only the
|
|
2479
|
+
known `undici` finding through `@earendil-works/pi-coding-agent` 1.4.0
|
|
2480
|
+
([#120](https://github.com/plagor44-ux/pi-session-mcp/issues/120)).
|
|
2481
|
+
- `npm pack` listed 916 files (tarball shasum
|
|
2482
|
+
`1259d57d2d59099ad72e796609ecab3d581e9b06`, identical in two packs). A scan
|
|
2483
|
+
of the packed files for private names, hosts and paths found none; its only
|
|
2484
|
+
matches for `/home/` are the already redacted
|
|
2485
|
+
`<repo>/eval/results/.../home/.pi/...` paths of disposable evaluation homes
|
|
2486
|
+
in six `docs/evaluation/extension-functions-2026-10-17/cursor/` evidence
|
|
2487
|
+
files, which `0.12.1` shipped unchanged.
|
|
2488
|
+
- Doctor reported `packageVersion` `0.13.0`. It exited with `2` because no
|
|
2489
|
+
configuration was set (`config_missing`).
|
|
2490
|
+
- Level 2 in the working tree: 160 of 160 checks passed with delivery off and
|
|
2491
|
+
228 of 228 with `--events spool`.
|
|
2492
|
+
- Packed-tarball Level 2 from a fresh prefix: 160 of 160 with delivery off and
|
|
2493
|
+
228 of 228 with `--events spool`. The install smoke from another fresh prefix
|
|
2494
|
+
reported `smoke_passed` with version `0.13.0`.
|
|
2495
|
+
- In every Level 2 run the real client registrations were unchanged, no process
|
|
2496
|
+
remained, the leak check passed and the disposable directories were removed.
|
|
2497
|
+
- Podman-gated writer tests on the candidate: 7 of 7 passed.
|
|
2498
|
+
- Bubblewrap-gated sandbox tests (`test/sandbox-shell.bwrap.test.ts`, bubblewrap
|
|
2499
|
+
`0.13.1`): 12 of 12 passed, none skipped.
|
|
2500
|
+
- Review: #169 had an independent review of its full diff (same model family as
|
|
2501
|
+
the author) that found no confinement or sandbox escape; its follow-ups were
|
|
2502
|
+
fixed before the merge of #170.
|
|
2503
|
+
|
|
2504
|
+
Not established for `0.13.0`: a live session with `shell: "sandboxed"` against
|
|
2505
|
+
a real provider, client-target `--verify` through the installed package, which
|
|
2506
|
+
follows the upgrade; Level 3.
|
|
2507
|
+
|
|
2508
|
+
### v0.12.1 post-release evidence
|
|
2509
|
+
|
|
2510
|
+
Recorded on 2026-10-28 for the published release `0.12.1`.
|
|
2511
|
+
|
|
2512
|
+
Tag binding: a fresh clone of the annotated tag `v0.12.1` (tag object
|
|
2513
|
+
`eecd6dbf0c220ded997c697f281f6b984d9ebf4e`) peels to the merge commit
|
|
2514
|
+
`91799f2e84b514110f77c24986d59e72c5ed98e5` of #161, and
|
|
2515
|
+
`git describe --tags --exact-match` reports `v0.12.1`. CI on the merge commit
|
|
2516
|
+
and on the tag passed on the first attempt.
|
|
2517
|
+
|
|
2518
|
+
- Level 1 on the tag clone after `npm ci`, from 14:43 to 14:47 UTC with
|
|
2519
|
+
Node.js `26.12.1` and npm `12.2.1`: typecheck, `build:cli`, the test run
|
|
2520
|
+
(120 files, 3708 passed, 14 skipped, with
|
|
2521
|
+
`NODE_OPTIONS=--unhandled-rejections=strict`), `build` and
|
|
2522
|
+
`git diff --check` exited with `0`, and the status stayed clean.
|
|
2523
|
+
- `pi-session-mcp@0.12.1` on npm: 907 files, shasum
|
|
2524
|
+
`58dc7235e0d54d4290d92934d877223e22eec03d`, `gitHead` `cc25693` (the head of
|
|
2525
|
+
#161, whose tree equals the merge commit). `npm pack` of the tag clone gives
|
|
2526
|
+
the same shasum and file count.
|
|
2527
|
+
- Published npm package, Level 2 from a fresh prefix: 160 of 160 with delivery
|
|
2528
|
+
off and 228 of 228 with `--events spool`; real registrations and built files
|
|
2529
|
+
unchanged, no process left, leak check passed. Clients: Codex CLI `0.162.1`
|
|
2530
|
+
and Claude Code `2.1.312`. The install smoke on the published package
|
|
2531
|
+
reported `smoke_passed` with version `0.12.1`.
|
|
2532
|
+
- The GitHub release `v0.12.1` was published the same day.
|
|
2533
|
+
|
|
2534
|
+
Workstation, upgraded from `0.12.0` to `0.12.1` by the release session: Doctor
|
|
2535
|
+
reported no error, client-target `--verify` passed for `claude-code:user` and
|
|
2536
|
+
`codex:user` (`mcp_verified`, `skill_verified`, and `hook_verified` for the
|
|
2537
|
+
wake hook), the open point of the pre-release record, and
|
|
2538
|
+
`--writer <name> --verify` passed for all 18 writers (`mcp_verified`).
|
|
2539
|
+
|
|
2540
|
+
Operator upgrade of the second machine (Linux) from `0.11.8` to `0.12.1` on
|
|
2541
|
+
2026-10-28 from 14:50 UTC, following
|
|
2542
|
+
[Installation](installation.md#container-writers), with Node.js `24.22.0`:
|
|
2543
|
+
|
|
2544
|
+
- No pi-session-mcp server process and no writer container ran at the start.
|
|
2545
|
+
- Both client targets were removed with `0.11.8`, then
|
|
2546
|
+
`npm install -g pi-session-mcp@0.12.1`. Doctor reported every writer
|
|
2547
|
+
`writer_not_ready`, each container workspace `workspace_base_unset` and each
|
|
2548
|
+
writer `writer_git_identity_missing`.
|
|
2549
|
+
- Four container repositories with 14 writers were migrated by repeating their
|
|
2550
|
+
provisioning commands with unchanged settings, each first as a dry run
|
|
2551
|
+
(`planned`), then with `--apply`: 2, 4, 4 (operator image, `--cache go`) and
|
|
2552
|
+
4 (operator image) writers. Every writer reported `writer_accepted`.
|
|
2553
|
+
- `--finish --apply` of all 14 writers reported `writer_reset`; no work was
|
|
2554
|
+
collected. Doctor then reported no writer or base finding.
|
|
2555
|
+
- `--apply` and `--verify` of both client targets passed (`mcp_verified`), and
|
|
2556
|
+
`--writer <name> --verify` passed for all 14 writers. No writer registration
|
|
2557
|
+
and no container remained.
|
|
2558
|
+
|
|
2559
|
+
Live checks of #154 and #151 on the workstation, 15:02 to 15:04 UTC, with the
|
|
2560
|
+
coding profile `code-deepseek` through the one host registration and wake
|
|
2561
|
+
events from the host spool:
|
|
2562
|
+
|
|
2563
|
+
- Settings change: a writer whose settings differ is refused with
|
|
2564
|
+
`writer_settings_changed`. Both repositories' writers were collected
|
|
2565
|
+
(`nothing_to_collect`), removed with `--writer <name> --remove` and
|
|
2566
|
+
provisioned again with `--tags reachable` (2 writers) and `--cache uv`
|
|
2567
|
+
(4 writers, operator image with uv `0.13.6`); every writer reported
|
|
2568
|
+
`writer_accepted`.
|
|
2569
|
+
- Tags (#154): the writer with `--tags reachable` held 14 tags, the tags of
|
|
2570
|
+
the operator checkout merged into `main`. In the session,
|
|
2571
|
+
`git describe --tags` reported `v0.12.1` and
|
|
2572
|
+
`git show v0.12.0:package.json` read the file at that tag. The writer without
|
|
2573
|
+
the setting held no tags.
|
|
2574
|
+
- Identity (#154): an empty commit in the session, without `GIT_AUTHOR_*` or
|
|
2575
|
+
`GIT_COMMITTER_*` and without `--author`, recorded the operator's name as
|
|
2576
|
+
author and committer. `git config user.name` inside the writer failed with
|
|
2577
|
+
exit `4` because `.git/config` is mounted read-only. The collected ref showed
|
|
2578
|
+
the same identity. The test ref was removed afterwards.
|
|
2579
|
+
- uv cache (#151): three sessions on the same writer, with `--finish --apply`
|
|
2580
|
+
between the second and the third. In the first, `UV_CACHE_DIR` was
|
|
2581
|
+
`/pi/uv/cache` and empty; `uv sync --frozen` took 1.9 s and left 95,576 KiB
|
|
2582
|
+
in 1,648 files. The second and third sessions found the same size and file
|
|
2583
|
+
count before they ran anything, and `uv sync --frozen --offline` after
|
|
2584
|
+
removing `.venv` took 0.07 s and 0.08 s.
|
|
2585
|
+
|
|
2586
|
+
Findings from these checks:
|
|
2587
|
+
|
|
2588
|
+
- Reset and finish keep Git-ignored files in the clone, as designed: `.venv`
|
|
2589
|
+
from the first session was still present in the third, after the finish.
|
|
2590
|
+
The opt-in `--clean all` removes them
|
|
2591
|
+
([#162](https://github.com/plagor44-ux/pi-session-mcp/issues/162)); it is
|
|
2592
|
+
not part of the released `0.12.1`.
|
|
2593
|
+
- A provisioning command with changed settings reported `mode_applied` and
|
|
2594
|
+
recorded the new mode although every writer failed with
|
|
2595
|
+
`writer_settings_changed`; Doctor reported no mismatch
|
|
2596
|
+
([#163](https://github.com/plagor44-ux/pi-session-mcp/issues/163), open).
|
|
2597
|
+
|
|
2598
|
+
Not established for `0.12.1`: a busy pool (`writer_unavailable`) and a `base`
|
|
2599
|
+
fix round in live use; Level 3; the macOS upgrade.
|
|
2600
|
+
|
|
2460
2601
|
### v0.12.1 pre-release acceptance
|
|
2461
2602
|
|
|
2462
2603
|
`v0.12.1` was accepted on 2026-10-28 from 13:12 to 13:17 UTC on its release
|