pi-microsandbox 0.1.0 → 0.2.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/README.md +21 -16
- package/docs/commands.md +3 -2
- package/docs/configuration.md +35 -4
- package/docs/development.md +96 -8
- package/docs/getting-started.md +25 -21
- package/docs/images.md +16 -8
- package/docs/safety.md +25 -5
- package/docs/storage.md +14 -0
- package/docs/troubleshooting.md +24 -0
- package/extensions/pi-msb/command.ts +5 -0
- package/extensions/pi-msb/config.ts +65 -6
- package/extensions/pi-msb/control.ts +95 -14
- package/extensions/pi-msb/flock.ts +94 -0
- package/extensions/pi-msb/locks.ts +5 -84
- package/extensions/pi-msb/sandbox-manager.ts +56 -7
- package/extensions/pi-msb/types.ts +18 -0
- package/native/flock/prebuilds/darwin-arm64/flock.node +0 -0
- package/native/flock/prebuilds/linux-arm64-gnu/flock.node +0 -0
- package/native/flock/prebuilds/linux-x64-gnu/flock.node +0 -0
- package/package.json +10 -7
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { promises as fs } from "node:fs";
|
|
2
2
|
import { homedir as osHomedir } from "node:os";
|
|
3
|
-
import { dirname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { basename, dirname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
|
|
4
4
|
import type {
|
|
5
5
|
Config,
|
|
6
6
|
ConfigLayerInput,
|
|
@@ -19,6 +19,37 @@ const CONTROL_KEYS = new Set(["removeSecrets", "removeMounts", "removeRouteTools
|
|
|
19
19
|
const SECRET_FIELDS = new Set(["env", "value", "allowHosts"]);
|
|
20
20
|
const MOUNT_FIELDS = new Set(["type", "hostPath", "guestPath", "readonly", "options"]);
|
|
21
21
|
const FORBIDDEN_CONFIG_KEYS = new Set(["__proto__", "prototype", "constructor"]);
|
|
22
|
+
const PROTECTED_GUEST_ENV = new Set([
|
|
23
|
+
"BASH_ENV",
|
|
24
|
+
"CDPATH",
|
|
25
|
+
"DOCKER_CERT_PATH",
|
|
26
|
+
"DOCKER_CONFIG",
|
|
27
|
+
"DOCKER_CONTEXT",
|
|
28
|
+
"DOCKER_HOST",
|
|
29
|
+
"DOCKER_TLS",
|
|
30
|
+
"DOCKER_TLS_VERIFY",
|
|
31
|
+
"ENV",
|
|
32
|
+
"GLOBIGNORE",
|
|
33
|
+
"LD_LIBRARY_PATH",
|
|
34
|
+
"LD_PRELOAD",
|
|
35
|
+
"PATH",
|
|
36
|
+
"SHELLOPTS",
|
|
37
|
+
]);
|
|
38
|
+
const PROTECTED_GUEST_PATHS = [
|
|
39
|
+
"/bin",
|
|
40
|
+
"/dev",
|
|
41
|
+
"/etc/ld.so.cache",
|
|
42
|
+
"/etc/ld.so.preload",
|
|
43
|
+
"/lib",
|
|
44
|
+
"/lib64",
|
|
45
|
+
"/proc",
|
|
46
|
+
"/sbin",
|
|
47
|
+
"/sys",
|
|
48
|
+
"/usr",
|
|
49
|
+
"/run",
|
|
50
|
+
"/var/run",
|
|
51
|
+
"/var/lib/docker",
|
|
52
|
+
] as const;
|
|
22
53
|
|
|
23
54
|
function deepFreeze<T>(value: T): DeepReadonly<T> {
|
|
24
55
|
if (value !== null && typeof value === "object" && !Object.isFrozen(value)) {
|
|
@@ -30,11 +61,11 @@ function deepFreeze<T>(value: T): DeepReadonly<T> {
|
|
|
30
61
|
|
|
31
62
|
/** Defaults from PLAN §11.2. Values containing credentials are deliberately absent. */
|
|
32
63
|
export const DEFAULT_CONFIG = deepFreeze<Config>({
|
|
33
|
-
image: "ghcr.io/hcohe/pi-microsandbox:1.
|
|
64
|
+
image: "ghcr.io/hcohe/pi-microsandbox:1.1.0@sha256:ab4e99d4232f827b3f295ff3210437e01446dbb672ef0d0c78358566170ac86c",
|
|
34
65
|
pullPolicy: "if-missing",
|
|
35
66
|
bootstrapTools: "auto",
|
|
36
|
-
cpus:
|
|
37
|
-
memoryMiB:
|
|
67
|
+
cpus: 4,
|
|
68
|
+
memoryMiB: 8_192,
|
|
38
69
|
idleTimeoutSec: 600,
|
|
39
70
|
stopTimeoutMs: 10_000,
|
|
40
71
|
detached: true,
|
|
@@ -47,6 +78,7 @@ export const DEFAULT_CONFIG = deepFreeze<Config>({
|
|
|
47
78
|
shallowArchive: false,
|
|
48
79
|
volumeQuotaMiB: 2_048,
|
|
49
80
|
network: { mode: "default", allowHosts: [], allowDns: true, publishPorts: [] },
|
|
81
|
+
docker: { mode: "auto", startupTimeoutMs: 15_000 },
|
|
50
82
|
secrets: [],
|
|
51
83
|
mounts: [],
|
|
52
84
|
blockThirdParty: true,
|
|
@@ -76,6 +108,7 @@ export interface ResolveConfigInput {
|
|
|
76
108
|
readFile?: (path: string) => Promise<string | null>;
|
|
77
109
|
exists?: (path: string) => Promise<boolean>;
|
|
78
110
|
realpath?: (path: string) => Promise<string>;
|
|
111
|
+
stat?: (path: string) => Promise<{ isSocket(): boolean }>;
|
|
79
112
|
}
|
|
80
113
|
|
|
81
114
|
export class ConfigError extends Error {
|
|
@@ -299,6 +332,7 @@ function knownPath(path: string): boolean {
|
|
|
299
332
|
return parts.length === 1 && CONTROL_KEYS.has(parts[0]) && !nestedNetworkRemoval;
|
|
300
333
|
}
|
|
301
334
|
if (parts[0] === "network") return parts.length === 1 || (parts.length === 2 && ["mode", "allowHosts", "allowDns", "publishPorts", "removeAllowHosts", "removePublishPorts"].includes(parts[1]));
|
|
335
|
+
if (parts[0] === "docker") return parts.length === 1 || (parts.length === 2 && ["mode", "startupTimeoutMs"].includes(parts[1]));
|
|
302
336
|
if (parts[0] === "secrets") return parts.length === 1 || (parts.length === 2 && SECRET_FIELDS.has(parts[1]));
|
|
303
337
|
if (parts[0] === "mounts") return parts.length === 1 || (parts.length === 2 && MOUNT_FIELDS.has(parts[1]));
|
|
304
338
|
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]);
|
|
@@ -495,6 +529,9 @@ export function validateConfig(raw: DeepPartial<Config>): Config {
|
|
|
495
529
|
if (!n(config.replaceTimeoutMs) || config.replaceTimeoutMs < 1) issues.push(issueForPath("replaceTimeoutMs", "must be positive"));
|
|
496
530
|
if (!n(config.volumeQuotaMiB) || config.volumeQuotaMiB < 1) issues.push(issueForPath("volumeQuotaMiB", "must be positive"));
|
|
497
531
|
if (!["auto", "git", "direct", "none"].includes(config.mode)) issues.push(issueForPath("mode", "unknown storage mode"));
|
|
532
|
+
const docker = isPlainObject(config.docker) ? config.docker : null;
|
|
533
|
+
if (!docker || !["auto", "require", "disabled"].includes(docker.mode as string)) issues.push(issueForPath("docker.mode", "must be auto, require, or disabled"));
|
|
534
|
+
if (!docker || !n(docker.startupTimeoutMs) || !Number.isInteger(docker.startupTimeoutMs) || (docker.startupTimeoutMs as number) < 1 || (docker.startupTimeoutMs as number) > 300_000) issues.push(issueForPath("docker.startupTimeoutMs", "must be an integer between 1 and 300000"));
|
|
498
535
|
if (!["auto", true, false].includes(config.bootstrapTools)) issues.push(issueForPath("bootstrapTools", "must be auto, true, or false"));
|
|
499
536
|
if (!["block", "host"].includes(config.fallbackMode)) issues.push(issueForPath("fallbackMode", "must be block or host"));
|
|
500
537
|
const booleanFields = ["detached", "replace", "shallowArchive", "blockThirdParty", "allowHostExecution", "allowSkillReads", "exposeSessionEnvironment", "autoStart", "pruneOnStart", "showFooter"] as const;
|
|
@@ -509,7 +546,7 @@ export function validateConfig(raw: DeepPartial<Config>): Config {
|
|
|
509
546
|
};
|
|
510
547
|
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
548
|
stringArray("passThroughTools", config.passThroughTools);
|
|
512
|
-
stringArray("hostEnv", config.hostEnv);
|
|
549
|
+
if (stringArray("hostEnv", config.hostEnv)) for (const name of config.hostEnv) if (PROTECTED_GUEST_ENV.has(name)) issues.push(issueForPath("hostEnv", `must not forward protected process environment variable ${name}`));
|
|
513
550
|
stringArray("hostRoAllowlist", config.hostRoAllowlist);
|
|
514
551
|
const network = isPlainObject(config.network) ? config.network : null;
|
|
515
552
|
if (!network || !["default", "open", "allowlist", "deny"].includes(network.mode as string)) issues.push(issueForPath("network.mode", "unknown network mode"));
|
|
@@ -521,6 +558,7 @@ export function validateConfig(raw: DeepPartial<Config>): Config {
|
|
|
521
558
|
if (!Array.isArray(config.secrets)) issues.push(issueForPath("secrets", "must be an array"));
|
|
522
559
|
for (const [index, secret] of (Array.isArray(config.secrets) ? config.secrets : []).entries()) {
|
|
523
560
|
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"));
|
|
561
|
+
else if (PROTECTED_GUEST_ENV.has(secret.env)) issues.push(issueForPath(`secrets[${index}].env`, `must not set protected process environment variable ${secret.env}`));
|
|
524
562
|
if (typeof secret?.value !== "string") issues.push(issueForPath(`secrets[${index}].value`, "must be a string"));
|
|
525
563
|
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
564
|
}
|
|
@@ -542,6 +580,7 @@ export function validateConfig(raw: DeepPartial<Config>): Config {
|
|
|
542
580
|
if (typeof guest === "string" && isAbsolute(guest)) {
|
|
543
581
|
const canonical = String(canonicalGuestPath(guest));
|
|
544
582
|
if (isInside(canonical, "/tmp") || isInside("/tmp", canonical)) issues.push(issueForPath(`mounts[${index}].guestPath`, "must not shadow reserved /tmp paths"));
|
|
583
|
+
if (PROTECTED_GUEST_PATHS.some((reserved) => isInside(canonical, reserved) || isInside(reserved, canonical))) issues.push(issueForPath(`mounts[${index}].guestPath`, "must not shadow protected guest system or Docker runtime paths"));
|
|
545
584
|
}
|
|
546
585
|
}
|
|
547
586
|
if (issues.length) throw new ConfigError(issues);
|
|
@@ -599,7 +638,7 @@ export async function resolveConfig(input: ResolveConfigInput): Promise<Resolved
|
|
|
599
638
|
const canonicalPath = async (path: string, failClosed = false): Promise<string> => {
|
|
600
639
|
try { return await realpath(path); }
|
|
601
640
|
catch {
|
|
602
|
-
if (failClosed) throw new ConfigError(["trusted project path could not be canonicalized"]);
|
|
641
|
+
if (failClosed) throw new ConfigError(["mount or trusted project path could not be canonicalized"]);
|
|
603
642
|
return resolve(path);
|
|
604
643
|
}
|
|
605
644
|
};
|
|
@@ -629,6 +668,12 @@ export async function resolveConfig(input: ResolveConfigInput): Promise<Resolved
|
|
|
629
668
|
config = normalizeLegacy(config, warnings);
|
|
630
669
|
// Legacy mounts participate in the same overlap/type checks as native mounts.
|
|
631
670
|
config = validateConfig(config);
|
|
671
|
+
config = {
|
|
672
|
+
...config,
|
|
673
|
+
mounts: await Promise.all(config.mounts.map(async (mount) => mount.type === "named" || mount.type === "tmpfs"
|
|
674
|
+
? mount
|
|
675
|
+
: { ...mount, hostPath: await canonicalPath(mount.hostPath!, true) })),
|
|
676
|
+
};
|
|
632
677
|
|
|
633
678
|
const projectGuestPath = String(canonicalGuestPath(resolve(input.repoRoot ?? input.cwd)));
|
|
634
679
|
const projectShadowIssues = config.mounts.flatMap((mount, index) => {
|
|
@@ -649,6 +694,20 @@ export async function resolveConfig(input: ResolveConfigInput): Promise<Resolved
|
|
|
649
694
|
.filter((mount) => mount && mount.readonly === false)
|
|
650
695
|
.map((mount) => String(canonicalGuestPath(mount.guestPath ?? "")));
|
|
651
696
|
const policyIssues: string[] = [];
|
|
697
|
+
const mountStat = input.stat ?? fs.stat;
|
|
698
|
+
const knownHostDockerSockets = ["/var/run/docker.sock", "/run/docker.sock"];
|
|
699
|
+
if (input.homedir ?? env.HOME) knownHostDockerSockets.push(join(String(input.homedir ?? env.HOME), ".docker", "run", "docker.sock"));
|
|
700
|
+
if (env.XDG_RUNTIME_DIR) knownHostDockerSockets.push(join(env.XDG_RUNTIME_DIR, "docker.sock"));
|
|
701
|
+
for (const [index, mount] of config.mounts.entries()) {
|
|
702
|
+
if (mount.type === "named" || mount.type === "tmpfs" || typeof mount.hostPath !== "string") continue;
|
|
703
|
+
const host = resolve(mount.hostPath);
|
|
704
|
+
let unsafe = basename(host) === "docker.sock" || knownHostDockerSockets.some((socket) => isInside(socket, host));
|
|
705
|
+
if (!unsafe) {
|
|
706
|
+
try { unsafe = (await mountStat(host)).isSocket(); }
|
|
707
|
+
catch { policyIssues.push(issueForPath(`mounts[${index}].hostPath`, "could not be safely inspected")); continue; }
|
|
708
|
+
}
|
|
709
|
+
if (unsafe) policyIssues.push(issueForPath(`mounts[${index}].hostPath`, "must not mount a host socket or Docker socket path"));
|
|
710
|
+
}
|
|
652
711
|
const projectSecretFiles = new Map<string, { reference: string; canonical: string }>();
|
|
653
712
|
if (Array.isArray(projectMounts)) for (const mount of projectMounts as any[]) {
|
|
654
713
|
if (!mount || typeof mount.hostPath !== "string" || mount.type === "named" || mount.type === "tmpfs") continue;
|
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
type PersistedSandboxState,
|
|
38
38
|
type ResolvedConfig,
|
|
39
39
|
type RuntimeExecution,
|
|
40
|
+
type RuntimePreparation,
|
|
40
41
|
type RuntimeState,
|
|
41
42
|
type StoragePlan,
|
|
42
43
|
type ToolOperations,
|
|
@@ -267,7 +268,7 @@ function parsePort(value: string): { bind: string; host: number; guest: number }
|
|
|
267
268
|
const numbers = parts.slice(-2).map((item) => Number(item));
|
|
268
269
|
if (parts.length === 1) return { bind: "127.0.0.1", host: numbers[0], guest: numbers[0] };
|
|
269
270
|
if (parts.length === 2) return { bind: "127.0.0.1", host: numbers[0], guest: numbers[1] };
|
|
270
|
-
return { bind: parts[0], host: numbers[
|
|
271
|
+
return { bind: parts[0], host: numbers[0], guest: numbers[1] };
|
|
271
272
|
}
|
|
272
273
|
|
|
273
274
|
function applyMount(builder: AnyRecord, mount: Config["mounts"][number]): void {
|
|
@@ -306,7 +307,7 @@ function applyNetwork(builder: AnyRecord, config: Config, sdk: MicrosandboxModul
|
|
|
306
307
|
return destination.domain(host);
|
|
307
308
|
}));
|
|
308
309
|
}
|
|
309
|
-
if (network.allowDns) policy.egress((rule: AnyRecord) => rule.
|
|
310
|
+
if (network.allowDns) policy.egress((rule: AnyRecord) => rule.udp().tcp().port(53).allowHost());
|
|
310
311
|
builder.network((n: AnyRecord) => n.policy(policy));
|
|
311
312
|
}
|
|
312
313
|
}
|
|
@@ -347,7 +348,7 @@ function sanitizeOverride(key: string, value: unknown): unknown {
|
|
|
347
348
|
}
|
|
348
349
|
|
|
349
350
|
export function createMsbIntegration(options: MsbControlOptions): MsbIntegration {
|
|
350
|
-
const configRef = { value: { ...DEFAULT_CONFIG, network: { ...DEFAULT_CONFIG.network } } as Config };
|
|
351
|
+
const configRef = { value: { ...DEFAULT_CONFIG, network: { ...DEFAULT_CONFIG.network }, docker: { ...DEFAULT_CONFIG.docker } } as Config };
|
|
351
352
|
let sessionId = options.sessionId;
|
|
352
353
|
let cwd = options.cwd;
|
|
353
354
|
let repoRoot: string | null = null;
|
|
@@ -531,21 +532,100 @@ export function createMsbIntegration(options: MsbControlOptions): MsbIntegration
|
|
|
531
532
|
bash: createBashOps({ withRuntime: async (callback) => callback({ transport, operations: undefined as never }) }),
|
|
532
533
|
} as ToolOperations;
|
|
533
534
|
},
|
|
534
|
-
|
|
535
|
-
const
|
|
536
|
-
const result = await Promise.all(
|
|
535
|
+
prepareRuntime: async (runtime, config): Promise<RuntimePreparation> => {
|
|
536
|
+
const commandsMissing = async (commandNames: readonly string[]) => {
|
|
537
|
+
const result = await Promise.all(commandNames.map(async (command) => ({
|
|
538
|
+
command,
|
|
539
|
+
result: await runtime.transport.exec("sh", ["-c", 'command -v "$1" >/dev/null 2>&1', "pi-msb-probe", command]),
|
|
540
|
+
})));
|
|
537
541
|
return result.filter((item) => item.result.exitCode !== 0).map((item) => item.command);
|
|
538
542
|
};
|
|
539
|
-
let commands = await
|
|
543
|
+
let commands = await commandsMissing(REQUIRED_GUEST_COMMANDS);
|
|
540
544
|
if (commands.length && config.bootstrapTools !== false) {
|
|
541
|
-
const apt = await runtime.transport.exec("sh", ["-
|
|
545
|
+
const apt = await runtime.transport.exec("sh", ["-c", 'command -v "$1" >/dev/null 2>&1', "pi-msb-probe", "apt-get"]);
|
|
542
546
|
if (apt.exitCode === 0) {
|
|
543
547
|
await runtime.transport.exec("apt-get", ["update", "-y"]);
|
|
544
548
|
await runtime.transport.exec("apt-get", ["install", "-y", "--no-install-recommends", "bash", "git", "ripgrep", "file", "coreutils", "ca-certificates"]);
|
|
545
|
-
commands = await
|
|
549
|
+
commands = await commandsMissing(REQUIRED_GUEST_COMMANDS);
|
|
546
550
|
}
|
|
547
551
|
}
|
|
548
552
|
if (commands.length) throw new Error(`sandbox is missing required commands: ${commands.join(", ")}; install them or use bootstrapTools=true`);
|
|
553
|
+
|
|
554
|
+
const mode = config.docker.mode;
|
|
555
|
+
if (mode === "disabled") return { docker: { mode, readiness: "disabled" } };
|
|
556
|
+
|
|
557
|
+
const dockerComponents = [
|
|
558
|
+
{ name: "docker", path: "/usr/local/bin/docker" },
|
|
559
|
+
{ name: "dockerd", path: "/usr/local/bin/dockerd" },
|
|
560
|
+
{ name: "pi-msb-docker-start", path: "/usr/local/sbin/pi-msb-docker-start" },
|
|
561
|
+
] as const;
|
|
562
|
+
const dockerChecks = await Promise.all(dockerComponents.map(async (component) => ({
|
|
563
|
+
...component,
|
|
564
|
+
result: await runtime.transport.exec("/usr/bin/test", ["-x", component.path]),
|
|
565
|
+
})));
|
|
566
|
+
const missingDocker = dockerChecks.filter((item) => item.result.exitCode !== 0).map((item) => item.name);
|
|
567
|
+
if (missingDocker.length) {
|
|
568
|
+
const reason = `image is missing Docker components: ${missingDocker.join(", ")}`;
|
|
569
|
+
if (mode === "require") throw new Error(reason);
|
|
570
|
+
return { docker: { mode, readiness: "missing", reason } };
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
let started = false;
|
|
574
|
+
try {
|
|
575
|
+
const result = await runtime.transport.exec(
|
|
576
|
+
"/usr/bin/env",
|
|
577
|
+
[
|
|
578
|
+
"-i",
|
|
579
|
+
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
|
580
|
+
"HOME=/root",
|
|
581
|
+
"/usr/local/sbin/pi-msb-docker-start",
|
|
582
|
+
String(config.docker.startupTimeoutMs),
|
|
583
|
+
],
|
|
584
|
+
{ timeoutMs: config.docker.startupTimeoutMs + 2_000 },
|
|
585
|
+
);
|
|
586
|
+
started = result.exitCode === 0;
|
|
587
|
+
} catch {
|
|
588
|
+
// Keep host-visible status bounded so transport errors cannot expose
|
|
589
|
+
// environment or secret values.
|
|
590
|
+
}
|
|
591
|
+
if (!started) {
|
|
592
|
+
const reason = "Docker daemon did not become ready; inspect /var/log/pi-msb-dockerd.log inside the sandbox";
|
|
593
|
+
if (mode === "require") throw new Error(reason);
|
|
594
|
+
return { docker: { mode, readiness: "unavailable", reason } };
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const inspectLocalDocker = (args: string[]) => runtime.transport.exec(
|
|
598
|
+
"/usr/bin/env",
|
|
599
|
+
[
|
|
600
|
+
"-i",
|
|
601
|
+
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
|
602
|
+
"HOME=/root",
|
|
603
|
+
"/usr/local/bin/docker",
|
|
604
|
+
"--host=unix:///var/run/docker.sock",
|
|
605
|
+
...args,
|
|
606
|
+
],
|
|
607
|
+
{ timeoutMs: 5_000 },
|
|
608
|
+
);
|
|
609
|
+
let version: string | undefined;
|
|
610
|
+
let storageDriver: string | undefined;
|
|
611
|
+
try {
|
|
612
|
+
const [versionResult, driverResult] = await Promise.all([
|
|
613
|
+
inspectLocalDocker(["version", "--format", "{{.Server.Version}}"]),
|
|
614
|
+
inspectLocalDocker(["info", "--format", "{{.Driver}}"]),
|
|
615
|
+
]);
|
|
616
|
+
if (versionResult.exitCode === 0 && driverResult.exitCode === 0) {
|
|
617
|
+
version = versionResult.stdout.toString("utf8").trim().split(/\r?\n/, 1)[0]?.slice(0, 128);
|
|
618
|
+
storageDriver = driverResult.stdout.toString("utf8").trim().split(/\r?\n/, 1)[0]?.slice(0, 128);
|
|
619
|
+
}
|
|
620
|
+
} catch {
|
|
621
|
+
// Report a bounded capability error below.
|
|
622
|
+
}
|
|
623
|
+
if (!version || !storageDriver) {
|
|
624
|
+
const reason = "Docker daemon became reachable but capability inspection failed";
|
|
625
|
+
if (mode === "require") throw new Error(reason);
|
|
626
|
+
return { docker: { mode, readiness: "unavailable", reason } };
|
|
627
|
+
}
|
|
628
|
+
return { docker: { mode, readiness: "ready", version, storageDriver } };
|
|
549
629
|
},
|
|
550
630
|
seed: async (runtime, prepared) => seedGitVolume(runtime.transport, prepared.plan as any, prepared.bundle ?? null),
|
|
551
631
|
persist: (state) => options.appendEntry?.(STATE_ENTRY, encodeSessionState(state)),
|
|
@@ -564,6 +644,7 @@ export function createMsbIntegration(options: MsbControlOptions): MsbIntegration
|
|
|
564
644
|
withRuntime: (callback) => manager.withRuntime(callback),
|
|
565
645
|
};
|
|
566
646
|
const effective = (): ResolvedConfig => resolved;
|
|
647
|
+
const configSnapshot = (): Config => structuredClone(configRef.value);
|
|
567
648
|
const overrideEntries = (entries: readonly unknown[]): DeepPartial<Config> => {
|
|
568
649
|
let result: DeepPartial<Config> = {};
|
|
569
650
|
for (const entry of entries) {
|
|
@@ -608,7 +689,7 @@ export function createMsbIntegration(options: MsbControlOptions): MsbIntegration
|
|
|
608
689
|
}
|
|
609
690
|
resolved = next;
|
|
610
691
|
configReady = true;
|
|
611
|
-
Object.assign(configRef.value, next.config, { network: { ...next.config.network }, secrets: [...next.config.secrets], mounts: [...next.config.mounts] });
|
|
692
|
+
Object.assign(configRef.value, next.config, { network: { ...next.config.network }, docker: { ...next.config.docker }, secrets: [...next.config.secrets], mounts: [...next.config.mounts] });
|
|
612
693
|
const state = setup.restored ?? persistenceState(options.entries?.() ?? [], sessionId);
|
|
613
694
|
explicitOff = isDisabledByEnv(options.env ?? process.env);
|
|
614
695
|
failureState = explicitOff ? { status: "off", info: null } : null;
|
|
@@ -616,7 +697,7 @@ export function createMsbIntegration(options: MsbControlOptions): MsbIntegration
|
|
|
616
697
|
if (!explicitOff) { await manager.setEnabled(false); failureState = { status: "off", info: null }; }
|
|
617
698
|
return visibleState();
|
|
618
699
|
}
|
|
619
|
-
const result = await manager.boot({ sessionId, cwd, config:
|
|
700
|
+
const result = await manager.boot({ sessionId, cwd, config: configSnapshot(), restored: state });
|
|
620
701
|
if (result.status === "unavailable") failureState = result;
|
|
621
702
|
notifyState(visibleState());
|
|
622
703
|
return visibleState();
|
|
@@ -644,7 +725,7 @@ export function createMsbIntegration(options: MsbControlOptions): MsbIntegration
|
|
|
644
725
|
await manager.setEnabled(false);
|
|
645
726
|
failureState = { status: "off", info: null };
|
|
646
727
|
} else {
|
|
647
|
-
const result = await manager.boot({ sessionId, cwd, config:
|
|
728
|
+
const result = await manager.boot({ sessionId, cwd, config: configSnapshot(), restored: persistenceState(options.entries?.() ?? [], sessionId) });
|
|
648
729
|
if (result.status === "unavailable") failureState = result;
|
|
649
730
|
}
|
|
650
731
|
notifyState(visibleState());
|
|
@@ -667,10 +748,10 @@ export function createMsbIntegration(options: MsbControlOptions): MsbIntegration
|
|
|
667
748
|
}
|
|
668
749
|
resolved = next;
|
|
669
750
|
configReady = true;
|
|
670
|
-
Object.assign(configRef.value, next.config, { network: { ...next.config.network }, secrets: [...next.config.secrets], mounts: [...next.config.mounts] });
|
|
751
|
+
Object.assign(configRef.value, next.config, { network: { ...next.config.network }, docker: { ...next.config.docker }, secrets: [...next.config.secrets], mounts: [...next.config.mounts] });
|
|
671
752
|
failureState = null;
|
|
672
753
|
if (!explicitOff && configRef.value.autoStart) {
|
|
673
|
-
const result = await manager.boot({ sessionId, cwd, config:
|
|
754
|
+
const result = await manager.boot({ sessionId, cwd, config: configSnapshot(), restored: persistenceState(options.entries?.() ?? [], sessionId) });
|
|
674
755
|
if (result.status === "unavailable") failureState = result;
|
|
675
756
|
}
|
|
676
757
|
notifyState(visibleState());
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
|
|
4
|
+
export type FlockOperation = "exnb" | "un";
|
|
5
|
+
export type FlockTarget = "darwin-arm64" | "linux-x64-gnu" | "linux-arm64-gnu";
|
|
6
|
+
|
|
7
|
+
export interface FlockRuntime {
|
|
8
|
+
platform: string;
|
|
9
|
+
arch: string;
|
|
10
|
+
glibcVersionRuntime?: unknown;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface NativeFlockBinding {
|
|
14
|
+
flock(fd: number, operation: FlockOperation): undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface LoadFlockOptions {
|
|
18
|
+
runtime?: FlockRuntime;
|
|
19
|
+
requireAddon?: (absolutePath: string) => unknown;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const SUPPORTED_TARGETS = "darwin-arm64, linux-x64-gnu, linux-arm64-gnu";
|
|
23
|
+
|
|
24
|
+
function errorMessage(error: unknown): string {
|
|
25
|
+
return error instanceof Error ? error.message : String(error);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function currentRuntime(): FlockRuntime {
|
|
29
|
+
const report = process.platform === "linux"
|
|
30
|
+
? process.report?.getReport() as { header?: { glibcVersionRuntime?: unknown } } | undefined
|
|
31
|
+
: undefined;
|
|
32
|
+
return {
|
|
33
|
+
platform: process.platform,
|
|
34
|
+
arch: process.arch,
|
|
35
|
+
glibcVersionRuntime: report?.header?.glibcVersionRuntime,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function flockTarget(runtime: FlockRuntime): FlockTarget {
|
|
40
|
+
if (runtime.platform === "darwin" && runtime.arch === "arm64") return "darwin-arm64";
|
|
41
|
+
if (runtime.platform === "linux" && (runtime.arch === "x64" || runtime.arch === "arm64")) {
|
|
42
|
+
if (typeof runtime.glibcVersionRuntime !== "string" || runtime.glibcVersionRuntime.length === 0) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`Bundled owner-lock addon requires glibc on Linux; libc is musl or indeterminate for linux-${runtime.arch}; `
|
|
45
|
+
+ `supported targets: ${SUPPORTED_TARGETS}; refusing to use an unsafe fallback`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
return `linux-${runtime.arch}-gnu`;
|
|
49
|
+
}
|
|
50
|
+
throw new Error(
|
|
51
|
+
`Bundled owner-lock addon does not support ${runtime.platform}-${runtime.arch}; `
|
|
52
|
+
+ `supported targets: ${SUPPORTED_TARGETS}; refusing to use an unsafe fallback`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function flockBinaryPath(target: FlockTarget): string {
|
|
57
|
+
return fileURLToPath(new URL(`../../native/flock/prebuilds/${target}/flock.node`, import.meta.url));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function loadFlockBinding(options: LoadFlockOptions = {}): NativeFlockBinding {
|
|
61
|
+
const target = flockTarget(options.runtime ?? currentRuntime());
|
|
62
|
+
const relativePath = `native/flock/prebuilds/${target}/flock.node`;
|
|
63
|
+
const absolutePath = flockBinaryPath(target);
|
|
64
|
+
const requireAddon = options.requireAddon ?? ((path: string) => createRequire(import.meta.url)(path));
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const candidate = requireAddon(absolutePath);
|
|
68
|
+
if (typeof candidate !== "object" || candidate === null
|
|
69
|
+
|| Object.keys(candidate).length !== 1
|
|
70
|
+
|| typeof (candidate as { flock?: unknown }).flock !== "function") {
|
|
71
|
+
throw new TypeError("native binding must export only flock() as a callable property");
|
|
72
|
+
}
|
|
73
|
+
return candidate as NativeFlockBinding;
|
|
74
|
+
} catch (error) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`Unable to load bundled owner-lock addon for ${target} from ${relativePath}; `
|
|
77
|
+
+ `refusing to use an unsafe fallback: ${errorMessage(error)}`,
|
|
78
|
+
{ cause: error },
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let binding: NativeFlockBinding | undefined;
|
|
84
|
+
|
|
85
|
+
export async function flock(fd: number, operation: FlockOperation): Promise<void> {
|
|
86
|
+
if (!Number.isInteger(fd) || fd < 0 || fd > 0x7fff_ffff) {
|
|
87
|
+
throw new TypeError("file descriptor must be a non-negative integer");
|
|
88
|
+
}
|
|
89
|
+
if (operation !== "exnb" && operation !== "un") {
|
|
90
|
+
throw new TypeError('operation must be "exnb" or "un"');
|
|
91
|
+
}
|
|
92
|
+
binding ??= loadFlockBinding();
|
|
93
|
+
binding.flock(fd, operation);
|
|
94
|
+
}
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { chmod, mkdir, open, readFile } from "node:fs/promises";
|
|
2
2
|
import { isAbsolute, join } from "node:path";
|
|
3
3
|
|
|
4
|
+
import { flock as bundledFlock } from "./flock.ts";
|
|
4
5
|
import { LOCKFILE_VERSION, resourceId } from "./types.ts";
|
|
5
6
|
import type { LockHandle, LockInfo, LocksPort } from "./types.ts";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* The hooks are deliberately part of the options shape so unit tests can exercise
|
|
9
10
|
* the lifecycle without loading the native addon. Production callers leave them
|
|
10
|
-
* unset and use
|
|
11
|
+
* unset and use the lazy bundled binding.
|
|
11
12
|
*/
|
|
12
13
|
export type FlockFn = (fd: number, operation: "exnb" | "un") => Promise<void>;
|
|
13
14
|
|
|
@@ -18,25 +19,6 @@ export interface LocksOptions {
|
|
|
18
19
|
unlock?: (fd: number) => Promise<void>;
|
|
19
20
|
}
|
|
20
21
|
|
|
21
|
-
type FsExtCallback = (
|
|
22
|
-
fd: number,
|
|
23
|
-
operation: string,
|
|
24
|
-
callback: (error?: unknown) => void,
|
|
25
|
-
) => void;
|
|
26
|
-
type FsExtUnlockCallback = (fd: number, callback: (error?: unknown) => void) => void;
|
|
27
|
-
type FsExtModule = {
|
|
28
|
-
flock?: FsExtCallback;
|
|
29
|
-
unlock?: FsExtUnlockCallback;
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
let fsExtPromise: Promise<FsExtModule> | undefined;
|
|
33
|
-
|
|
34
|
-
// Keep the native dependency genuinely lazy and let extension-load/type-only
|
|
35
|
-
// environments operate without resolving the optional native module.
|
|
36
|
-
function importNativeModule(specifier: string): Promise<unknown> {
|
|
37
|
-
return import(specifier);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
22
|
function errorCode(error: unknown): string | undefined {
|
|
41
23
|
if (typeof error === "object" && error !== null && "code" in error) {
|
|
42
24
|
const code = (error as { code?: unknown }).code;
|
|
@@ -49,77 +31,16 @@ function errorMessage(error: unknown): string {
|
|
|
49
31
|
return error instanceof Error ? error.message : String(error);
|
|
50
32
|
}
|
|
51
33
|
|
|
52
|
-
function unsupportedPlatformError(): Error {
|
|
53
|
-
return new Error(
|
|
54
|
-
"pi-microsandbox owner locks require POSIX flock(2) via fs-ext; this platform is unsupported (Windows LockFileEx is not implemented)",
|
|
55
|
-
);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
async function loadFsExt(): Promise<FsExtModule> {
|
|
59
|
-
if (process.platform === "win32") throw unsupportedPlatformError();
|
|
60
|
-
fsExtPromise ??= importNativeModule("fs-ext").then((module) => {
|
|
61
|
-
const defaultExport = (module as unknown as { default?: unknown }).default;
|
|
62
|
-
const candidate = (defaultExport ?? module) as FsExtModule;
|
|
63
|
-
if (typeof candidate.flock !== "function") {
|
|
64
|
-
throw new Error("fs-ext loaded without flock(); refusing to use a racy PID fallback");
|
|
65
|
-
}
|
|
66
|
-
return candidate;
|
|
67
|
-
});
|
|
68
|
-
return fsExtPromise;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function callbackFlock(flock: FsExtCallback, fd: number, operation: string): Promise<void> {
|
|
72
|
-
return new Promise((resolve, reject) => {
|
|
73
|
-
try {
|
|
74
|
-
flock(fd, operation, (error) => (error ? reject(error) : resolve()));
|
|
75
|
-
} catch (error) {
|
|
76
|
-
reject(error);
|
|
77
|
-
}
|
|
78
|
-
});
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function callbackUnlock(unlock: FsExtUnlockCallback, fd: number): Promise<void> {
|
|
82
|
-
return new Promise((resolve, reject) => {
|
|
83
|
-
try {
|
|
84
|
-
unlock(fd, (error) => (error ? reject(error) : resolve()));
|
|
85
|
-
} catch (error) {
|
|
86
|
-
reject(error);
|
|
87
|
-
}
|
|
88
|
-
});
|
|
89
|
-
}
|
|
90
|
-
|
|
91
34
|
interface LockOperations {
|
|
92
35
|
flock(fd: number): Promise<void>;
|
|
93
36
|
unlock(fd: number): Promise<void>;
|
|
94
37
|
}
|
|
95
38
|
|
|
96
39
|
async function lockOperations(opts: LocksOptions): Promise<LockOperations> {
|
|
97
|
-
|
|
98
|
-
return {
|
|
99
|
-
flock: (fd) => opts.flock!(fd, "exnb"),
|
|
100
|
-
unlock: (fd) => opts.unlock ? opts.unlock(fd) : opts.flock!(fd, "un"),
|
|
101
|
-
};
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
let module: FsExtModule;
|
|
105
|
-
try {
|
|
106
|
-
module = await loadFsExt();
|
|
107
|
-
} catch (error) {
|
|
108
|
-
throw new Error(
|
|
109
|
-
`Unable to load fs-ext for owner locks; refusing to use a racy PID fallback: ${errorMessage(error)}`,
|
|
110
|
-
{ cause: error },
|
|
111
|
-
);
|
|
112
|
-
}
|
|
113
|
-
const flock = module.flock;
|
|
114
|
-
if (!flock) {
|
|
115
|
-
throw new Error("fs-ext does not provide flock(); refusing to use a racy PID fallback");
|
|
116
|
-
}
|
|
117
|
-
const unlock = module.unlock;
|
|
40
|
+
const flock = opts.flock ?? bundledFlock;
|
|
118
41
|
return {
|
|
119
|
-
flock: (fd) =>
|
|
120
|
-
unlock: (fd) => unlock
|
|
121
|
-
? callbackUnlock(unlock, fd)
|
|
122
|
-
: callbackFlock(flock, fd, "un"),
|
|
42
|
+
flock: (fd) => flock(fd, "exnb"),
|
|
43
|
+
unlock: (fd) => opts.unlock ? opts.unlock(fd) : flock(fd, "un"),
|
|
123
44
|
};
|
|
124
45
|
}
|
|
125
46
|
|