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
|
@@ -118,7 +118,7 @@ function validCapabilities(value) {
|
|
|
118
118
|
if (!profile || typeof profile !== "object")
|
|
119
119
|
return false;
|
|
120
120
|
const record = profile;
|
|
121
|
-
return exactKeys(record, ["alias", "default", "permissionProfile", "provider", "model", "thinkingLevel", "extensions", "grantedExtensionTools", "skills"])
|
|
121
|
+
return exactKeys(record, ["alias", "default", "permissionProfile", "provider", "model", "thinkingLevel", "extensions", "grantedExtensionTools", "shell", "skills"])
|
|
122
122
|
&& typeof record.alias === "string" && ALIAS_PATTERN.test(record.alias)
|
|
123
123
|
&& typeof record.default === "boolean"
|
|
124
124
|
&& (record.permissionProfile === "read-only" || record.permissionProfile === "coding")
|
|
@@ -130,6 +130,7 @@ function validCapabilities(value) {
|
|
|
130
130
|
&& Array.isArray(record.grantedExtensionTools) && record.grantedExtensionTools.length <= MAX_GRANTED_EXTENSION_TOOLS
|
|
131
131
|
&& record.grantedExtensionTools.every((name) => typeof name === "string" && isAcceptableExtensionToolName(name))
|
|
132
132
|
&& new Set(record.grantedExtensionTools).size === record.grantedExtensionTools.length
|
|
133
|
+
&& (record.shell === "none" || record.shell === "sandboxed")
|
|
133
134
|
&& Array.isArray(record.skills) && record.skills.length <= MAX_PROFILE_SKILLS
|
|
134
135
|
&& record.skills.every((skill) => typeof skill === "string" && ALIAS_PATTERN.test(skill))
|
|
135
136
|
&& new Set(record.skills).size === record.skills.length;
|
|
@@ -20,6 +20,7 @@ const profileSchema = z.object({
|
|
|
20
20
|
mcpServers: z.custom((value) => isMcpServers(value)).optional(),
|
|
21
21
|
extensions: z.array(extensionSpec).max(MAX_PROFILE_EXTENSIONS).optional(),
|
|
22
22
|
grantedExtensionTools: z.array(z.string().refine(isAcceptableExtensionToolName)).max(MAX_GRANTED_EXTENSION_TOOLS).optional(),
|
|
23
|
+
shell: z.literal("sandboxed").optional(),
|
|
23
24
|
skills: z.array(alias).max(MAX_PROFILE_SKILLS).optional(),
|
|
24
25
|
}).strict().superRefine((profile, context) => {
|
|
25
26
|
if (profile.mcpServers !== undefined && !isMcpServers(profile.mcpServers, profile.permissionProfile)) {
|
|
@@ -46,6 +47,18 @@ const profileSchema = z.object({
|
|
|
46
47
|
context.addIssue({ code: "custom", path: ["grantedExtensionTools"], message: "Extension tool grants must not name built-in tools" });
|
|
47
48
|
}
|
|
48
49
|
}
|
|
50
|
+
if (profile.shell !== undefined) {
|
|
51
|
+
// Granted extension tools may write, so the sandbox's read-only guarantee would not hold (ADR 0013).
|
|
52
|
+
if (profile.permissionProfile !== "read-only") {
|
|
53
|
+
context.addIssue({ code: "custom", path: ["shell"], message: "A sandboxed shell requires a read-only profile" });
|
|
54
|
+
}
|
|
55
|
+
if (profile.grantedExtensionTools !== undefined) {
|
|
56
|
+
context.addIssue({ code: "custom", path: ["shell"], message: "A sandboxed shell cannot be combined with granted extension tools" });
|
|
57
|
+
}
|
|
58
|
+
if (process.platform !== "linux") {
|
|
59
|
+
context.addIssue({ code: "custom", path: ["shell"], message: "A sandboxed shell requires Linux" });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
49
62
|
if (profile.skills !== undefined && new Set(profile.skills).size !== profile.skills.length) {
|
|
50
63
|
context.addIssue({ code: "custom", path: ["skills"], message: "A skill alias may appear only once per profile" });
|
|
51
64
|
}
|
|
@@ -59,12 +72,31 @@ const CONFIG_ERROR_MESSAGES = {
|
|
|
59
72
|
};
|
|
60
73
|
export class ConfigError extends Error {
|
|
61
74
|
code;
|
|
62
|
-
|
|
75
|
+
/** Dotted schema issue locations for Doctor; built by `issueLocations`, never a value, message or path. */
|
|
76
|
+
locations;
|
|
77
|
+
constructor(code, locations = []) {
|
|
63
78
|
super(CONFIG_ERROR_MESSAGES[code]);
|
|
64
79
|
this.name = "ConfigError";
|
|
65
80
|
this.code = code;
|
|
81
|
+
this.locations = Object.freeze([...locations]);
|
|
66
82
|
}
|
|
67
83
|
}
|
|
84
|
+
export const MAX_CONFIG_ISSUE_LOCATIONS = 5;
|
|
85
|
+
/**
|
|
86
|
+
* Sorted, deduplicated and bounded issue locations; an issue at the root has the empty location.
|
|
87
|
+
* A segment is kept only if it is a schema field name, or, inside the path, a key that has the alias
|
|
88
|
+
* form (record keys are aliases, which Doctor already reports). Any other key, and every unrecognized
|
|
89
|
+
* key that is not a schema field name, could be a path or a secret and becomes `<key>`.
|
|
90
|
+
*/
|
|
91
|
+
export function issueLocations(issues) {
|
|
92
|
+
const fields = new Set([...Object.keys(configSchema.shape), ...Object.keys(profileSchema.shape), ...Object.keys(eventsSchema.shape)]);
|
|
93
|
+
const segment = (value, aliasAllowed) => typeof value === "number" ? String(value) : typeof value === "string" && (fields.has(value) || (aliasAllowed && ALIAS_PATTERN.test(value))) ? value : "<key>";
|
|
94
|
+
const locations = issues.flatMap((issue) => {
|
|
95
|
+
const path = issue.path.map((value) => segment(value, true));
|
|
96
|
+
return issue.code === "unrecognized_keys" ? issue.keys.map((key) => [...path, segment(key, false)]) : [path];
|
|
97
|
+
});
|
|
98
|
+
return [...new Set(locations.map((path) => path.join(".")))].sort().slice(0, MAX_CONFIG_ISSUE_LOCATIONS);
|
|
99
|
+
}
|
|
68
100
|
const eventsSchema = z.object({ delivery: z.enum(["spool", "off"]) }).strict();
|
|
69
101
|
export const configSchema = z.object({
|
|
70
102
|
events: eventsSchema.optional(),
|
|
@@ -138,7 +170,7 @@ export async function loadConfig(path = process.env.PI_SESSION_MCP_CONFIG) {
|
|
|
138
170
|
const thinkingLevelIssue = error.issues.some((issue) => issue.path.at(-1) === "thinkingLevel");
|
|
139
171
|
// Only the events field's own issues map to the delivery code; an unknown top-level key stays a profile-config error.
|
|
140
172
|
const eventsIssue = error.issues.some((issue) => issue.path[0] === "events");
|
|
141
|
-
throw new ConfigError(thinkingLevelIssue ? "invalid_thinking_level" : eventsIssue ? "invalid_event_delivery" : "invalid_execution_profile_config");
|
|
173
|
+
throw new ConfigError(thinkingLevelIssue ? "invalid_thinking_level" : eventsIssue ? "invalid_event_delivery" : "invalid_execution_profile_config", issueLocations(error.issues));
|
|
142
174
|
}
|
|
143
175
|
throw error;
|
|
144
176
|
}
|
|
@@ -146,13 +178,14 @@ export async function loadConfig(path = process.env.PI_SESSION_MCP_CONFIG) {
|
|
|
146
178
|
const overrideDirectory = skillOverrideDirectory(absoluteConfig);
|
|
147
179
|
const workspaces = new ImmutableMap(Object.entries(parsed.workspaces).map(([key, value]) => [key, resolve(base, value)]));
|
|
148
180
|
const catalog = parsed.skillCatalog ?? {};
|
|
149
|
-
const executionProfiles = new ImmutableMap(Object.entries(parsed.executionProfiles).map(([key, { mcpServers, extensions, grantedExtensionTools, skills, ...value }]) => {
|
|
181
|
+
const executionProfiles = new ImmutableMap(Object.entries(parsed.executionProfiles).map(([key, { mcpServers, extensions, grantedExtensionTools, shell, skills, ...value }]) => {
|
|
150
182
|
const selection = resolveSkillSelection(skills, catalog, base, overrideDirectory);
|
|
151
183
|
return [key, Object.freeze({
|
|
152
184
|
alias: key, ...value,
|
|
153
185
|
...(mcpServers === undefined ? {} : { mcpServers: snapshotMcpServers(mcpServers) }),
|
|
154
186
|
...(extensions === undefined ? {} : { extensions: Object.freeze(extensions.map((spec) => parseExtensionSpec(spec))) }),
|
|
155
187
|
...(grantedExtensionTools === undefined ? {} : { grantedExtensionTools: Object.freeze([...grantedExtensionTools]) }),
|
|
188
|
+
...(shell === undefined ? {} : { shell }),
|
|
156
189
|
...(selection === undefined ? {} : { skills: selection }),
|
|
157
190
|
})];
|
|
158
191
|
}));
|
|
@@ -3,11 +3,12 @@ import { constants } from "node:fs";
|
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { createProcessRunner } from "./setup-process.js";
|
|
6
|
+
import { probeSandbox, resolveWorktreeGitDir } from "./sandbox-shell.js";
|
|
6
7
|
import { podmanInfo, isSafeRefName } from "./writer-boundary.js";
|
|
7
8
|
import { containedBuiltinSkills, containedProfileRefused } from "./writer-lifecycle.js";
|
|
8
9
|
import { readWriterState, writerAcceptanceCurrent, writerName, writerPaths } from "./writer-state.js";
|
|
9
10
|
import { LEASE_DIRECTORY } from "./writer-lease.js";
|
|
10
|
-
import { loadConfig } from "./config.js";
|
|
11
|
+
import { ConfigError, loadConfig } from "./config.js";
|
|
11
12
|
import { containerRuntimeUsable } from "./container-runtime.js";
|
|
12
13
|
import { PiExtensionError } from "./extension-spec.js";
|
|
13
14
|
import { readPackageMetadata } from "./package-metadata.js";
|
|
@@ -32,7 +33,7 @@ async function readPrefix(path, maxBytes) {
|
|
|
32
33
|
}
|
|
33
34
|
}
|
|
34
35
|
const defaultFileSystem = { readFile, stat, access };
|
|
35
|
-
const CHECK_ORDER = ["node_version_unsupported", "package_metadata_unreadable", "build_missing", "build_stale", "version_mismatch", "config_missing", "config_unreadable", "config_invalid", "workspace_missing", "workspace_not_directory", "workspace_unreadable", "workspace_not_writable", "workspace_gitfile", "workspace_mode_unset", "workspace_base_unset", "workspace_host_writes_accepted", "container_runtime_unavailable", "container_profile_refused", "writer_lease_directory", "writer_not_ready", "writer_not_migrated", "writer_off_default_base", "writer_dirty", "writer_git_identity_missing", "writer_uncollected_bundle", "extension_missing", "extension_version_mismatch", "extension_unpinned", "skill_missing", "skill_unreadable", "skill_duplicate", "skill_override_outdated", "event_spool_root"];
|
|
36
|
+
const CHECK_ORDER = ["node_version_unsupported", "package_metadata_unreadable", "build_missing", "build_stale", "version_mismatch", "config_missing", "config_unreadable", "config_invalid", "workspace_missing", "workspace_not_directory", "workspace_unreadable", "workspace_not_writable", "workspace_gitfile", "workspace_mode_unset", "workspace_base_unset", "workspace_host_writes_accepted", "container_runtime_unavailable", "container_profile_refused", "writer_lease_directory", "writer_not_ready", "writer_not_migrated", "writer_off_default_base", "writer_dirty", "writer_git_identity_missing", "writer_uncollected_bundle", "extension_missing", "extension_version_mismatch", "extension_unpinned", "skill_missing", "skill_unreadable", "skill_duplicate", "skill_override_outdated", "sandbox_unavailable", "workspace_git_unavailable", "event_spool_root"];
|
|
36
37
|
function failure(id, subject, remediation) { return Object.freeze({ id, severity: "error", subject, remediation }); }
|
|
37
38
|
function warning(id, subject, remediation) { return Object.freeze({ id, severity: "warning", subject, remediation }); }
|
|
38
39
|
function ok(id, subject) { return Object.freeze({ id, severity: "ok", subject }); }
|
|
@@ -148,6 +149,8 @@ async function hasGitfilePointer(fs, workspace) {
|
|
|
148
149
|
return false;
|
|
149
150
|
}
|
|
150
151
|
}
|
|
152
|
+
/** The trusted bubblewrap binary plus a probe run through the session's fixed arguments for the workspace, its worktree Git directory included. */
|
|
153
|
+
const defaultSandboxProbe = (workspace) => probeSandbox(workspace);
|
|
151
154
|
async function defaultRootInfo(path) {
|
|
152
155
|
try {
|
|
153
156
|
const info = await lstat(path);
|
|
@@ -420,8 +423,13 @@ export async function runDoctor(dependencies = {}) {
|
|
|
420
423
|
checks.push(failure("config_missing", "configuration", "create PI_SESSION_MCP_CONFIG configuration"));
|
|
421
424
|
else if (isUnreadable(error))
|
|
422
425
|
checks.push(failure("config_unreadable", "configuration", "grant configuration read access"));
|
|
423
|
-
else
|
|
424
|
-
|
|
426
|
+
else {
|
|
427
|
+
// Locations name the offending field; an error without one, or at the root, keeps the plain subject.
|
|
428
|
+
const locations = error instanceof ConfigError ? error.locations : [];
|
|
429
|
+
for (const location of locations.length ? locations : [""]) {
|
|
430
|
+
checks.push(failure("config_invalid", location ? `configuration:${location}` : "configuration", "fix configuration using the documented schema"));
|
|
431
|
+
}
|
|
432
|
+
}
|
|
425
433
|
}
|
|
426
434
|
if (config) {
|
|
427
435
|
for (const [alias, path] of [...config.workspaces].sort(([a], [b]) => a.localeCompare(b))) {
|
|
@@ -627,6 +635,46 @@ export async function runDoctor(dependencies = {}) {
|
|
|
627
635
|
}
|
|
628
636
|
}
|
|
629
637
|
}
|
|
638
|
+
if (config) {
|
|
639
|
+
const sandboxed = [...config.executionProfiles].filter(([, profile]) => profile.shell === "sandboxed").map(([alias]) => alias).sort((a, b) => a.localeCompare(b));
|
|
640
|
+
if (sandboxed.length > 0) {
|
|
641
|
+
// The overlay depends on the workspace's filesystem, so each existing workspace directory is probed.
|
|
642
|
+
for (const [alias, path] of [...config.workspaces].sort(([a], [b]) => a.localeCompare(b))) {
|
|
643
|
+
try {
|
|
644
|
+
if (!(await fs.stat(path)).isDirectory())
|
|
645
|
+
continue;
|
|
646
|
+
}
|
|
647
|
+
catch {
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
let usable = false;
|
|
651
|
+
try {
|
|
652
|
+
usable = await (dependencies.sandboxProbe ?? defaultSandboxProbe)(path);
|
|
653
|
+
}
|
|
654
|
+
catch {
|
|
655
|
+
usable = false;
|
|
656
|
+
}
|
|
657
|
+
checks.push(usable
|
|
658
|
+
? ok("sandbox_unavailable", alias)
|
|
659
|
+
: failure("sandbox_unavailable", alias, "install a bubblewrap with --overlay-src, --tmp-overlay and --disable-userns support at /usr/bin/bwrap (root-owned, not group- or world-writable) and allow unprivileged user namespaces, and keep the workspace on a filesystem that can be an overlay lower layer; see docs/installation.md"));
|
|
660
|
+
}
|
|
661
|
+
// Without a usable worktree pointer Git commands fail inside the sandbox; the read tools are unaffected.
|
|
662
|
+
for (const [alias, path] of [...config.workspaces].sort(([a], [b]) => a.localeCompare(b))) {
|
|
663
|
+
if (!(await hasGitfilePointer(fs, path)))
|
|
664
|
+
continue;
|
|
665
|
+
let gitDir;
|
|
666
|
+
try {
|
|
667
|
+
gitDir = await (dependencies.worktreeGitDir ?? resolveWorktreeGitDir)(path);
|
|
668
|
+
}
|
|
669
|
+
catch {
|
|
670
|
+
gitDir = undefined;
|
|
671
|
+
}
|
|
672
|
+
checks.push(gitDir === undefined
|
|
673
|
+
? warning("workspace_git_unavailable", alias, "the workspace's gitfile does not point to a linked worktree whose main Git directory is intact, so Git commands fail inside the shell sandbox")
|
|
674
|
+
: ok("workspace_git_unavailable", alias));
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
}
|
|
630
678
|
if (config?.eventDelivery === "spool")
|
|
631
679
|
checks.push(await eventSpoolCheck(dependencies.env ?? process.env, dependencies.eventSpoolRoot ?? defaultRootInfo, dependencies.uid ?? process.getuid?.()));
|
|
632
680
|
const lease = await writerLeaseCheck(home, dependencies.writerLeaseRoot ?? defaultRootInfo, dependencies.uid ?? process.getuid?.());
|
|
@@ -4,6 +4,7 @@ import { z } from "zod";
|
|
|
4
4
|
import { ConfigError } from "./config.js";
|
|
5
5
|
import { ImmutableMap } from "./execution-profile.js";
|
|
6
6
|
import { isSafeRefName } from "./writer-boundary.js";
|
|
7
|
+
import { cpusSchema, cpuSharesSchema, memorySchema } from "./writer-limits.js";
|
|
7
8
|
export const EXECUTION_MODES_FILE = "execution-modes.json";
|
|
8
9
|
/** Writers per container workspace; capabilities report it and setup verify checks it. */
|
|
9
10
|
export const MAX_WRITERS = 16;
|
|
@@ -28,9 +29,13 @@ const entrySchema = z.discriminatedUnion("mode", [
|
|
|
28
29
|
profiles: z.array(z.string()).min(1),
|
|
29
30
|
image: z.union([z.literal("node"), z.string().regex(/^[0-9a-f]{64}$/)]).default("node"),
|
|
30
31
|
network: z.enum(["pasta", "none"]).default("pasta"),
|
|
31
|
-
cache: z.enum(["none", "go", "uv"]).default("none"),
|
|
32
|
+
cache: z.enum(["none", "go", "uv", "npm"]).default("none"),
|
|
32
33
|
tags: z.enum(["none", "reachable"]).default("none"),
|
|
34
|
+
clean: z.enum(["untracked", "all"]).default("untracked"),
|
|
33
35
|
base: z.string().refine(isShortBranchName).optional(),
|
|
36
|
+
cpus: cpusSchema.optional(),
|
|
37
|
+
cpuShares: cpuSharesSchema.optional(),
|
|
38
|
+
memory: memorySchema.optional(),
|
|
34
39
|
}).strict(),
|
|
35
40
|
]);
|
|
36
41
|
export const executionModesSchema = z.object({
|
|
@@ -52,10 +57,13 @@ export function resolveExecutionModes(raw, workspaceAliases, profiles) {
|
|
|
52
57
|
throw new ConfigError("invalid_execution_modes");
|
|
53
58
|
}
|
|
54
59
|
}
|
|
55
|
-
const { mode, base, ...settings } = entry;
|
|
60
|
+
const { mode, base, cpus, cpuShares, memory, ...settings } = entry;
|
|
56
61
|
entries.set(alias, Object.freeze({ mode, container: Object.freeze({
|
|
57
62
|
...settings, profiles: Object.freeze([...settings.profiles]),
|
|
58
63
|
...(base === undefined ? {} : { base }),
|
|
64
|
+
...(cpus === undefined ? {} : { cpus }),
|
|
65
|
+
...(cpuShares === undefined ? {} : { cpuShares }),
|
|
66
|
+
...(memory === undefined ? {} : { memory }),
|
|
59
67
|
}) }));
|
|
60
68
|
}
|
|
61
69
|
else {
|
|
@@ -10,6 +10,7 @@ export function toResolvedExecutionProfile(profile) {
|
|
|
10
10
|
...(profile.mcpServers === undefined ? {} : { mcpServers: snapshotMcpServers(profile.mcpServers) }),
|
|
11
11
|
...(profile.extensions === undefined ? {} : { extensions: profile.extensions }),
|
|
12
12
|
...(profile.grantedExtensionTools === undefined ? {} : { grantedExtensionTools: Object.freeze([...profile.grantedExtensionTools]) }),
|
|
13
|
+
...(profile.shell === undefined ? {} : { shell: profile.shell }),
|
|
13
14
|
...(profile.skills === undefined ? {} : { skills: profile.skills }),
|
|
14
15
|
});
|
|
15
16
|
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { constants as osConstants } from "node:os";
|
|
3
|
+
import { lstatSync, readlinkSync } from "node:fs";
|
|
4
|
+
import { lstat, readFile, realpath, stat } from "node:fs/promises";
|
|
5
|
+
import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
6
|
+
import { isTrustedSystemBinary } from "./setup-platform.js";
|
|
7
|
+
/** The only bubblewrap binary the shell uses: a fixed path, never a `PATH` lookup. */
|
|
8
|
+
export const BWRAP_PATH = "/usr/bin/bwrap";
|
|
9
|
+
export const SANDBOX_BASH_PATH = "/usr/bin/bash";
|
|
10
|
+
const SANDBOX_TRUE_PATH = "/usr/bin/true";
|
|
11
|
+
const SANDBOX_PATH = "/usr/local/bin:/usr/bin:/bin";
|
|
12
|
+
const SANDBOX_HOME = "/tmp/home";
|
|
13
|
+
/** Directories of a merged-`/usr` host that the sandbox recreates as symlinks or binds read-only. */
|
|
14
|
+
const ROOT_ENTRIES = ["/bin", "/lib", "/lib64", "/sbin"];
|
|
15
|
+
export const PROBE_TIMEOUT_MS = 5_000;
|
|
16
|
+
/** Seconds a command may run when the tool call names no `timeout`. */
|
|
17
|
+
export const DEFAULT_COMMAND_TIMEOUT_SECONDS = 600;
|
|
18
|
+
/** Size limit of the sandbox's tmpfs `/tmp`, in bytes (512 MiB). */
|
|
19
|
+
export const SANDBOX_TMP_BYTES = 536_870_912;
|
|
20
|
+
export const SANDBOX_UNAVAILABLE_MESSAGE = "Read-only shell sandbox is not available";
|
|
21
|
+
const GITFILE_LIMIT_BYTES = 4096;
|
|
22
|
+
/** The complete, fixed bubblewrap argument list of ADR 0013. Pure: host facts come in as `rootEntries`. */
|
|
23
|
+
export function sandboxArgs(input) {
|
|
24
|
+
const args = [
|
|
25
|
+
"--unshare-all", "--unshare-user", "--disable-userns", "--die-with-parent", "--new-session", "--clearenv",
|
|
26
|
+
"--setenv", "PATH", SANDBOX_PATH, "--setenv", "HOME", SANDBOX_HOME,
|
|
27
|
+
"--ro-bind", "/usr", "/usr",
|
|
28
|
+
];
|
|
29
|
+
for (const entry of input.rootEntries) {
|
|
30
|
+
if (entry.kind === "symlink")
|
|
31
|
+
args.push("--symlink", entry.target, entry.path);
|
|
32
|
+
else
|
|
33
|
+
args.push("--ro-bind", entry.path, entry.path);
|
|
34
|
+
}
|
|
35
|
+
args.push("--ro-bind", "/etc", "/etc", "--dev", "/dev", "--proc", "/proc", "--size", String(SANDBOX_TMP_BYTES), "--tmpfs", "/tmp", "--dir", SANDBOX_HOME, "--overlay-src", input.workspace, "--tmp-overlay", input.workspace);
|
|
36
|
+
if (input.gitCommonDir !== undefined)
|
|
37
|
+
args.push("--overlay-src", input.gitCommonDir, "--tmp-overlay", input.gitCommonDir);
|
|
38
|
+
args.push("--chdir", input.cwd, "--", ...input.argv);
|
|
39
|
+
return args;
|
|
40
|
+
}
|
|
41
|
+
/** The host's `/bin`, `/lib`, `/lib64` and `/sbin`: a symlink is recreated, a directory bound, an absent entry skipped. */
|
|
42
|
+
export function hostRootEntries() {
|
|
43
|
+
const entries = [];
|
|
44
|
+
for (const path of ROOT_ENTRIES) {
|
|
45
|
+
try {
|
|
46
|
+
const info = lstatSync(path);
|
|
47
|
+
if (info.isSymbolicLink())
|
|
48
|
+
entries.push({ path, kind: "symlink", target: readlinkSync(path) });
|
|
49
|
+
else if (info.isDirectory())
|
|
50
|
+
entries.push({ path, kind: "directory" });
|
|
51
|
+
}
|
|
52
|
+
catch { /* absent */ }
|
|
53
|
+
}
|
|
54
|
+
return entries;
|
|
55
|
+
}
|
|
56
|
+
const within = (path, root) => path === root || path.startsWith(root.endsWith(sep) ? root : `${root}${sep}`);
|
|
57
|
+
async function readSmall(path) {
|
|
58
|
+
try {
|
|
59
|
+
const info = await lstat(path);
|
|
60
|
+
if (!info.isFile() || info.size > GITFILE_LIMIT_BYTES)
|
|
61
|
+
return undefined;
|
|
62
|
+
return (await readFile(path, "utf8")).trim();
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const real = async (path) => { try {
|
|
69
|
+
return await realpath(path);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return undefined;
|
|
73
|
+
} };
|
|
74
|
+
const isDirectory = async (path) => { try {
|
|
75
|
+
return (await stat(path)).isDirectory();
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return false;
|
|
79
|
+
} };
|
|
80
|
+
const isFile = async (path) => { try {
|
|
81
|
+
return (await stat(path)).isFile();
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return false;
|
|
85
|
+
} };
|
|
86
|
+
/**
|
|
87
|
+
* The main repository's Git directory of a linked worktree, or `undefined`. Only a `.git` regular file
|
|
88
|
+
* whose `gitdir:` names `<common>/worktrees/<name>` is accepted, and only if that directory points
|
|
89
|
+
* back at this workspace's `.git`, its `commondir` resolves to `<common>`, and `<common>` holds `HEAD`,
|
|
90
|
+
* `objects` and `refs`. Never throws on bad content.
|
|
91
|
+
*/
|
|
92
|
+
export async function resolveWorktreeGitDir(workspace) {
|
|
93
|
+
try {
|
|
94
|
+
const gitFile = join(workspace, ".git");
|
|
95
|
+
const content = await readSmall(gitFile);
|
|
96
|
+
const match = content === undefined ? null : /^gitdir: (.+)$/.exec(content);
|
|
97
|
+
if (!match)
|
|
98
|
+
return undefined;
|
|
99
|
+
const gitdir = await real(resolve(workspace, match[1]));
|
|
100
|
+
if (gitdir === undefined || !(await isDirectory(gitdir)))
|
|
101
|
+
return undefined;
|
|
102
|
+
const worktrees = dirname(gitdir);
|
|
103
|
+
if (basename(worktrees) !== "worktrees")
|
|
104
|
+
return undefined;
|
|
105
|
+
const common = dirname(worktrees);
|
|
106
|
+
const back = await readSmall(join(gitdir, "gitdir"));
|
|
107
|
+
if (back === undefined || (await real(resolve(gitdir, back))) !== (await real(gitFile)))
|
|
108
|
+
return undefined;
|
|
109
|
+
const commondir = await readSmall(join(gitdir, "commondir"));
|
|
110
|
+
if (commondir === undefined || (await real(resolve(gitdir, commondir))) !== common)
|
|
111
|
+
return undefined;
|
|
112
|
+
if (!(await isFile(join(common, "HEAD"))) || !(await isDirectory(join(common, "objects"))) || !(await isDirectory(join(common, "refs"))))
|
|
113
|
+
return undefined;
|
|
114
|
+
return common;
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const defaultSpawn = (file, args, options) => spawn(file, [...args], options);
|
|
121
|
+
const defaultTrusted = () => isTrustedSystemBinary(BWRAP_PATH);
|
|
122
|
+
/** Whether the fixed bubblewrap binary is a trusted system binary. */
|
|
123
|
+
export function sandboxBinaryTrusted(dependencies = {}) {
|
|
124
|
+
try {
|
|
125
|
+
return (dependencies.trusted ?? defaultTrusted)();
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/** The complete argument list for one run; refuses when the fixed binary is not trusted. */
|
|
132
|
+
async function prepare(workspace, cwd, argv, dependencies) {
|
|
133
|
+
if (!sandboxBinaryTrusted(dependencies))
|
|
134
|
+
throw new Error(SANDBOX_UNAVAILABLE_MESSAGE);
|
|
135
|
+
const gitCommonDir = await (dependencies.gitCommonDir ?? resolveWorktreeGitDir)(workspace);
|
|
136
|
+
return sandboxArgs({ workspace, gitCommonDir, cwd, rootEntries: (dependencies.rootEntries ?? hostRootEntries)(), argv });
|
|
137
|
+
}
|
|
138
|
+
/** Spawns bubblewrap and attaches the termination listeners in the same tick, so no event is missed. */
|
|
139
|
+
function start(args, dependencies) {
|
|
140
|
+
// An empty environment: neither the server's nor the tool's variables reach the sandbox.
|
|
141
|
+
const child = (dependencies.spawn ?? defaultSpawn)(BWRAP_PATH, args, { env: {}, stdio: ["ignore", "pipe", "pipe"] });
|
|
142
|
+
const closed = new Promise((resolvePromise, reject) => {
|
|
143
|
+
child.once("error", () => reject(new Error(SANDBOX_UNAVAILABLE_MESSAGE)));
|
|
144
|
+
child.once("close", (code, signal) => resolvePromise({ code, signal }));
|
|
145
|
+
});
|
|
146
|
+
closed.catch(() => undefined);
|
|
147
|
+
return { child, closed };
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Runs `/usr/bin/true` through the same sandbox as a command, bounded by `PROBE_TIMEOUT_MS`.
|
|
151
|
+
* False for an untrusted binary, a spawn failure, a non-zero exit or a timeout.
|
|
152
|
+
*/
|
|
153
|
+
export async function probeSandbox(workspace, dependencies = {}, signal) {
|
|
154
|
+
let running;
|
|
155
|
+
let timer;
|
|
156
|
+
let onAbort;
|
|
157
|
+
try {
|
|
158
|
+
if (signal?.aborted)
|
|
159
|
+
return false;
|
|
160
|
+
const workspaceReal = await realpath(workspace);
|
|
161
|
+
running = start(await prepare(workspaceReal, workspaceReal, [SANDBOX_TRUE_PATH], dependencies), dependencies);
|
|
162
|
+
const { child, closed } = running;
|
|
163
|
+
child.stdout?.resume();
|
|
164
|
+
child.stderr?.resume();
|
|
165
|
+
const deadline = new Promise((resolvePromise) => {
|
|
166
|
+
timer = setTimeout(() => resolvePromise(null), PROBE_TIMEOUT_MS);
|
|
167
|
+
onAbort = () => resolvePromise(null);
|
|
168
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
169
|
+
if (signal?.aborted)
|
|
170
|
+
resolvePromise(null);
|
|
171
|
+
});
|
|
172
|
+
const outcome = await Promise.race([closed, deadline]);
|
|
173
|
+
if (outcome === null)
|
|
174
|
+
child.kill("SIGKILL");
|
|
175
|
+
return outcome !== null && outcome.code === 0 && signal?.aborted !== true;
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
try {
|
|
179
|
+
running?.child.kill("SIGKILL");
|
|
180
|
+
}
|
|
181
|
+
catch { /* already gone */ }
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
finally {
|
|
185
|
+
if (timer)
|
|
186
|
+
clearTimeout(timer);
|
|
187
|
+
if (onAbort)
|
|
188
|
+
signal?.removeEventListener("abort", onAbort);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* `BashOperations` that run each command as `bwrap <fixed arguments> -- bash -c <command>`. The passed
|
|
193
|
+
* `env` is ignored, a working directory outside the workspace is refused and there is no unsandboxed
|
|
194
|
+
* fallback: a sandbox that cannot start fails the command.
|
|
195
|
+
*/
|
|
196
|
+
export function createSandboxedBashOperations(workspace, dependencies = {}) {
|
|
197
|
+
return {
|
|
198
|
+
exec: async (command, cwd, { onData, signal, timeout }) => {
|
|
199
|
+
if (signal?.aborted)
|
|
200
|
+
throw new Error("aborted");
|
|
201
|
+
let workspaceReal;
|
|
202
|
+
let cwdReal;
|
|
203
|
+
try {
|
|
204
|
+
workspaceReal = await realpath(workspace);
|
|
205
|
+
cwdReal = await realpath(isAbsolute(cwd) ? cwd : resolve(workspaceReal, cwd));
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
throw new Error("Working directory is not available");
|
|
209
|
+
}
|
|
210
|
+
if (!within(cwdReal, workspaceReal))
|
|
211
|
+
throw new Error("Working directory is outside the workspace");
|
|
212
|
+
let timeoutMs = DEFAULT_COMMAND_TIMEOUT_SECONDS * 1000;
|
|
213
|
+
if (timeout !== undefined) {
|
|
214
|
+
if (!Number.isFinite(timeout) || timeout <= 0)
|
|
215
|
+
throw new Error("Invalid timeout: must be a finite number of seconds");
|
|
216
|
+
timeoutMs = Math.min(timeout * 1000, 2_147_483_647);
|
|
217
|
+
}
|
|
218
|
+
let running;
|
|
219
|
+
try {
|
|
220
|
+
running = start(await prepare(workspaceReal, cwdReal, [SANDBOX_BASH_PATH, "-c", command], dependencies), dependencies);
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
throw new Error(SANDBOX_UNAVAILABLE_MESSAGE);
|
|
224
|
+
}
|
|
225
|
+
const { child, closed } = running;
|
|
226
|
+
child.stdout?.on("data", onData);
|
|
227
|
+
child.stderr?.on("data", onData);
|
|
228
|
+
let timedOut = false;
|
|
229
|
+
let timer;
|
|
230
|
+
const kill = () => { try {
|
|
231
|
+
child.kill("SIGKILL");
|
|
232
|
+
}
|
|
233
|
+
catch { /* already gone */ } };
|
|
234
|
+
const onAbort = () => kill();
|
|
235
|
+
try {
|
|
236
|
+
timer = setTimeout(() => { timedOut = true; kill(); }, timeoutMs);
|
|
237
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
238
|
+
if (signal?.aborted)
|
|
239
|
+
kill();
|
|
240
|
+
const outcome = await closed;
|
|
241
|
+
if (signal?.aborted)
|
|
242
|
+
throw new Error("aborted");
|
|
243
|
+
if (timedOut)
|
|
244
|
+
throw new Error(`timeout:${timeout ?? DEFAULT_COMMAND_TIMEOUT_SECONDS}`);
|
|
245
|
+
return { exitCode: outcome.code ?? (outcome.signal ? 128 + (osConstants.signals[outcome.signal] ?? 0) : 1) };
|
|
246
|
+
}
|
|
247
|
+
finally {
|
|
248
|
+
if (timer)
|
|
249
|
+
clearTimeout(timer);
|
|
250
|
+
signal?.removeEventListener("abort", onAbort);
|
|
251
|
+
}
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
}
|
|
@@ -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
|
}
|
|
@@ -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");
|
|
@@ -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,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
|
+
}
|