pi-session-mcp 0.12.0 → 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 +8 -3
- 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 +13 -3
- 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 +96 -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 +8 -3
- 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 +15 -1
- package/dist/execution-modes.js +13 -3
- 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 +37 -6
- 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 +266 -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 +34 -1
- package/docs/isolation-setup.md +31 -2
- package/docs/roadmap.md +8 -1
- 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
|
@@ -6,6 +6,7 @@ import { loadConfig } from "./config.js";
|
|
|
6
6
|
import { containerRuntimeUsable } from "./container-runtime.js";
|
|
7
7
|
import { ALIAS_PATTERN } from "./execution-profile.js";
|
|
8
8
|
import { runDoctor } from "./doctor.js";
|
|
9
|
+
import { MAX_WRITERS } from "./execution-modes.js";
|
|
9
10
|
import { changeExecutionMode } from "./setup-modes.js";
|
|
10
11
|
import { acquireOverrideLock, OverrideLockError } from "./override-lock.js";
|
|
11
12
|
import { createCommandMcpStdioLauncher, createProcessRunner } from "./setup-process.js";
|
|
@@ -14,7 +15,8 @@ import { localHookNeededByOthers, ownsWakeHook, runWakeHook } from "./setup-hook
|
|
|
14
15
|
import { result } from "./setup-result.js";
|
|
15
16
|
import { createWriterRegistrationAdapter, unregisterWriter } from "./client-adapters/writer-registration.js";
|
|
16
17
|
import { verifyMcpCapabilities } from "./client-adapters/mcp-verifier.js";
|
|
17
|
-
import {
|
|
18
|
+
import { sameWriterLimits, writerLimits } from "./writer-limits.js";
|
|
19
|
+
import { IMAGE_PULL_TIMEOUT_MS, podmanCgroupControllers } from "./writer-boundary.js";
|
|
18
20
|
import { acquireWriterLease, WriterLeaseError } from "./writer-lease.js";
|
|
19
21
|
import { ensureWriterAbsent } from "./writer-fence.js";
|
|
20
22
|
import * as lifecycle from "./writer-lifecycle.js";
|
|
@@ -158,7 +160,7 @@ async function remove(ctx, name, discard, deps, extras = []) {
|
|
|
158
160
|
const writerIndex = (name) => Number(name.slice(name.lastIndexOf("-w") + 2));
|
|
159
161
|
async function plan(ctx, entry) {
|
|
160
162
|
const keep = [], missing = [], surplus = [];
|
|
161
|
-
for (let index = 1; index <=
|
|
163
|
+
for (let index = 1; index <= MAX_WRITERS; index++) {
|
|
162
164
|
const name = writerName(ctx.alias, index), state = await readWriterState(ctx.home, name);
|
|
163
165
|
if (entry.mode === "container" && index <= entry.container.writers) {
|
|
164
166
|
keep.push(name);
|
|
@@ -208,6 +210,15 @@ async function reconcileUnlocked(configPath, alias, request, deps) {
|
|
|
208
210
|
add(name, drift);
|
|
209
211
|
return;
|
|
210
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
|
+
}
|
|
211
222
|
}
|
|
212
223
|
// Decide for both clients before removing anything, so a divergent registration of one client never
|
|
213
224
|
// costs the writer its owned registration of the other. A client that is not installed has nothing to keep.
|
|
@@ -310,7 +321,7 @@ export async function changeWriterMode(configPath, request, deps) {
|
|
|
310
321
|
return failed("alias_unknown");
|
|
311
322
|
let runtimeProbe;
|
|
312
323
|
const containerRuntime = () => runtimeProbe ??= (deps.containerRuntime ?? containerRuntimeUsable)().catch(() => false);
|
|
313
|
-
const modeDependencies = { containerRuntime };
|
|
324
|
+
const modeDependencies = { containerRuntime, cgroupControllers: () => podmanCgroupControllers(services(deps).runner) };
|
|
314
325
|
try {
|
|
315
326
|
let writerWork = request.mode === "container";
|
|
316
327
|
if (!writerWork) {
|
|
@@ -322,7 +333,7 @@ export async function changeWriterMode(configPath, request, deps) {
|
|
|
322
333
|
return changeExecutionMode(configPath, request, modeDependencies);
|
|
323
334
|
}
|
|
324
335
|
writerWork = config.workspaceModes.get(request.alias)?.mode === "container";
|
|
325
|
-
for (let index = 1; index <=
|
|
336
|
+
for (let index = 1; index <= MAX_WRITERS && !writerWork; index++) {
|
|
326
337
|
try {
|
|
327
338
|
await lstat(join(writersRoot(deps.home ?? homedir()), writerName(request.alias, index)));
|
|
328
339
|
writerWork = true;
|
|
@@ -359,7 +370,7 @@ export async function changeWriterMode(configPath, request, deps) {
|
|
|
359
370
|
const config = await loadConfig(configPath);
|
|
360
371
|
if (!config.workspaceModes.get(request.alias)?.container?.base) {
|
|
361
372
|
const bases = new Set();
|
|
362
|
-
for (let index = 1; index <=
|
|
373
|
+
for (let index = 1; index <= MAX_WRITERS; index++) {
|
|
363
374
|
const state = await readWriterState(deps.home ?? homedir(), writerName(request.alias, index));
|
|
364
375
|
if (state)
|
|
365
376
|
bases.add(state.base.ref);
|
|
@@ -398,6 +409,24 @@ export async function changeWriterMode(configPath, request, deps) {
|
|
|
398
409
|
throw error;
|
|
399
410
|
}
|
|
400
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
|
+
}
|
|
401
430
|
}
|
|
402
431
|
else
|
|
403
432
|
await preflight(ctx, planned.surplus, request.discard ?? false, deps);
|
|
@@ -419,7 +448,9 @@ export async function changeWriterMode(configPath, request, deps) {
|
|
|
419
448
|
for (const name of planned.keep) {
|
|
420
449
|
// The same settings check as apply, so a drifted writer shows before anything is applied (#134).
|
|
421
450
|
const drift = await life.writerSettingsDrift(ctx, { index: writerIndex(name), settings: proposed.container, rawConfig, overrideDirectory });
|
|
422
|
-
|
|
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" }) });
|
|
423
454
|
}
|
|
424
455
|
const refused = kept.some((finding) => finding.status === "failed" || finding.status === "divergent");
|
|
425
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
|
}
|