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
|
@@ -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
|
}
|
|
@@ -146,13 +159,14 @@ export async function loadConfig(path = process.env.PI_SESSION_MCP_CONFIG) {
|
|
|
146
159
|
const overrideDirectory = skillOverrideDirectory(absoluteConfig);
|
|
147
160
|
const workspaces = new ImmutableMap(Object.entries(parsed.workspaces).map(([key, value]) => [key, resolve(base, value)]));
|
|
148
161
|
const catalog = parsed.skillCatalog ?? {};
|
|
149
|
-
const executionProfiles = new ImmutableMap(Object.entries(parsed.executionProfiles).map(([key, { mcpServers, extensions, grantedExtensionTools, skills, ...value }]) => {
|
|
162
|
+
const executionProfiles = new ImmutableMap(Object.entries(parsed.executionProfiles).map(([key, { mcpServers, extensions, grantedExtensionTools, shell, skills, ...value }]) => {
|
|
150
163
|
const selection = resolveSkillSelection(skills, catalog, base, overrideDirectory);
|
|
151
164
|
return [key, Object.freeze({
|
|
152
165
|
alias: key, ...value,
|
|
153
166
|
...(mcpServers === undefined ? {} : { mcpServers: snapshotMcpServers(mcpServers) }),
|
|
154
167
|
...(extensions === undefined ? {} : { extensions: Object.freeze(extensions.map((spec) => parseExtensionSpec(spec))) }),
|
|
155
168
|
...(grantedExtensionTools === undefined ? {} : { grantedExtensionTools: Object.freeze([...grantedExtensionTools]) }),
|
|
169
|
+
...(shell === undefined ? {} : { shell }),
|
|
156
170
|
...(selection === undefined ? {} : { skills: selection }),
|
|
157
171
|
})];
|
|
158
172
|
}));
|
|
@@ -3,6 +3,7 @@ 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";
|
|
@@ -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);
|
|
@@ -627,6 +630,46 @@ export async function runDoctor(dependencies = {}) {
|
|
|
627
630
|
}
|
|
628
631
|
}
|
|
629
632
|
}
|
|
633
|
+
if (config) {
|
|
634
|
+
const sandboxed = [...config.executionProfiles].filter(([, profile]) => profile.shell === "sandboxed").map(([alias]) => alias).sort((a, b) => a.localeCompare(b));
|
|
635
|
+
if (sandboxed.length > 0) {
|
|
636
|
+
// The overlay depends on the workspace's filesystem, so each existing workspace directory is probed.
|
|
637
|
+
for (const [alias, path] of [...config.workspaces].sort(([a], [b]) => a.localeCompare(b))) {
|
|
638
|
+
try {
|
|
639
|
+
if (!(await fs.stat(path)).isDirectory())
|
|
640
|
+
continue;
|
|
641
|
+
}
|
|
642
|
+
catch {
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
let usable = false;
|
|
646
|
+
try {
|
|
647
|
+
usable = await (dependencies.sandboxProbe ?? defaultSandboxProbe)(path);
|
|
648
|
+
}
|
|
649
|
+
catch {
|
|
650
|
+
usable = false;
|
|
651
|
+
}
|
|
652
|
+
checks.push(usable
|
|
653
|
+
? ok("sandbox_unavailable", alias)
|
|
654
|
+
: 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"));
|
|
655
|
+
}
|
|
656
|
+
// Without a usable worktree pointer Git commands fail inside the sandbox; the read tools are unaffected.
|
|
657
|
+
for (const [alias, path] of [...config.workspaces].sort(([a], [b]) => a.localeCompare(b))) {
|
|
658
|
+
if (!(await hasGitfilePointer(fs, path)))
|
|
659
|
+
continue;
|
|
660
|
+
let gitDir;
|
|
661
|
+
try {
|
|
662
|
+
gitDir = await (dependencies.worktreeGitDir ?? resolveWorktreeGitDir)(path);
|
|
663
|
+
}
|
|
664
|
+
catch {
|
|
665
|
+
gitDir = undefined;
|
|
666
|
+
}
|
|
667
|
+
checks.push(gitDir === undefined
|
|
668
|
+
? 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")
|
|
669
|
+
: ok("workspace_git_unavailable", alias));
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
}
|
|
630
673
|
if (config?.eventDelivery === "spool")
|
|
631
674
|
checks.push(await eventSpoolCheck(dependencies.env ?? process.env, dependencies.eventSpoolRoot ?? defaultRootInfo, dependencies.uid ?? process.getuid?.()));
|
|
632
675
|
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
|
+
}
|
|
@@ -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
|
}
|