pi-microsandbox 0.1.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/LICENSE +21 -0
- package/README.md +120 -0
- package/SECURITY.md +58 -0
- package/docs/commands.md +38 -0
- package/docs/configuration.md +80 -0
- package/docs/development.md +119 -0
- package/docs/getting-started.md +66 -0
- package/docs/images.md +190 -0
- package/docs/safety.md +39 -0
- package/docs/storage.md +57 -0
- package/docs/troubleshooting.md +20 -0
- package/extensions/pi-msb/command.ts +532 -0
- package/extensions/pi-msb/config.ts +771 -0
- package/extensions/pi-msb/control.ts +803 -0
- package/extensions/pi-msb/footer.ts +191 -0
- package/extensions/pi-msb/git.ts +256 -0
- package/extensions/pi-msb/index.ts +156 -0
- package/extensions/pi-msb/labels.ts +321 -0
- package/extensions/pi-msb/locks.ts +292 -0
- package/extensions/pi-msb/operations-exec.ts +434 -0
- package/extensions/pi-msb/operations.ts +321 -0
- package/extensions/pi-msb/prune.ts +232 -0
- package/extensions/pi-msb/sandbox-manager.ts +702 -0
- package/extensions/pi-msb/skill-access.ts +164 -0
- package/extensions/pi-msb/storage.ts +332 -0
- package/extensions/pi-msb/tools.ts +417 -0
- package/extensions/pi-msb/transport.ts +518 -0
- package/extensions/pi-msb/types.ts +436 -0
- package/package.json +74 -0
|
@@ -0,0 +1,771 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import { homedir as osHomedir } from "node:os";
|
|
3
|
+
import { dirname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
|
|
4
|
+
import type {
|
|
5
|
+
Config,
|
|
6
|
+
ConfigLayerInput,
|
|
7
|
+
ConfigLayerName,
|
|
8
|
+
DeepPartial,
|
|
9
|
+
DeepReadonly,
|
|
10
|
+
MergeResult,
|
|
11
|
+
ParsedConfigLayer,
|
|
12
|
+
ResolvedConfig,
|
|
13
|
+
} from "./types.ts";
|
|
14
|
+
|
|
15
|
+
const ROUTED_TOOLS = ["read", "write", "edit", "ls", "find", "grep", "bash"];
|
|
16
|
+
const PASS_THROUGH_TOOLS = ["todo", "ask_user_question", "web_search", "source_check", "fetch_content"];
|
|
17
|
+
const ALLOWLIST_PATHS = new Set(["routeTools", "passThroughTools", "hostEnv", "hostRoAllowlist", "network.allowHosts"]);
|
|
18
|
+
const CONTROL_KEYS = new Set(["removeSecrets", "removeMounts", "removeRouteTools", "removePassThroughTools", "removeHostEnv", "removeHostRoAllowlist", "removeAllowHosts", "removePublishPorts"]);
|
|
19
|
+
const SECRET_FIELDS = new Set(["env", "value", "allowHosts"]);
|
|
20
|
+
const MOUNT_FIELDS = new Set(["type", "hostPath", "guestPath", "readonly", "options"]);
|
|
21
|
+
const FORBIDDEN_CONFIG_KEYS = new Set(["__proto__", "prototype", "constructor"]);
|
|
22
|
+
|
|
23
|
+
function deepFreeze<T>(value: T): DeepReadonly<T> {
|
|
24
|
+
if (value !== null && typeof value === "object" && !Object.isFrozen(value)) {
|
|
25
|
+
for (const child of Object.values(value)) deepFreeze(child);
|
|
26
|
+
Object.freeze(value);
|
|
27
|
+
}
|
|
28
|
+
return value as DeepReadonly<T>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Defaults from PLAN §11.2. Values containing credentials are deliberately absent. */
|
|
32
|
+
export const DEFAULT_CONFIG = deepFreeze<Config>({
|
|
33
|
+
image: "ghcr.io/hcohe/pi-microsandbox:1.0.0@sha256:00ea1e0911189815614e8a8eee36d1fd64f0f1edb39492e0bda9f273c834e59f",
|
|
34
|
+
pullPolicy: "if-missing",
|
|
35
|
+
bootstrapTools: "auto",
|
|
36
|
+
cpus: 1,
|
|
37
|
+
memoryMiB: 512,
|
|
38
|
+
idleTimeoutSec: 600,
|
|
39
|
+
stopTimeoutMs: 10_000,
|
|
40
|
+
detached: true,
|
|
41
|
+
replace: false,
|
|
42
|
+
replaceTimeoutMs: 10_000,
|
|
43
|
+
sandboxName: null,
|
|
44
|
+
mode: "direct",
|
|
45
|
+
cloneBranch: "current",
|
|
46
|
+
cloneDepth: "unlimited",
|
|
47
|
+
shallowArchive: false,
|
|
48
|
+
volumeQuotaMiB: 2_048,
|
|
49
|
+
network: { mode: "default", allowHosts: [], allowDns: true, publishPorts: [] },
|
|
50
|
+
secrets: [],
|
|
51
|
+
mounts: [],
|
|
52
|
+
blockThirdParty: true,
|
|
53
|
+
routeTools: [...ROUTED_TOOLS],
|
|
54
|
+
passThroughTools: [...PASS_THROUGH_TOOLS],
|
|
55
|
+
allowHostExecution: true,
|
|
56
|
+
allowSkillReads: true,
|
|
57
|
+
fallbackMode: "block",
|
|
58
|
+
exposeSessionEnvironment: false,
|
|
59
|
+
hostEnv: [],
|
|
60
|
+
autoStart: true,
|
|
61
|
+
pruneOnStart: true,
|
|
62
|
+
showFooter: true,
|
|
63
|
+
lockDir: "~/.pi-msb/locks",
|
|
64
|
+
hostRoAllowlist: [],
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
export interface ResolveConfigInput {
|
|
68
|
+
cwd: string;
|
|
69
|
+
repoRoot?: string | null;
|
|
70
|
+
projectTrusted: boolean;
|
|
71
|
+
configDirName: string;
|
|
72
|
+
env?: NodeJS.ProcessEnv;
|
|
73
|
+
homedir?: string;
|
|
74
|
+
xdgConfigHome?: string;
|
|
75
|
+
cliOverridesToml?: string;
|
|
76
|
+
readFile?: (path: string) => Promise<string | null>;
|
|
77
|
+
exists?: (path: string) => Promise<boolean>;
|
|
78
|
+
realpath?: (path: string) => Promise<string>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export class ConfigError extends Error {
|
|
82
|
+
readonly issues: readonly string[];
|
|
83
|
+
constructor(issues: readonly string[] | string) {
|
|
84
|
+
const list = typeof issues === "string" ? [issues] : [...issues];
|
|
85
|
+
super(list.join("; "));
|
|
86
|
+
this.name = "ConfigError";
|
|
87
|
+
this.issues = list;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function createRecord(): Record<string, unknown> {
|
|
92
|
+
return Object.create(null) as Record<string, unknown>;
|
|
93
|
+
}
|
|
94
|
+
function assertSafeConfigKey(key: string, path: readonly string[]): void {
|
|
95
|
+
let semanticKey = key;
|
|
96
|
+
if (key.length >= 2 && key[0] === key[key.length - 1] && (key[0] === "\"" || key[0] === "'")) {
|
|
97
|
+
if (key[0] === "'") semanticKey = key.slice(1, -1);
|
|
98
|
+
else {
|
|
99
|
+
try { semanticKey = JSON.parse(key) as string; } catch { /* malformed TOML is rejected elsewhere */ }
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (FORBIDDEN_CONFIG_KEYS.has(key) || FORBIDDEN_CONFIG_KEYS.has(semanticKey)) {
|
|
103
|
+
throw new ConfigError([`forbidden config key ${path.join(".")}`]);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function assertSafeConfigPath(parts: readonly string[]): void {
|
|
107
|
+
for (let index = 0; index < parts.length; index++) assertSafeConfigKey(parts[index]!, parts.slice(0, index + 1));
|
|
108
|
+
}
|
|
109
|
+
function assertSafeConfigValue(value: unknown, path: readonly string[] = []): void {
|
|
110
|
+
if (Array.isArray(value)) {
|
|
111
|
+
value.forEach((child, index) => assertSafeConfigValue(child, [...path, `[${index}]`]));
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (value === null || typeof value !== "object") return;
|
|
115
|
+
const prototype = Object.getPrototypeOf(value);
|
|
116
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
117
|
+
throw new ConfigError([`unsafe config object at ${path.join(".") || "<root>"}`]);
|
|
118
|
+
}
|
|
119
|
+
for (const [key, child] of Object.entries(value)) {
|
|
120
|
+
assertSafeConfigKey(key, [...path, key]);
|
|
121
|
+
assertSafeConfigValue(child, [...path, key]);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
type LayerValue = DeepPartial<Config> & {
|
|
126
|
+
removeSecrets?: unknown;
|
|
127
|
+
removeMounts?: unknown;
|
|
128
|
+
removeRouteTools?: unknown;
|
|
129
|
+
removePassThroughTools?: unknown;
|
|
130
|
+
removeHostEnv?: unknown;
|
|
131
|
+
removeHostRoAllowlist?: unknown;
|
|
132
|
+
removeAllowHosts?: unknown;
|
|
133
|
+
removePublishPorts?: unknown;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const clone = <T>(value: T): T => {
|
|
137
|
+
if (value === undefined || value === null || typeof value !== "object") return value;
|
|
138
|
+
return structuredClone(value);
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
function camel(key: string): string {
|
|
142
|
+
const input = /^[A-Z0-9_]+$/.test(key) ? key.toLowerCase() : key;
|
|
143
|
+
return input.replace(/_([a-zA-Z0-9])/g, (_, c: string) => c.toUpperCase()).replace(/Mib$/, "MiB");
|
|
144
|
+
}
|
|
145
|
+
function snake(key: string): string {
|
|
146
|
+
return key.replace(/MiB/g, "mib").replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
|
|
147
|
+
}
|
|
148
|
+
function pathKey(parts: string[]): string { return parts.join("."); }
|
|
149
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
150
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
151
|
+
const prototype = Object.getPrototypeOf(value);
|
|
152
|
+
return prototype === Object.prototype || prototype === null;
|
|
153
|
+
}
|
|
154
|
+
function withoutSecret(value: unknown): string {
|
|
155
|
+
return typeof value === "string" && /^\$(?:ENV|FILE):[^\s]+$/.test(value) ? value : "<redacted>";
|
|
156
|
+
}
|
|
157
|
+
function warning(text: string): string { return text.replace(/(secret|password|token|value)\s*=\s*[^,; ]+/gi, "$1=<redacted>"); }
|
|
158
|
+
|
|
159
|
+
function stripTomlComment(line: string): string {
|
|
160
|
+
let quote = "";
|
|
161
|
+
let depth = 0;
|
|
162
|
+
for (let i = 0; i < line.length; i++) {
|
|
163
|
+
const c = line[i];
|
|
164
|
+
if (quote) {
|
|
165
|
+
if (c === quote && line[i - 1] !== "\\") quote = "";
|
|
166
|
+
} else if (c === "\"" || c === "'") quote = c;
|
|
167
|
+
else if (c === "[" || c === "{") depth++;
|
|
168
|
+
else if (c === "]" || c === "}") depth--;
|
|
169
|
+
else if (c === "#" && depth === 0) return line.slice(0, i).trim();
|
|
170
|
+
}
|
|
171
|
+
return line.trim();
|
|
172
|
+
}
|
|
173
|
+
function splitTopLevel(text: string, delimiter = ","): string[] {
|
|
174
|
+
const result: string[] = [];
|
|
175
|
+
let start = 0, depth = 0, quote = "";
|
|
176
|
+
for (let i = 0; i < text.length; i++) {
|
|
177
|
+
const c = text[i];
|
|
178
|
+
if (quote) { if (c === quote && text[i - 1] !== "\\") quote = ""; continue; }
|
|
179
|
+
if (c === "\"" || c === "'") quote = c;
|
|
180
|
+
else if ("[{(".includes(c)) depth++;
|
|
181
|
+
else if ("]})".includes(c)) depth--;
|
|
182
|
+
else if (c === delimiter && depth === 0) { result.push(text.slice(start, i).trim()); start = i + 1; }
|
|
183
|
+
}
|
|
184
|
+
result.push(text.slice(start).trim());
|
|
185
|
+
return result.filter(Boolean);
|
|
186
|
+
}
|
|
187
|
+
function findEquals(text: string): number {
|
|
188
|
+
let quote = "", depth = 0;
|
|
189
|
+
for (let i = 0; i < text.length; i++) {
|
|
190
|
+
const c = text[i];
|
|
191
|
+
if (quote) { if (c === quote && text[i - 1] !== "\\") quote = ""; }
|
|
192
|
+
else if (c === "\"" || c === "'") quote = c;
|
|
193
|
+
else if ("[{".includes(c)) depth++;
|
|
194
|
+
else if ("]}".includes(c)) depth--;
|
|
195
|
+
else if (c === "=" && depth === 0) return i;
|
|
196
|
+
}
|
|
197
|
+
return -1;
|
|
198
|
+
}
|
|
199
|
+
function parseTomlValue(text: string): unknown {
|
|
200
|
+
const value = text.trim();
|
|
201
|
+
if (value.startsWith("[") && value.endsWith("]")) return splitTopLevel(value.slice(1, -1)).map(parseTomlValue);
|
|
202
|
+
if (value.startsWith("{") && value.endsWith("}")) {
|
|
203
|
+
const object = createRecord();
|
|
204
|
+
for (const part of splitTopLevel(value.slice(1, -1))) {
|
|
205
|
+
const at = findEquals(part);
|
|
206
|
+
if (at < 0) throw new Error("invalid inline table");
|
|
207
|
+
const key = part.slice(0, at).trim();
|
|
208
|
+
assertSafeConfigKey(key, [key]);
|
|
209
|
+
object[key] = parseTomlValue(part.slice(at + 1));
|
|
210
|
+
}
|
|
211
|
+
return object;
|
|
212
|
+
}
|
|
213
|
+
if ((value.startsWith("\"") && value.endsWith("\"")) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
214
|
+
if (value[0] === "'") return value.slice(1, -1);
|
|
215
|
+
try { return JSON.parse(value); } catch { return value.slice(1, -1); }
|
|
216
|
+
}
|
|
217
|
+
if (value === "true" || value === "false") return value === "true";
|
|
218
|
+
if (/^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/.test(value)) return Number(value);
|
|
219
|
+
return value;
|
|
220
|
+
}
|
|
221
|
+
function assign(root: Record<string, unknown>, keys: string[], value: unknown): void {
|
|
222
|
+
assertSafeConfigPath(keys);
|
|
223
|
+
let current = root;
|
|
224
|
+
for (const key of keys.slice(0, -1)) {
|
|
225
|
+
if (!isPlainObject(current[key])) current[key] = createRecord();
|
|
226
|
+
current = current[key] as Record<string, unknown>;
|
|
227
|
+
}
|
|
228
|
+
current[keys[keys.length - 1]!] = value;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** A small dependency-free TOML reader for the config subset. It intentionally has no I/O. */
|
|
232
|
+
function readToml(text: string): Record<string, unknown> {
|
|
233
|
+
const root = createRecord();
|
|
234
|
+
let section: string[] = [];
|
|
235
|
+
let arraySection: string[] | null = null;
|
|
236
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
237
|
+
const line = stripTomlComment(raw);
|
|
238
|
+
if (!line) continue;
|
|
239
|
+
if (line.startsWith("[[") && line.endsWith("]]")) {
|
|
240
|
+
const keys = line.slice(2, -2).trim().split(".").map((x) => x.trim());
|
|
241
|
+
assertSafeConfigPath(keys);
|
|
242
|
+
let parent: Record<string, unknown> = root;
|
|
243
|
+
for (const key of keys.slice(0, -1)) {
|
|
244
|
+
if (!Array.isArray(parent[key])) parent[key] = [];
|
|
245
|
+
const list = parent[key] as unknown[];
|
|
246
|
+
const last = list[list.length - 1];
|
|
247
|
+
if (!isPlainObject(last)) list.push(createRecord());
|
|
248
|
+
parent = list[list.length - 1] as Record<string, unknown>;
|
|
249
|
+
}
|
|
250
|
+
const final = keys[keys.length - 1]!;
|
|
251
|
+
if (!Array.isArray(parent[final])) parent[final] = [];
|
|
252
|
+
(parent[final] as unknown[]).push(createRecord());
|
|
253
|
+
arraySection = keys;
|
|
254
|
+
section = [];
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (line.startsWith("[") && line.endsWith("]")) {
|
|
258
|
+
section = line.slice(1, -1).trim().split(".").map((x) => x.trim());
|
|
259
|
+
assertSafeConfigPath(section);
|
|
260
|
+
arraySection = null;
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
const at = findEquals(line);
|
|
264
|
+
if (at < 0) throw new Error("invalid assignment");
|
|
265
|
+
const key = line.slice(0, at).trim();
|
|
266
|
+
const value = parseTomlValue(line.slice(at + 1));
|
|
267
|
+
if (arraySection) {
|
|
268
|
+
let parent: Record<string, unknown> = root;
|
|
269
|
+
for (const part of arraySection) {
|
|
270
|
+
const list = parent[part];
|
|
271
|
+
if (!Array.isArray(list) || !isPlainObject(list[list.length - 1])) throw new Error("invalid array table");
|
|
272
|
+
parent = list[list.length - 1] as Record<string, unknown>;
|
|
273
|
+
}
|
|
274
|
+
assertSafeConfigKey(key, [...arraySection, key]);
|
|
275
|
+
parent[key] = value;
|
|
276
|
+
} else assign(root, [...section, ...key.split(".").map((x) => x.trim())], value);
|
|
277
|
+
}
|
|
278
|
+
return root;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function normalizeValue(value: unknown, path: readonly string[] = []): unknown {
|
|
282
|
+
if (Array.isArray(value)) return value.map((child, index) => normalizeValue(child, [...path, `[${index}]`]));
|
|
283
|
+
if (!isPlainObject(value)) return value;
|
|
284
|
+
const out: Record<string, unknown> = {};
|
|
285
|
+
for (const [key, child] of Object.entries(value)) {
|
|
286
|
+
assertSafeConfigKey(key, [...path, key]);
|
|
287
|
+
const normalizedKey = camel(key);
|
|
288
|
+
assertSafeConfigKey(normalizedKey, [...path, normalizedKey]);
|
|
289
|
+
out[normalizedKey] = normalizeValue(child, [...path, normalizedKey]);
|
|
290
|
+
}
|
|
291
|
+
return out;
|
|
292
|
+
}
|
|
293
|
+
function knownPath(path: string): boolean {
|
|
294
|
+
const parts = path.split(".");
|
|
295
|
+
if (parts[0] === "removeSecrets") return parts.length === 1 || (parts.length === 2 && SECRET_FIELDS.has(parts[1]));
|
|
296
|
+
if (parts[0] === "removeMounts") return parts.length === 1 || (parts.length === 2 && MOUNT_FIELDS.has(parts[1]));
|
|
297
|
+
if (parts[0].startsWith("remove")) {
|
|
298
|
+
const nestedNetworkRemoval = parts[0] === "removeAllowHosts" || parts[0] === "removePublishPorts";
|
|
299
|
+
return parts.length === 1 && CONTROL_KEYS.has(parts[0]) && !nestedNetworkRemoval;
|
|
300
|
+
}
|
|
301
|
+
if (parts[0] === "network") return parts.length === 1 || (parts.length === 2 && ["mode", "allowHosts", "allowDns", "publishPorts", "removeAllowHosts", "removePublishPorts"].includes(parts[1]));
|
|
302
|
+
if (parts[0] === "secrets") return parts.length === 1 || (parts.length === 2 && SECRET_FIELDS.has(parts[1]));
|
|
303
|
+
if (parts[0] === "mounts") return parts.length === 1 || (parts.length === 2 && MOUNT_FIELDS.has(parts[1]));
|
|
304
|
+
return ["image", "pullPolicy", "bootstrapTools", "cpus", "memoryMiB", "idleTimeoutSec", "stopTimeoutMs", "detached", "replace", "replaceTimeoutMs", "sandboxName", "mode", "cloneBranch", "cloneDepth", "shallowArchive", "volumeQuotaMiB", "blockThirdParty", "routeTools", "passThroughTools", "allowHostExecution", "allowSkillReads", "fallbackMode", "exposeSessionEnvironment", "hostEnv", "autoStart", "pruneOnStart", "showFooter", "lockDir", "hostRoAllowlist"].includes(parts[0]);
|
|
305
|
+
}
|
|
306
|
+
function collectUnknown(value: unknown, base: string[], warnings: string[]): void {
|
|
307
|
+
if (!isPlainObject(value)) return;
|
|
308
|
+
for (const [key, child] of Object.entries(value)) {
|
|
309
|
+
const current = pathKey([...base, key]);
|
|
310
|
+
if (!knownPath(current)) warnings.push(`unknown config key ${current}`);
|
|
311
|
+
else if (isPlainObject(child)) collectUnknown(child, [...base, key], warnings);
|
|
312
|
+
else if (Array.isArray(child)) child.forEach((item) => isPlainObject(item) && collectUnknown(item, [...base, key], warnings));
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
function canonicalGuestPath(value: unknown): unknown {
|
|
316
|
+
if (typeof value !== "string" || !isAbsolute(value)) return value;
|
|
317
|
+
const result = normalize(value).replace(/[\\/]$/, "");
|
|
318
|
+
return result || sep;
|
|
319
|
+
}
|
|
320
|
+
function withMountDefaults(value: LayerValue): LayerValue {
|
|
321
|
+
const result = clone(value);
|
|
322
|
+
if (!Array.isArray(result.mounts)) return result;
|
|
323
|
+
result.mounts = result.mounts.map((raw) => {
|
|
324
|
+
if (!isPlainObject(raw)) return raw as any;
|
|
325
|
+
const mount = { ...raw } as Record<string, unknown>;
|
|
326
|
+
if (mount.type === undefined) mount.type = "dir";
|
|
327
|
+
if (mount.guestPath === undefined && (mount.type === "dir" || mount.type === "file") && typeof mount.hostPath === "string") mount.guestPath = mount.hostPath;
|
|
328
|
+
if (mount.readonly === undefined) mount.readonly = true;
|
|
329
|
+
if (mount.options === undefined) mount.options = [];
|
|
330
|
+
if (typeof mount.guestPath === "string") mount.guestPath = canonicalGuestPath(mount.guestPath);
|
|
331
|
+
return mount as any;
|
|
332
|
+
}) as any;
|
|
333
|
+
return result;
|
|
334
|
+
}
|
|
335
|
+
function normalizeLayer(name: ConfigLayerName, raw: Record<string, unknown>, source?: string): ParsedConfigLayer {
|
|
336
|
+
assertSafeConfigValue(raw);
|
|
337
|
+
const value = withMountDefaults(normalizeValue(raw) as LayerValue);
|
|
338
|
+
const warnings: string[] = [];
|
|
339
|
+
collectUnknown(value, [], warnings);
|
|
340
|
+
if (name === "project" && Array.isArray(value.secrets)) {
|
|
341
|
+
for (const secret of value.secrets as any[]) {
|
|
342
|
+
if (typeof secret?.value === "string" && !/^\$(?:ENV|FILE):/.test(secret.value)) {
|
|
343
|
+
warnings.push("project config contains a literal secret value; use $ENV or $FILE instead");
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return { name, value, warnings: warnings.map(warning), source };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export function parseTomlConfig(text: string, source: string): ParsedConfigLayer {
|
|
351
|
+
try { return normalizeLayer(source === "cli" ? "cli" : source === "env" ? "env" : source === "project" ? "project" : "global", readToml(text), source); }
|
|
352
|
+
catch { throw new ConfigError([`invalid TOML in ${source}`]); }
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function envScalar(text: string, key: string): unknown {
|
|
356
|
+
const trimmed = text.trim();
|
|
357
|
+
if ((trimmed.startsWith("{") || trimmed.startsWith("[")) && trimmed.endsWith(trimmed[0] === "{" ? "}" : "]")) {
|
|
358
|
+
let parsed: unknown;
|
|
359
|
+
try { parsed = JSON.parse(trimmed); } catch { /* use scalar below */ }
|
|
360
|
+
if (parsed !== undefined) return normalizeValue(parsed);
|
|
361
|
+
}
|
|
362
|
+
if (trimmed === "true" || trimmed === "false") return trimmed === "true";
|
|
363
|
+
if (/^[+-]?\d+(?:\.\d+)?$/.test(trimmed)) return Number(trimmed);
|
|
364
|
+
if (ALLOWLIST_PATHS.has(key) || key.endsWith("Tools") || key.endsWith("Env") || key.endsWith("Hosts") || key.endsWith("Ports")) {
|
|
365
|
+
// JSON remains the unambiguous form (especially for port mappings). For
|
|
366
|
+
// scalar lists use the native PATH-style delimiter: ':' on POSIX and ';'
|
|
367
|
+
// on Windows. Keep comma input as a compatibility fallback when no native
|
|
368
|
+
// delimiter is present.
|
|
369
|
+
const delimiter = process.platform === "win32" ? ";" : ":";
|
|
370
|
+
const separator = process.platform !== "win32" && !trimmed.includes(":") && trimmed.includes(",") ? "," : delimiter;
|
|
371
|
+
return trimmed ? trimmed.split(separator).map((item) => item.trim()).filter(Boolean) : [];
|
|
372
|
+
}
|
|
373
|
+
return text;
|
|
374
|
+
}
|
|
375
|
+
export function parseEnvConfig(env: NodeJS.ProcessEnv): ParsedConfigLayer {
|
|
376
|
+
const root = createRecord();
|
|
377
|
+
const warnings: string[] = [];
|
|
378
|
+
for (const [name, raw] of Object.entries(env)) {
|
|
379
|
+
if (!name.startsWith("PI_MSB_") || raw === undefined || name === "PI_MSB_DISABLE" || name === "PI_MSB_CONFIG_FILE") continue;
|
|
380
|
+
const alias = name === "PI_MSB_HOST_RO_PATHS";
|
|
381
|
+
const parts = alias ? ["hostRoAllowlist"] : name.slice("PI_MSB_".length).split("__").filter(Boolean).map(camel);
|
|
382
|
+
if (!parts.length) continue;
|
|
383
|
+
assign(root, parts, envScalar(raw as string, pathKey(parts)));
|
|
384
|
+
}
|
|
385
|
+
const layer = normalizeLayer("env", root, "environment");
|
|
386
|
+
return { ...layer, warnings: [...layer.warnings, ...warnings] };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function mergeObject(target: Record<string, any>, source: Record<string, any>, prefix: string[], layer: ConfigLayerName, provenance: Record<string, ConfigLayerName>, warnings: string[]): void {
|
|
390
|
+
for (const [key, value] of Object.entries(source)) {
|
|
391
|
+
const path = [...prefix, key];
|
|
392
|
+
assertSafeConfigKey(key, path);
|
|
393
|
+
if (CONTROL_KEYS.has(key) || value === undefined) continue;
|
|
394
|
+
if (key === "secrets" && Array.isArray(value)) { mergeIdentity(target, key, value, "env", layer, provenance); continue; }
|
|
395
|
+
if (key === "mounts" && Array.isArray(value)) { mergeIdentity(target, key, value, "guestPath", layer, provenance); continue; }
|
|
396
|
+
if (ALLOWLIST_PATHS.has(pathKey(path)) && Array.isArray(value)) {
|
|
397
|
+
const existing = Array.isArray(target[key]) ? target[key] : [];
|
|
398
|
+
const removeName = `remove${key[0].toUpperCase()}${key.slice(1)}`;
|
|
399
|
+
const remove = (source as LayerValue)[removeName as keyof LayerValue];
|
|
400
|
+
const removeValues = Array.isArray(remove) ? remove.map(String) : [];
|
|
401
|
+
target[key] = [...existing.filter((x: unknown) => !removeValues.includes(String(x))), ...value.filter((x: unknown) => !removeValues.includes(String(x)) && !existing.includes(x))];
|
|
402
|
+
if (value.length || removeValues.length) provenance[pathKey(path)] = layer;
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
if (isPlainObject(value)) {
|
|
406
|
+
if (!isPlainObject(target[key])) target[key] = {};
|
|
407
|
+
mergeObject(target[key], value, path, layer, provenance, warnings);
|
|
408
|
+
} else {
|
|
409
|
+
target[key] = clone(value);
|
|
410
|
+
provenance[pathKey(path)] = layer;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
const removalKeys = prefix[0] === "network"
|
|
414
|
+
? ["removeAllowHosts", "removePublishPorts"]
|
|
415
|
+
: ["removeRouteTools", "removePassThroughTools", "removeHostEnv", "removeHostRoAllowlist"];
|
|
416
|
+
for (const key of removalKeys) {
|
|
417
|
+
const remove = (source as Record<string, unknown>)[key];
|
|
418
|
+
if (!Array.isArray(remove)) continue;
|
|
419
|
+
const field = key.slice(6, 7).toLowerCase() + key.slice(7);
|
|
420
|
+
if (Array.isArray(target[field])) target[field] = target[field].filter((x: unknown) => !remove.map(String).includes(String(x)));
|
|
421
|
+
provenance[pathKey([...prefix, field])] = layer;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
function mergeIdentity(target: Record<string, any>, key: string, incoming: unknown[], identity: string, layer: ConfigLayerName, provenance: Record<string, ConfigLayerName>): void {
|
|
425
|
+
const existing: any[] = Array.isArray(target[key]) ? target[key] : [];
|
|
426
|
+
for (const [incomingIndex, item] of incoming.entries()) {
|
|
427
|
+
if (!isPlainObject(item)) throw new ConfigError([`${key}[${incomingIndex}] must be an object`]);
|
|
428
|
+
const id = String(item[identity] ?? "");
|
|
429
|
+
const existingIndex = existing.findIndex((old) => isPlainObject(old) && String(old[identity] ?? "") === id);
|
|
430
|
+
if (existingIndex >= 0) existing[existingIndex] = clone(item); else existing.push(clone(item));
|
|
431
|
+
provenance[`${key}[${id}]`] = layer;
|
|
432
|
+
}
|
|
433
|
+
target[key] = existing;
|
|
434
|
+
}
|
|
435
|
+
function applyRemovals(target: Record<string, any>, source: LayerValue, layer: ConfigLayerName, provenance: Record<string, ConfigLayerName>): void {
|
|
436
|
+
for (const [key, identity, removeKey] of [["secrets", "env", "removeSecrets"], ["mounts", "guestPath", "removeMounts"]] as const) {
|
|
437
|
+
const remove = source[removeKey];
|
|
438
|
+
if (!Array.isArray(remove) || !Array.isArray(target[key])) continue;
|
|
439
|
+
const ids = remove.map((item) => {
|
|
440
|
+
const raw = isPlainObject(item) ? item[identity] ?? item.env ?? item.guestPath ?? "" : item;
|
|
441
|
+
return key === "mounts" ? String(canonicalGuestPath(raw)) : String(raw);
|
|
442
|
+
});
|
|
443
|
+
target[key] = target[key].filter((item: any) => {
|
|
444
|
+
const raw = item?.[identity] ?? "";
|
|
445
|
+
return !ids.includes(key === "mounts" ? String(canonicalGuestPath(raw)) : String(raw));
|
|
446
|
+
});
|
|
447
|
+
provenance[key] = layer;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
export function mergeConfigLayers(layers: readonly ConfigLayerInput[]): MergeResult {
|
|
452
|
+
const result: Record<string, any> = {};
|
|
453
|
+
const provenance: Record<string, ConfigLayerName> = {};
|
|
454
|
+
const warnings: string[] = [];
|
|
455
|
+
for (const layer of layers) {
|
|
456
|
+
assertSafeConfigValue(layer.value);
|
|
457
|
+
warnings.push(...layer.warnings.map(warning));
|
|
458
|
+
const source = withMountDefaults(clone(layer.value) as LayerValue);
|
|
459
|
+
applyRemovals(result, source, layer.name, provenance);
|
|
460
|
+
mergeObject(result, source as Record<string, any>, [], layer.name, provenance, warnings);
|
|
461
|
+
}
|
|
462
|
+
return { value: result as DeepPartial<Config>, provenance, warnings };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function mergeWithDefaults(raw: DeepPartial<Config>): Config {
|
|
466
|
+
const merged = mergeConfigLayers([
|
|
467
|
+
{ name: "defaults", value: clone(DEFAULT_CONFIG), warnings: [] },
|
|
468
|
+
{ name: "cli", value: raw, warnings: [] },
|
|
469
|
+
]).value;
|
|
470
|
+
return merged as Config;
|
|
471
|
+
}
|
|
472
|
+
function issueForPath(path: string, text: string): string { return `${path}: ${text}`; }
|
|
473
|
+
function isInside(child: string, parent: string): boolean {
|
|
474
|
+
const rel = relative(resolve(parent), resolve(child));
|
|
475
|
+
return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
|
|
476
|
+
}
|
|
477
|
+
function validatePort(port: unknown): boolean {
|
|
478
|
+
if (typeof port !== "string" || !port.trim()) return false;
|
|
479
|
+
const parts = port.split(":");
|
|
480
|
+
if (parts.length > 3) return false;
|
|
481
|
+
const nums = parts.slice(-2).map(Number);
|
|
482
|
+
return parts.every((p, i) => i < parts.length - 2 || /^\d+$/.test(p)) && nums.every((n) => Number.isInteger(n) && n >= 1 && n <= 65535);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
export function validateConfig(raw: DeepPartial<Config>): Config {
|
|
486
|
+
const config = mergeWithDefaults(raw);
|
|
487
|
+
const issues: string[] = [];
|
|
488
|
+
const n = (value: unknown) => typeof value === "number" && Number.isFinite(value);
|
|
489
|
+
if (!config.image || typeof config.image !== "string") issues.push(issueForPath("image", "must be a non-empty string"));
|
|
490
|
+
if (!["always", "if-missing", "never"].includes(config.pullPolicy)) issues.push(issueForPath("pullPolicy", "must be always, if-missing, or never"));
|
|
491
|
+
if (!n(config.cpus) || config.cpus < 1 || config.cpus > 64) issues.push(issueForPath("cpus", "must be between 1 and 64"));
|
|
492
|
+
if (!n(config.memoryMiB) || config.memoryMiB < 128) issues.push(issueForPath("memoryMiB", "must be at least 128 MiB"));
|
|
493
|
+
if (!n(config.idleTimeoutSec) || config.idleTimeoutSec < 0) issues.push(issueForPath("idleTimeoutSec", "must be non-negative"));
|
|
494
|
+
if (!n(config.stopTimeoutMs) || config.stopTimeoutMs < 1) issues.push(issueForPath("stopTimeoutMs", "must be positive"));
|
|
495
|
+
if (!n(config.replaceTimeoutMs) || config.replaceTimeoutMs < 1) issues.push(issueForPath("replaceTimeoutMs", "must be positive"));
|
|
496
|
+
if (!n(config.volumeQuotaMiB) || config.volumeQuotaMiB < 1) issues.push(issueForPath("volumeQuotaMiB", "must be positive"));
|
|
497
|
+
if (!["auto", "git", "direct", "none"].includes(config.mode)) issues.push(issueForPath("mode", "unknown storage mode"));
|
|
498
|
+
if (!["auto", true, false].includes(config.bootstrapTools)) issues.push(issueForPath("bootstrapTools", "must be auto, true, or false"));
|
|
499
|
+
if (!["block", "host"].includes(config.fallbackMode)) issues.push(issueForPath("fallbackMode", "must be block or host"));
|
|
500
|
+
const booleanFields = ["detached", "replace", "shallowArchive", "blockThirdParty", "allowHostExecution", "allowSkillReads", "exposeSessionEnvironment", "autoStart", "pruneOnStart", "showFooter"] as const;
|
|
501
|
+
for (const field of booleanFields) if (typeof config[field] !== "boolean") issues.push(issueForPath(field, "must be boolean"));
|
|
502
|
+
if (config.sandboxName !== null && typeof config.sandboxName !== "string") issues.push(issueForPath("sandboxName", "must be a string or null"));
|
|
503
|
+
if (typeof config.cloneBranch !== "string" || !config.cloneBranch) issues.push(issueForPath("cloneBranch", "must be a non-empty string"));
|
|
504
|
+
if (config.cloneDepth !== "unlimited" && (!n(config.cloneDepth) || !Number.isInteger(config.cloneDepth) || config.cloneDepth < 1)) issues.push(issueForPath("cloneDepth", "must be a positive integer or unlimited"));
|
|
505
|
+
const stringArray = (field: string, value: unknown) => {
|
|
506
|
+
if (!Array.isArray(value)) { issues.push(issueForPath(field, "must be an array")); return false; }
|
|
507
|
+
for (const item of value) if (typeof item !== "string" || !item) issues.push(issueForPath(field, "must contain non-empty strings"));
|
|
508
|
+
return true;
|
|
509
|
+
};
|
|
510
|
+
if (stringArray("routeTools", config.routeTools)) for (const tool of config.routeTools) if (!ROUTED_TOOLS.includes(tool)) issues.push(issueForPath("routeTools", `unknown routed tool ${tool}`));
|
|
511
|
+
stringArray("passThroughTools", config.passThroughTools);
|
|
512
|
+
stringArray("hostEnv", config.hostEnv);
|
|
513
|
+
stringArray("hostRoAllowlist", config.hostRoAllowlist);
|
|
514
|
+
const network = isPlainObject(config.network) ? config.network : null;
|
|
515
|
+
if (!network || !["default", "open", "allowlist", "deny"].includes(network.mode as string)) issues.push(issueForPath("network.mode", "unknown network mode"));
|
|
516
|
+
if (network && stringArray("network.allowHosts", network.allowHosts)) { /* checked above */ }
|
|
517
|
+
if (network && stringArray("network.publishPorts", network.publishPorts)) { /* checked above */ }
|
|
518
|
+
if (network && typeof network.allowDns !== "boolean") issues.push(issueForPath("network.allowDns", "must be boolean"));
|
|
519
|
+
if (network?.mode === "allowlist" && Array.isArray(network.allowHosts) && !network.allowHosts.length && !network.allowDns) issues.push(issueForPath("network", "allowlist needs a host or DNS permission"));
|
|
520
|
+
for (const port of (network && Array.isArray(network.publishPorts) ? network.publishPorts : [])) if (!validatePort(port)) issues.push(issueForPath("network.publishPorts", "invalid port mapping"));
|
|
521
|
+
if (!Array.isArray(config.secrets)) issues.push(issueForPath("secrets", "must be an array"));
|
|
522
|
+
for (const [index, secret] of (Array.isArray(config.secrets) ? config.secrets : []).entries()) {
|
|
523
|
+
if (!secret || typeof secret.env !== "string" || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(secret.env)) issues.push(issueForPath(`secrets[${index}]`, "env must be a valid host environment name"));
|
|
524
|
+
if (typeof secret?.value !== "string") issues.push(issueForPath(`secrets[${index}].value`, "must be a string"));
|
|
525
|
+
if (!Array.isArray(secret?.allowHosts) || !secret.allowHosts.length || secret.allowHosts.some((host: unknown) => typeof host !== "string" || !host)) issues.push(issueForPath(`secrets[${index}]`, "allowHosts must be a non-empty string array"));
|
|
526
|
+
}
|
|
527
|
+
const seenGuests: { path: string; index: number }[] = [];
|
|
528
|
+
if (!Array.isArray(config.mounts)) issues.push(issueForPath("mounts", "must be an array"));
|
|
529
|
+
for (const [index, mount] of (Array.isArray(config.mounts) ? config.mounts : []).entries()) {
|
|
530
|
+
const guest = mount?.guestPath;
|
|
531
|
+
if (!mount || !["dir", "file", "named", "tmpfs"].includes(mount.type)) issues.push(issueForPath(`mounts[${index}]`, "unknown mount type"));
|
|
532
|
+
if (typeof guest !== "string" || !isAbsolute(guest)) issues.push(issueForPath(`mounts[${index}].guestPath`, "must be absolute"));
|
|
533
|
+
else {
|
|
534
|
+
const canonical = normalize(guest).replace(/[\\/]$/, "") || sep;
|
|
535
|
+
for (const prior of seenGuests) if (isInside(canonical, prior.path) || isInside(prior.path, canonical)) issues.push(issueForPath(`mounts[${index}].guestPath`, `overlaps mount ${prior.index}`));
|
|
536
|
+
seenGuests.push({ path: canonical, index });
|
|
537
|
+
}
|
|
538
|
+
if (typeof mount?.readonly !== "boolean") issues.push(issueForPath(`mounts[${index}].readonly`, "must be boolean"));
|
|
539
|
+
if (!Array.isArray(mount?.options) || mount.options.some((option: unknown) => typeof option !== "string")) issues.push(issueForPath(`mounts[${index}].options`, "must be a string array"));
|
|
540
|
+
if (mount?.type === "named" && (typeof mount.hostPath !== "string" || !mount.hostPath)) issues.push(issueForPath(`mounts[${index}].hostPath`, "named mounts require a volume name"));
|
|
541
|
+
if (mount?.type !== "named" && mount?.type !== "tmpfs" && (typeof mount?.hostPath !== "string" || !isAbsolute(mount.hostPath))) issues.push(issueForPath(`mounts[${index}].hostPath`, "hostPath must be absolute"));
|
|
542
|
+
if (typeof guest === "string" && isAbsolute(guest)) {
|
|
543
|
+
const canonical = String(canonicalGuestPath(guest));
|
|
544
|
+
if (isInside(canonical, "/tmp") || isInside("/tmp", canonical)) issues.push(issueForPath(`mounts[${index}].guestPath`, "must not shadow reserved /tmp paths"));
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
if (issues.length) throw new ConfigError(issues);
|
|
548
|
+
return config;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function normalizeLegacy(config: Config, warnings: string[], canonical?: (p: string) => string): Config {
|
|
552
|
+
if (!config.hostRoAllowlist.length) return config;
|
|
553
|
+
warnings.push("hostRoAllowlist is deprecated; use read-only mounts instead");
|
|
554
|
+
const mounts = [...config.mounts];
|
|
555
|
+
for (const host of config.hostRoAllowlist) {
|
|
556
|
+
const path = canonical ? canonical(host) : resolve(host);
|
|
557
|
+
if (!mounts.some((mount) => mount.guestPath === path)) mounts.push({ type: "dir", hostPath: path, guestPath: path, readonly: true, options: [] });
|
|
558
|
+
}
|
|
559
|
+
return { ...config, mounts };
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
async function defaultRead(path: string): Promise<string | null> { try { return await fs.readFile(path, "utf8"); } catch { return null; } }
|
|
563
|
+
async function defaultExists(path: string): Promise<boolean> { try { await fs.access(path); return true; } catch { return false; } }
|
|
564
|
+
async function defaultRealpath(path: string): Promise<string> { return fs.realpath(path); }
|
|
565
|
+
async function projectFile(cwd: string, repoRoot: string | null | undefined, configDirName: string, exists: (p: string) => Promise<boolean>): Promise<string | null> {
|
|
566
|
+
let current = resolve(cwd);
|
|
567
|
+
const stop = repoRoot ? resolve(repoRoot) : null;
|
|
568
|
+
while (true) {
|
|
569
|
+
const candidates = [join(current, ".pi-msb.toml"), join(current, configDirName, "msb.toml")];
|
|
570
|
+
for (const candidate of candidates) if (await exists(candidate)) return candidate;
|
|
571
|
+
if (stop && current === stop) break;
|
|
572
|
+
const parent = dirname(current);
|
|
573
|
+
if (parent === current) break;
|
|
574
|
+
current = parent;
|
|
575
|
+
}
|
|
576
|
+
return null;
|
|
577
|
+
}
|
|
578
|
+
function configDir(env: NodeJS.ProcessEnv, input: ResolveConfigInput): string {
|
|
579
|
+
return input.xdgConfigHome ?? env.XDG_CONFIG_HOME ?? join(input.homedir ?? env.HOME ?? osHomedir(), ".config");
|
|
580
|
+
}
|
|
581
|
+
function layerFromText(name: ConfigLayerName, text: string, source: string): ParsedConfigLayer {
|
|
582
|
+
try { return normalizeLayer(name, readToml(text), source); }
|
|
583
|
+
catch { throw new ConfigError([`invalid TOML in ${source}`]); }
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
export async function resolveConfig(input: ResolveConfigInput): Promise<ResolvedConfig> {
|
|
587
|
+
const env = input.env ?? process.env;
|
|
588
|
+
const readFile = input.readFile ?? defaultRead;
|
|
589
|
+
const exists = input.exists ?? defaultExists;
|
|
590
|
+
const realpath = input.realpath ?? defaultRealpath;
|
|
591
|
+
const warnings: string[] = [];
|
|
592
|
+
const layers: ConfigLayerInput[] = [{ name: "defaults", value: clone(DEFAULT_CONFIG), warnings: [] }];
|
|
593
|
+
const globalPaths = [join(configDir(env, input), "pi-msb", "config.toml")];
|
|
594
|
+
if (env.PI_MSB_CONFIG_FILE) globalPaths.push(isAbsolute(env.PI_MSB_CONFIG_FILE) ? env.PI_MSB_CONFIG_FILE : resolve(input.cwd, env.PI_MSB_CONFIG_FILE));
|
|
595
|
+
for (const file of [...new Set(globalPaths)]) {
|
|
596
|
+
const text = await readFile(file);
|
|
597
|
+
if (text !== null) layers.push(layerFromText("global", text, file));
|
|
598
|
+
}
|
|
599
|
+
const canonicalPath = async (path: string, failClosed = false): Promise<string> => {
|
|
600
|
+
try { return await realpath(path); }
|
|
601
|
+
catch {
|
|
602
|
+
if (failClosed) throw new ConfigError(["trusted project path could not be canonicalized"]);
|
|
603
|
+
return resolve(path);
|
|
604
|
+
}
|
|
605
|
+
};
|
|
606
|
+
const project = await projectFile(input.cwd, input.repoRoot, input.configDirName, exists);
|
|
607
|
+
if (project && !input.projectTrusted) warnings.push("project config ignored because this project is not trusted");
|
|
608
|
+
if (project && input.projectTrusted) {
|
|
609
|
+
const text = await readFile(project);
|
|
610
|
+
if (text !== null) {
|
|
611
|
+
const parsed = layerFromText("project", text, project);
|
|
612
|
+
const projectValue = withMountDefaults(clone(parsed.value) as LayerValue);
|
|
613
|
+
if (Array.isArray(projectValue.mounts)) {
|
|
614
|
+
projectValue.mounts = await Promise.all(projectValue.mounts.map(async (mount: any) => {
|
|
615
|
+
if (mount && (mount.type === "dir" || mount.type === "file") && typeof mount.hostPath === "string" && isAbsolute(mount.hostPath)) {
|
|
616
|
+
return { ...mount, hostPath: await canonicalPath(mount.hostPath, true) };
|
|
617
|
+
}
|
|
618
|
+
return mount;
|
|
619
|
+
})) as any;
|
|
620
|
+
}
|
|
621
|
+
layers.push({ ...parsed, value: projectValue });
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
layers.push(parseEnvConfig(env));
|
|
625
|
+
if (input.cliOverridesToml !== undefined) layers.push(parseTomlConfig(input.cliOverridesToml, "cli"));
|
|
626
|
+
const merged = mergeConfigLayers(layers);
|
|
627
|
+
warnings.push(...merged.warnings);
|
|
628
|
+
let config = validateConfig(merged.value);
|
|
629
|
+
config = normalizeLegacy(config, warnings);
|
|
630
|
+
// Legacy mounts participate in the same overlap/type checks as native mounts.
|
|
631
|
+
config = validateConfig(config);
|
|
632
|
+
|
|
633
|
+
const projectGuestPath = String(canonicalGuestPath(resolve(input.repoRoot ?? input.cwd)));
|
|
634
|
+
const projectShadowIssues = config.mounts.flatMap((mount, index) => {
|
|
635
|
+
if (typeof mount.guestPath !== "string") return [];
|
|
636
|
+
const guest = String(canonicalGuestPath(mount.guestPath));
|
|
637
|
+
return isInside(guest, projectGuestPath) || isInside(projectGuestPath, guest)
|
|
638
|
+
? [issueForPath(`mounts[${index}].guestPath`, "must not shadow the project mount")]
|
|
639
|
+
: [];
|
|
640
|
+
});
|
|
641
|
+
if (projectShadowIssues.length) throw new ConfigError(projectShadowIssues);
|
|
642
|
+
|
|
643
|
+
if (config.network.mode === "open") warnings.push("network.mode=\"open\" permits private and host access; it is broader than microsandbox default mode");
|
|
644
|
+
const repo = input.repoRoot ? await canonicalPath(input.repoRoot) : null;
|
|
645
|
+
const projectLayer = layers.find((layer) => layer.name === "project");
|
|
646
|
+
const projectMounts = (projectLayer?.value as LayerValue | undefined)?.mounts;
|
|
647
|
+
const authorizedWrites = layers.filter((layer) => layer.name === "global" || layer.name === "cli")
|
|
648
|
+
.flatMap((layer) => ((layer.value as LayerValue).mounts ?? []) as any[])
|
|
649
|
+
.filter((mount) => mount && mount.readonly === false)
|
|
650
|
+
.map((mount) => String(canonicalGuestPath(mount.guestPath ?? "")));
|
|
651
|
+
const policyIssues: string[] = [];
|
|
652
|
+
const projectSecretFiles = new Map<string, { reference: string; canonical: string }>();
|
|
653
|
+
if (Array.isArray(projectMounts)) for (const mount of projectMounts as any[]) {
|
|
654
|
+
if (!mount || typeof mount.hostPath !== "string" || mount.type === "named" || mount.type === "tmpfs") continue;
|
|
655
|
+
const outsideRepo = !repo || !isInside(mount.hostPath, repo);
|
|
656
|
+
if (outsideRepo && mount.readonly !== true && !authorizedWrites.includes(String(canonicalGuestPath(mount.guestPath ?? "")))) {
|
|
657
|
+
policyIssues.push("project host mounts outside the repository must be read-only unless a global or session policy authorizes write access");
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
if (Array.isArray((projectLayer?.value as LayerValue | undefined)?.secrets)) {
|
|
661
|
+
const approvedRoots = await Promise.all([input.cwd, repo ?? input.cwd, input.homedir ?? env.HOME, configDir(env, input)]
|
|
662
|
+
.filter(Boolean).map((item) => canonicalPath(String(item))));
|
|
663
|
+
for (const secret of (projectLayer!.value as any).secrets) if (typeof secret?.value === "string" && secret.value.startsWith("$FILE:")) {
|
|
664
|
+
const file = secret.value.slice(6);
|
|
665
|
+
const absolute = isAbsolute(file) ? file : resolve(dirname(project!), file);
|
|
666
|
+
const canonicalFile = await canonicalPath(absolute, true);
|
|
667
|
+
if (!approvedRoots.some((root) => isInside(canonicalFile, root))) policyIssues.push("project secret file reference is outside approved roots");
|
|
668
|
+
if (typeof secret.env === "string") projectSecretFiles.set(secret.env, { reference: secret.value, canonical: canonicalFile });
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
if (policyIssues.length) throw new ConfigError(policyIssues);
|
|
672
|
+
if (projectSecretFiles.size) {
|
|
673
|
+
config = {
|
|
674
|
+
...config,
|
|
675
|
+
secrets: config.secrets.map((secret) => {
|
|
676
|
+
const approved = projectSecretFiles.get(secret.env);
|
|
677
|
+
return merged.provenance[`secrets[${secret.env}]`] === "project" && approved?.reference === secret.value
|
|
678
|
+
? { ...secret, value: `$FILE:${approved.canonical}` }
|
|
679
|
+
: secret;
|
|
680
|
+
}),
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
if (config.hostRoAllowlist.length) {
|
|
684
|
+
const legacyPaths = new Map(config.hostRoAllowlist.map((path) => [resolve(path), path]));
|
|
685
|
+
const canonicalMounts = await Promise.all(config.mounts.map(async (mount) => {
|
|
686
|
+
const original = mount.hostPath && legacyPaths.has(resolve(mount.hostPath)) ? resolve(mount.hostPath) : undefined;
|
|
687
|
+
if (original !== undefined && mount.guestPath === original && mount.readonly) {
|
|
688
|
+
const rp = await canonicalPath(original);
|
|
689
|
+
return { ...mount, hostPath: rp, guestPath: rp };
|
|
690
|
+
}
|
|
691
|
+
return mount;
|
|
692
|
+
}));
|
|
693
|
+
config = validateConfig({ ...config, mounts: canonicalMounts });
|
|
694
|
+
}
|
|
695
|
+
return { config, provenance: merged.provenance, warnings: warnings.map(warning) };
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function setAt(base: any, parts: string[], value: unknown): any {
|
|
699
|
+
assertSafeConfigValue(base);
|
|
700
|
+
assertSafeConfigPath(parts);
|
|
701
|
+
assertSafeConfigValue(value, parts);
|
|
702
|
+
const out: any = clone(base) ?? {};
|
|
703
|
+
let current = out;
|
|
704
|
+
for (const part of parts.slice(0, -1)) { if (!isPlainObject(current[part])) current[part] = {}; current = current[part]; }
|
|
705
|
+
current[parts[parts.length - 1]!] = clone(value);
|
|
706
|
+
return out;
|
|
707
|
+
}
|
|
708
|
+
export function applyOverride(base: DeepPartial<Config>, dottedSnakeKey: string, value: unknown): DeepPartial<Config> {
|
|
709
|
+
const rawParts = dottedSnakeKey.split(".").filter(Boolean);
|
|
710
|
+
assertSafeConfigPath(rawParts);
|
|
711
|
+
return setAt(base, rawParts.map(camel), value) as DeepPartial<Config>;
|
|
712
|
+
}
|
|
713
|
+
export function removeOverride(base: DeepPartial<Config>, dottedSnakeKey: string): DeepPartial<Config> {
|
|
714
|
+
assertSafeConfigValue(base);
|
|
715
|
+
const out: any = clone(base) ?? {};
|
|
716
|
+
const rawParts = dottedSnakeKey.split(".").filter(Boolean);
|
|
717
|
+
assertSafeConfigPath(rawParts);
|
|
718
|
+
const parts = rawParts.map(camel);
|
|
719
|
+
assertSafeConfigPath(parts);
|
|
720
|
+
let current = out;
|
|
721
|
+
for (const part of parts.slice(0, -1)) { if (!isPlainObject(current[part])) return out; current = current[part]; }
|
|
722
|
+
delete current[parts[parts.length - 1]];
|
|
723
|
+
return out;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function tomlScalar(value: unknown): string {
|
|
727
|
+
if (typeof value === "string") return JSON.stringify(value);
|
|
728
|
+
if (typeof value === "boolean" || typeof value === "number") return String(value);
|
|
729
|
+
if (value === null) return '""';
|
|
730
|
+
if (Array.isArray(value)) return `[${value.map(tomlScalar).join(", ")}]`;
|
|
731
|
+
if (isPlainObject(value)) return `{ ${Object.entries(value).map(([k, v]) => `${snake(k)} = ${tomlScalar(v)}`).join(", ")} }`;
|
|
732
|
+
return JSON.stringify(String(value));
|
|
733
|
+
}
|
|
734
|
+
function serializeToml(value: Record<string, unknown>, prefix: string[] = []): string[] {
|
|
735
|
+
const lines: string[] = [];
|
|
736
|
+
const scalarEntries = Object.entries(value).filter(([, v]) => !isPlainObject(v) && !(Array.isArray(v) && v.some(isPlainObject)));
|
|
737
|
+
for (const [key, child] of scalarEntries) lines.push(`${snake(key)} = ${tomlScalar(child)}`);
|
|
738
|
+
for (const [key, child] of Object.entries(value)) {
|
|
739
|
+
if (isPlainObject(child)) { lines.push("", `[${[...prefix, snake(key)].join(".")}]`, ...serializeToml(child, [...prefix, key])); }
|
|
740
|
+
else if (Array.isArray(child) && child.some(isPlainObject)) for (const item of child) {
|
|
741
|
+
if (!isPlainObject(item)) continue;
|
|
742
|
+
lines.push("", `[[${[...prefix, snake(key)].join(".")}]]`, ...serializeToml(item, [...prefix, key]));
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
return lines;
|
|
746
|
+
}
|
|
747
|
+
export function overridesToToml(value: DeepPartial<Config>): string {
|
|
748
|
+
assertSafeConfigValue(value);
|
|
749
|
+
return serializeToml(clone(value) as Record<string, unknown>).join("\n").replace(/^\n+/, "") + "\n";
|
|
750
|
+
}
|
|
751
|
+
export function toEffectiveToml(value: ResolvedConfig): string {
|
|
752
|
+
const config = clone(value.config) as any;
|
|
753
|
+
if (Array.isArray(config.secrets)) config.secrets = config.secrets.map((secret: any) => ({ ...secret, value: withoutSecret(secret.value) }));
|
|
754
|
+
return overridesToToml(config);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
export async function resolveSecretValue(value: string, env: NodeJS.ProcessEnv, readFile: (p: string) => Promise<string> = async (p) => fs.readFile(p, "utf8")): Promise<string> {
|
|
758
|
+
if (value.startsWith("$ENV:")) {
|
|
759
|
+
const name = value.slice(5);
|
|
760
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || env[name] === undefined) throw new ConfigError(["secret environment reference is missing or invalid"]);
|
|
761
|
+
return env[name]!;
|
|
762
|
+
}
|
|
763
|
+
if (value.startsWith("$FILE:")) {
|
|
764
|
+
try { return await readFile(value.slice(6)); } catch { throw new ConfigError(["secret file reference could not be read"]); }
|
|
765
|
+
}
|
|
766
|
+
return value;
|
|
767
|
+
}
|
|
768
|
+
export function isDisabledByEnv(env: NodeJS.ProcessEnv): boolean {
|
|
769
|
+
const value = env.PI_MSB_DISABLE;
|
|
770
|
+
return value !== undefined && !["", "0", "false", "no", "off"].includes(value.trim().toLowerCase());
|
|
771
|
+
}
|