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.
@@ -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.0.0@sha256:00ea1e0911189815614e8a8eee36d1fd64f0f1edb39492e0bda9f273c834e59f",
64
+ image: "ghcr.io/hcohe/pi-microsandbox:1.1.0@sha256:ab4e99d4232f827b3f295ff3210437e01446dbb672ef0d0c78358566170ac86c",
34
65
  pullPolicy: "if-missing",
35
66
  bootstrapTools: "auto",
36
- cpus: 1,
37
- memoryMiB: 512,
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[1], guest: numbers[2] };
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.allowDns());
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
- probeAndBootstrap: async (runtime, config) => {
535
- const missing = async () => {
536
- const result = await Promise.all(REQUIRED_GUEST_COMMANDS.map(async (command) => ({ command, result: await runtime.transport.exec("sh", ["-lc", `command -v ${command}`]) })));
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 missing();
543
+ let commands = await commandsMissing(REQUIRED_GUEST_COMMANDS);
540
544
  if (commands.length && config.bootstrapTools !== false) {
541
- const apt = await runtime.transport.exec("sh", ["-lc", "command -v apt-get"]);
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 missing();
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: configRef.value, restored: state });
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: configRef.value, restored: persistenceState(options.entries?.() ?? [], sessionId) });
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: configRef.value, restored: persistenceState(options.entries?.() ?? [], sessionId) });
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 fs-ext below.
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
- if (opts.flock) {
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) => callbackFlock(flock, fd, "exnb"),
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