import fs from "fs";
import net from "net";
import os from "os";
import path from "path";
import { randomUUID } from "crypto";
import { execFileSync } from "child_process";
import { Duplex, Readable } from "stream";
import { AsyncSingleflight } from "../utils/async.js";
import { createTempQcow2Overlay, ensureDiskImageMinimumSize, ensureQemuImgAvailable, inferDiskFormatFromPath, moveFile, parseDiskSizeToBytes, rebaseQcow2InPlace, resolveQcow2BackingPath, } from "../qemu/img.js";
import { VmCheckpoint, registerVmCreate, } from "../checkpoint.js";
import { loadAssetManifest } from "../assets.js";
import { isRootfsMode } from "../build/config.js";
import { decodeOutputFrame, } from "../sandbox/control-protocol.js";
import { SandboxServer } from "../sandbox/server.js";
import { assertMacHypervisorEntitlement } from "../sandbox/krun-controller.js";
import { resolveSandboxServerOptions, resolveSandboxServerOptionsAsync, } from "../sandbox/server-options.js";
import { SessionIpcServer, gcSessions, registerSession, unregisterSession, } from "../session-registry.js";
import { createMitmCaProvider, resolveMitmMounts } from "./mitm-vfs.js";
import { buildShellEnv, envInputToEntries, mapToEnvArray, mergeEnvInputs, parseEnvEntry, resolveEnvNumber, } from "../utils/env.js";
import { defaultDebugLog, resolveDebugFlags, } from "../debug.js";
import { IngressGateway, createGondolinEtcHooks, createGondolinEtcMount, } from "../ingress.js";
import { MemoryProvider } from "../vfs/node/index.js";
import { SandboxVfsProvider, composeVfsHooks, wrapProvider, } from "../vfs/provider.js";
import { MountRouterProvider, listMountPaths, normalizeMountMap, normalizeMountPath, } from "../vfs/mounts.js";
import { VmFsController } from "./fs.js";
import { ExecProcess, createExecSession, finishExecSession, rejectExecSession, resolveOutputMode, applyOutputChunk, normalizeCommand, toAsyncIterable, } from "../exec.js";
const MAX_REQUEST_ID = 0xffffffff;
const DEFAULT_STDIN_CHUNK = 32 * 1024;
const DEFAULT_VM_START_TIMEOUT_MS = 120000;
const VM_START_TIMEOUT_MS = resolveEnvNumber("GONDOLIN_START_TIMEOUT_MS", DEFAULT_VM_START_TIMEOUT_MS);
function normalizeStartTimeoutMs(value, fallback = VM_START_TIMEOUT_MS) {
    const normalizedFallback = Number.isFinite(fallback) && fallback > 0
        ? Math.max(1, Math.trunc(fallback))
        : DEFAULT_VM_START_TIMEOUT_MS;
    if (value === undefined) {
        return normalizedFallback;
    }
    if (!Number.isFinite(value)) {
        return normalizedFallback;
    }
    return Math.max(0, Math.trunc(value));
}
const DEFAULT_VFS_READY_TIMEOUT_MS = 30000;
const VFS_READY_SLEEP_SECONDS = resolveEnvNumber("GONDOLIN_VFS_READY_SLEEP_SECONDS", 0.1);
const VFS_READY_TIMEOUT_MS = resolveEnvNumber("GONDOLIN_VFS_READY_TIMEOUT_MS", DEFAULT_VFS_READY_TIMEOUT_MS);
const VFS_READY_ATTEMPTS = Math.max(1, Math.ceil(VFS_READY_TIMEOUT_MS / (VFS_READY_SLEEP_SECONDS * 1000)));
export class VM {
    /**
     * Replace the debug log callback.
     *
     * Passing `null` disables debug output.
     */
    setDebugLog(callback) {
        this.debugLog = callback;
    }
    /** vm session identifier */
    id;
    /** guest filesystem operations */
    fs;
    autoStart;
    startTimeoutMs;
    sessionLabel;
    server;
    resolvedSandboxOptions;
    rootDisk = null;
    checkpointed = false;
    baseOptionsForClone;
    defaultEnv;
    connection = null;
    connectPromise = null;
    startSingleflight = new AsyncSingleflight();
    closeSingleflight = new AsyncSingleflight();
    startupGeneration = 0;
    statusPromise = null;
    statusResolve = null;
    statusReject = null;
    state = "unknown";
    stateWaiters = [];
    sessions = new Map();
    nextId = 1;
    vfs;
    fuseMount;
    fuseBinds;
    shortcutBindMounts;
    bootSent = false;
    vfsReadyPromise = null;
    rootfsGuestResizePending = false;
    rootfsGuestResizeDone = false;
    vmmChecked = false;
    debugLog = null;
    debugListener = null;
    sshAccess = null;
    gondolinEtc = null;
    ingressAccess = null;
    sessionIpc = null;
    /**
     * Create a VM instance, downloading guest assets if needed.
     *
     * This is the recommended way to create a VM in production, as it will
     * automatically download the guest image if it's not available locally.
     *
     * @param options VM configuration options
     * @returns A configured VM instance
     */
    static async create(options = {}) {
        if (options.rootfs?.size !== undefined) {
            parseDiskSizeToBytes(options.rootfs.size);
        }
        // Resolve sandbox options with async asset fetching
        const sandboxOptions = { ...options.sandbox };
        // Build the combined sandbox options
        if (options.fetch && sandboxOptions.fetch === undefined) {
            sandboxOptions.fetch = options.fetch;
        }
        if (options.httpHooks && sandboxOptions.httpHooks === undefined) {
            sandboxOptions.httpHooks = options.httpHooks;
        }
        if (options.dns && sandboxOptions.dns === undefined) {
            sandboxOptions.dns = options.dns;
        }
        if (options.ssh && sandboxOptions.ssh === undefined) {
            sandboxOptions.ssh = options.ssh;
        }
        if (options.maxHttpBodyBytes !== undefined &&
            sandboxOptions.maxHttpBodyBytes === undefined) {
            sandboxOptions.maxHttpBodyBytes = options.maxHttpBodyBytes;
        }
        if (options.maxHttpResponseBodyBytes !== undefined &&
            sandboxOptions.maxHttpResponseBodyBytes === undefined) {
            sandboxOptions.maxHttpResponseBodyBytes =
                options.maxHttpResponseBodyBytes;
        }
        if (options.allowWebSockets !== undefined &&
            sandboxOptions.allowWebSockets === undefined) {
            sandboxOptions.allowWebSockets = options.allowWebSockets;
        }
        if (options.tcp && sandboxOptions.tcp === undefined) {
            sandboxOptions.tcp = options.tcp;
        }
        if (options.memory && sandboxOptions.memory === undefined) {
            sandboxOptions.memory = options.memory;
        }
        if (options.cpus && sandboxOptions.cpus === undefined) {
            sandboxOptions.cpus = options.cpus;
        }
        // Resolve options with asset fetching
        const resolvedSandboxOptions = await resolveSandboxServerOptionsAsync(sandboxOptions);
        // Create VM with pre-resolved options
        return new VM(options, resolvedSandboxOptions);
    }
    /**
     * Create a VM instance synchronously.
     *
     * This constructor requires that guest assets are available locally (either
     * in a development checkout or via GONDOLIN_GUEST_DIR). For automatic asset
     * downloading, use the async `VM.create()` factory instead.
     *
     * @param options VM configuration options
     * @param resolvedSandboxOptions Optional pre-resolved sandbox options (from VM.create())
     */
    constructor(options = {}, resolvedSandboxOptions) {
        this.id = randomUUID();
        this.baseOptionsForClone = { ...options };
        this.autoStart = options.autoStart ?? true;
        this.startTimeoutMs = normalizeStartTimeoutMs(options.startTimeoutMs);
        this.sessionLabel = options.sessionLabel ?? process.argv.join(" ");
        const rootfsSizeBytes = options.rootfs?.size === undefined
            ? null
            : parseDiskSizeToBytes(options.rootfs.size);
        const mitmMounts = resolveMitmMounts(options.vfs, options.sandbox?.mitmCertDir, options.sandbox?.netEnabled ?? true);
        // Inject a guarded /etc/gondolin mount (host-authoritative ingress configuration)
        let gondolinMounts = {};
        let gondolinHooks = {};
        if (options.vfs !== null) {
            const mountPaths = listMountPaths(options.vfs?.mounts);
            if (!mountPaths.includes("/etc/gondolin")) {
                const etcProvider = new MemoryProvider();
                this.gondolinEtc = createGondolinEtcMount(etcProvider);
                gondolinMounts = {
                    "/etc/gondolin": etcProvider,
                };
                gondolinHooks = createGondolinEtcHooks(this.gondolinEtc.listeners, etcProvider);
            }
        }
        const mergedHooks = composeVfsHooks(options.vfs?.hooks, gondolinHooks);
        const vfsOptions = options.vfs === null
            ? null
            : {
                ...(options.vfs ?? {}),
                hooks: mergedHooks,
            };
        const resolvedVfs = resolveVmVfs(vfsOptions, {
            ...mitmMounts,
            ...gondolinMounts,
        });
        this.vfs = resolvedVfs.provider;
        this.defaultEnv = options.env;
        let fuseMounts = resolvedVfs.mounts;
        let fuseConfig = resolveFuseConfig(options.vfs, fuseMounts);
        this.fuseMount = fuseConfig.fuseMount;
        this.fuseBinds = fuseConfig.fuseBinds;
        const sandboxOptions = { ...options.sandbox };
        if (sandboxOptions.vfsProvider && options.vfs) {
            throw new Error("VM cannot specify both vfs and sandbox.vfsProvider");
        }
        if (sandboxOptions.vfsProvider) {
            const injectedMounts = resolveMitmMounts(undefined, sandboxOptions.mitmCertDir, sandboxOptions.netEnabled ?? true);
            if (Object.keys(injectedMounts).length > 0) {
                const normalized = normalizeMountMap({
                    "/": sandboxOptions.vfsProvider,
                    ...injectedMounts,
                });
                this.vfs = wrapProvider(new MountRouterProvider(normalized), {});
                fuseMounts = { "/": sandboxOptions.vfsProvider, ...injectedMounts };
            }
            else {
                this.vfs = wrapProvider(sandboxOptions.vfsProvider, {});
                fuseMounts = { "/": sandboxOptions.vfsProvider };
            }
            fuseConfig = resolveFuseConfig(options.vfs, fuseMounts);
            this.fuseMount = fuseConfig.fuseMount;
            this.fuseBinds = fuseConfig.fuseBinds;
            sandboxOptions.vfsProvider = this.vfs;
        }
        this.shortcutBindMounts = this.fuseBinds
            .filter((mountPath) => mountPath !== this.fuseMount)
            .sort((a, b) => b.length - a.length);
        if (options.fetch && sandboxOptions.fetch === undefined) {
            sandboxOptions.fetch = options.fetch;
        }
        if (options.httpHooks && sandboxOptions.httpHooks === undefined) {
            sandboxOptions.httpHooks = options.httpHooks;
        }
        if (options.dns && sandboxOptions.dns === undefined) {
            sandboxOptions.dns = options.dns;
        }
        if (options.ssh && sandboxOptions.ssh === undefined) {
            sandboxOptions.ssh = options.ssh;
        }
        if (options.maxHttpBodyBytes !== undefined &&
            sandboxOptions.maxHttpBodyBytes === undefined) {
            sandboxOptions.maxHttpBodyBytes = options.maxHttpBodyBytes;
        }
        if (options.maxHttpResponseBodyBytes !== undefined &&
            sandboxOptions.maxHttpResponseBodyBytes === undefined) {
            sandboxOptions.maxHttpResponseBodyBytes =
                options.maxHttpResponseBodyBytes;
        }
        if (options.allowWebSockets !== undefined &&
            sandboxOptions.allowWebSockets === undefined) {
            sandboxOptions.allowWebSockets = options.allowWebSockets;
        }
        if (options.tcp && sandboxOptions.tcp === undefined) {
            sandboxOptions.tcp = options.tcp;
        }
        if (this.vfs && sandboxOptions.vfsProvider === undefined) {
            sandboxOptions.vfsProvider = this.vfs;
        }
        if (options.memory && sandboxOptions.memory === undefined) {
            sandboxOptions.memory = options.memory;
        }
        if (options.cpus && sandboxOptions.cpus === undefined) {
            sandboxOptions.cpus = options.cpus;
        }
        // Resolve sandbox options (sync) if needed so we can prepare the root disk.
        const resolved = resolvedSandboxOptions
            ? { ...resolvedSandboxOptions }
            : resolveSandboxServerOptions(sandboxOptions);
        // Merge VFS provider into resolved options
        if (this.vfs) {
            resolved.vfsProvider = this.vfs;
        }
        const hasUserRootDiskConfig = sandboxOptions.rootDiskPath !== undefined ||
            sandboxOptions.rootDiskFormat !== undefined ||
            sandboxOptions.rootDiskReadOnly !== undefined ||
            sandboxOptions.rootDiskDeleteOnClose !== undefined;
        const manifestRootfsMode = resolveManifestRootfsMode(resolved);
        const rootfsMode = options.rootfs?.mode ?? manifestRootfsMode ?? "cow";
        const supportsSnapshotRootDisk = (resolved.vmm ?? "qemu") === "qemu";
        try {
            // Prepare root disk:
            // - Explicit sandbox.rootDisk* options win.
            // - Otherwise, use rootfs mode from VM options/manifest/default.
            if (hasUserRootDiskConfig) {
                this.rootDisk = prepareConfiguredRootDisk(resolved, sandboxOptions);
            }
            else if (rootfsMode === "readonly") {
                this.rootDisk = prepareBaseRootDisk(resolved, {
                    readOnly: true,
                    snapshot: false,
                });
            }
            else if (rootfsMode === "memory") {
                this.rootDisk =
                    supportsSnapshotRootDisk && rootfsSizeBytes === null
                        ? prepareBaseRootDisk(resolved, {
                            readOnly: false,
                            snapshot: true,
                        })
                        : prepareOverlayRootDisk(resolved);
            }
            else if (rootfsMode === "cow") {
                this.rootDisk = prepareOverlayRootDisk(resolved);
            }
            else {
                throw new Error(`unsupported rootfs mode: ${String(rootfsMode)}`);
            }
            if (rootfsSizeBytes !== null) {
                prepareRootDiskResize(this.rootDisk, resolved.rootfsPath, rootfsSizeBytes);
                this.rootfsGuestResizePending = true;
            }
        }
        catch (err) {
            this.cleanupRootDiskSync();
            throw err;
        }
        this.resolvedSandboxOptions = resolved;
        this.server = new SandboxServer(resolved, {
            qemuRootDiskVolatileMode: this.rootDisk?.snapshot
                ? "snapshot"
                : undefined,
        });
        this.fs = new VmFsController({
            start: () => this.start(),
            exec: (command, fsOptions = {}) => this.exec(command, fsOptions),
            getServer: () => this.server,
            vfs: this.vfs,
            fuseMount: this.fuseMount,
            shortcutBindMounts: this.shortcutBindMounts,
        });
        const effectiveDebugFlags = resolvedSandboxOptions
            ? new Set(resolvedSandboxOptions.debug ?? [])
            : resolveDebugFlags(sandboxOptions.debug);
        const anyDebug = effectiveDebugFlags.size > 0;
        if (anyDebug) {
            // If the user didn't provide a debug sink, default to console.log
            this.debugLog =
                options.debugLog === undefined ? defaultDebugLog : options.debugLog;
            // Always attach the listener so `vm.setDebugLog()` can enable logging later.
            this.debugListener = (component, message) => {
                const logger = this.debugLog;
                if (!logger)
                    return;
                try {
                    logger(component, message);
                }
                catch {
                    // ignore logger errors
                }
            };
            this.server.on("debug", this.debugListener);
        }
    }
    /**
     * Start the VM.
     *
     * If VFS is configured, this also waits for the VFS mount(s) to be ready.
     */
    async start() {
        return this.startSingleflight.run(() => this.startInternal());
    }
    /**
     * Close the VM and release associated resources.
     */
    async close() {
        return this.closeSingleflight.run(() => this.closeInternal());
    }
    /**
     * Return the host PID of the active VM runner process, or null when no runner is active.
     */
    getHostPid() {
        return this.server?.getHostPid() ?? null;
    }
    /**
     * Execute a command in the sandbox.
     *
     * Returns an ExecProcess which can be:
     * - awaited for a buffered result with strings
     * - iterated for streaming output (requires stdout: "pipe")
     * - used with stdin via write()/end()
     *
     * @example
     * ```typescript
     * // String form runs via `/bin/sh -lc "..."`
     * const r1 = await vm.exec("echo hello");
     * console.log(r1.stdout); // 'hello\n'
     *
     * // Array form executes an executable directly (does not search `$PATH`)
     * const r2 = await vm.exec(["/bin/echo", "hello"]);
     * console.log(r2.stdout); // 'hello\n'
     *
     * // Streaming output (piped stdout)
     * for await (const line of vm.exec(["/bin/tail", "-f", "/var/log/syslog"], { stdout: "pipe" })) {
     *   console.log(line);
     * }
     *
     * // Interactive with stdin
     * const proc = vm.exec(["/bin/cat"], { stdin: true });
     * proc.write("hello\n");
     * proc.end();
     * const result = await proc;
     * ```
     */
    exec(command, options = {}) {
        const proc = this.execInternal(command, options);
        return proc;
    }
    /**
     * Start an interactive shell session.
     *
     * By default, attaches to process.stdin/stdout/stderr when running in a TTY.
     *
     * @example
     * ```typescript
     * // Simple interactive shell
     * const result = await vm.shell();
     * process.exit(result.exitCode);
     *
     * // Custom command (absolute path required)
     * const result = await vm.shell({ command: "/bin/sh" });
     *
     * // Manual control
     * const proc = vm.shell({ attach: false });
     * proc.write('ls\n');
     * for await (const chunk of proc) {
     *   process.stdout.write(chunk);
     * }
     * ```
     */
    shell(options = {}) {
        const command = options.command ?? ["/bin/bash", "-i"];
        const shouldAttach = options.attach ?? process.stdin.isTTY;
        const env = buildShellEnv(this.defaultEnv, options.env);
        const proc = this.exec(command, {
            env,
            cwd: options.cwd,
            stdin: true,
            pty: true,
            signal: options.signal,
            ...(shouldAttach
                ? {
                    stdout: "inherit",
                    stderr: "inherit",
                }
                : {
                    stdout: "pipe",
                    stderr: "pipe",
                }),
        });
        if (shouldAttach) {
            proc.attach(process.stdin, process.stdout, process.stderr);
        }
        return proc;
    }
    /**
     * Enable SSH access to the VM by starting `sshd` in the guest (bound to loopback)
     * and creating a host-local TCP forwarder.
     */
    async enableSsh(options = {}) {
        if (this.sshAccess)
            return this.sshAccess;
        await this.start();
        const user = options.user ?? "root";
        const listenHost = options.listenHost ?? "127.0.0.1";
        const listenPort = options.listenPort ?? 0;
        // Generate ephemeral client keypair
        const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gondolin-ssh-"));
        const keyPath = path.join(tmpDir, "id_ed25519");
        try {
            execFileSync("ssh-keygen", ["-t", "ed25519", "-N", "", "-f", keyPath], {
                stdio: "ignore",
            });
        }
        catch (err) {
            try {
                fs.rmSync(tmpDir, { recursive: true, force: true });
            }
            catch {
                // ignore
            }
            throw new Error(`failed to run ssh-keygen (needed for vm.enableSsh): ${err instanceof Error ? err.message : String(err)}`);
        }
        const pubKey = fs.readFileSync(keyPath + ".pub", "utf8").trim();
        const shQuote = (value) => "'" + value.replace(/'/g, "'\\''") + "'";
        const sshUser = shQuote(user);
        // Install authorized_keys + start sandboxssh + start sshd
        const setupScript = `set -eu
SSH_USER=${sshUser}
if ! command -v sshd >/dev/null 2>&1; then
  echo "sshd not found in guest image" 1>&2
  exit 127
fi

if ! command -v sandboxssh >/dev/null 2>&1; then
  echo "sandboxssh not found in guest image" 1>&2
  exit 126
fi

if ! id "$SSH_USER" >/dev/null 2>&1; then
  echo "ssh user '$SSH_USER' does not exist in guest image" 1>&2
  exit 125
fi

SSH_UID=$(id -u "$SSH_USER")
SSH_GID=$(id -g "$SSH_USER")

SSH_HOME=""
if command -v getent >/dev/null 2>&1; then
  SSH_HOME=$(getent passwd "$SSH_USER" | cut -d: -f6 || true)
fi
if [ -z "$SSH_HOME" ] && [ -r /etc/passwd ]; then
  SSH_HOME=$(awk -F: -v u="$SSH_USER" '$1==u{print $6;exit}' /etc/passwd || true)
fi
if [ -z "$SSH_HOME" ]; then
  if [ "$SSH_UID" = "0" ]; then
    SSH_HOME=/root
  else
    SSH_HOME="/home/$SSH_USER"
  fi
fi

# Ensure loopback is up (needed for ListenAddress=127.0.0.1 and tcp forwarding)
if command -v ip >/dev/null 2>&1; then
  ip link set lo up || true
else
  ifconfig lo up || true
fi

# sshd on Alpine wants /var/empty to be root-owned
mkdir -p /var/empty
chown root:root /var/empty || true
chmod 755 /var/empty || true

mkdir -p "$SSH_HOME" "$SSH_HOME/.ssh" /run/sshd /etc/ssh

chown "$SSH_UID:$SSH_GID" "$SSH_HOME" "$SSH_HOME/.ssh" || true
if [ "$SSH_UID" = "0" ]; then
  chmod 700 "$SSH_HOME" || true
else
  chmod 755 "$SSH_HOME" || true
fi
chmod 700 "$SSH_HOME/.ssh" || true

cat > "$SSH_HOME/.ssh/authorized_keys" <<'EOF'
${pubKey}
EOF
chown "$SSH_UID:$SSH_GID" "$SSH_HOME/.ssh/authorized_keys" || true
chmod 600 "$SSH_HOME/.ssh/authorized_keys"

# Generate host keys if missing
ssh-keygen -A >/dev/null 2>&1 || true

# Start sandboxssh if it's not already running (required for host-side TCP forwarding)
if ! ps | grep -q '[s]andboxssh'; then
  sandboxssh >/tmp/sandboxssh.log 2>&1 &
fi

# Start sshd bound to loopback only
#
# Don't try to be clever about whether it's already running; it's easy to
# accidentally match our own command line. Starting twice is harmless (it will fail
# to bind), and we validate by probing the port from the host.
/usr/sbin/sshd -D -e -p 22 \
  -o ListenAddress=127.0.0.1 \
  -o PasswordAuthentication=no \
  -o KbdInteractiveAuthentication=no \
  -o ChallengeResponseAuthentication=no \
  -o PubkeyAuthentication=yes \
  -o AllowUsers=$SSH_USER \
  -o AllowAgentForwarding=no \
  -o AllowTcpForwarding=no \
  -o X11Forwarding=no \
  -o PermitTunnel=no \
  -o PermitRootLogin=prohibit-password \
  -o PidFile=/run/sshd.pid \
  >/tmp/sshd.log 2>&1 &
`;
        const setupResult = await this.exec(["/bin/sh", "-lc", setupScript]);
        if (setupResult.exitCode !== 0 &&
            setupResult.exitCode !== 127 &&
            setupResult.exitCode !== 126 &&
            setupResult.exitCode !== 125) {
            throw new Error(`failed to configure ssh in guest (exit ${setupResult.exitCode}): ${setupResult.stderr.trim()}`);
        }
        if (setupResult.exitCode === 127) {
            throw new Error("sshd not available in guest image. Rebuild guest assets with openssh installed (default images should include it).");
        }
        if (setupResult.exitCode === 126) {
            throw new Error("sandboxssh not available in guest image. Rebuild guest assets to include sandboxssh.");
        }
        if (setupResult.exitCode === 125) {
            throw new Error(`ssh user '${user}' does not exist in guest image (vm.enableSsh({ user }))`);
        }
        // Verify that the virtio tcp-forwarder is working and that sshd is reachable.
        const server = this.server;
        if (!server) {
            throw new Error("sandbox server is not available");
        }
        const deadline = Date.now() + 10_000;
        let lastErr = null;
        while (Date.now() < deadline) {
            let probe = null;
            try {
                const stream = await server.openTcpStream({
                    host: "127.0.0.1",
                    port: 22,
                    timeoutMs: 2000,
                });
                probe = stream;
                // sshd sends its banner immediately after accepting a TCP connection.
                // Waiting for it makes enableSsh more reliable on slow boots.
                const banner = await new Promise((resolve, reject) => {
                    const onData = (chunk) => {
                        cleanup();
                        resolve(chunk.toString("utf8"));
                    };
                    const onError = (err) => {
                        cleanup();
                        reject(err);
                    };
                    const timeout = setTimeout(() => {
                        cleanup();
                        reject(new Error("ssh banner timeout"));
                    }, 1000);
                    const cleanup = () => {
                        clearTimeout(timeout);
                        stream.off("data", onData);
                        stream.off("error", onError);
                    };
                    stream.on("data", onData);
                    stream.on("error", onError);
                });
                if (!banner.startsWith("SSH-")) {
                    throw new Error(`unexpected ssh banner: ${JSON.stringify(banner.slice(0, 32))}`);
                }
                lastErr = null;
                break;
            }
            catch (err) {
                lastErr = err;
                await new Promise((r) => setTimeout(r, 150));
            }
            finally {
                probe?.destroy();
            }
        }
        if (lastErr) {
            const detail = lastErr instanceof Error ? lastErr.message : String(lastErr);
            throw new Error(`ssh port-forward is not available: ${detail}`);
        }
        // Create local forwarder
        const forwardServer = net.createServer((socket) => {
            socket.setNoDelay(true);
            // Ensure we always have an error handler; otherwise socket.destroy(err)
            // can turn into an uncaught exception.
            socket.on("error", () => {
                // ignore
            });
            void (async () => {
                const server = this.server;
                if (!server) {
                    socket.destroy();
                    return;
                }
                try {
                    const tunnel = await server.openTcpStream({
                        host: "127.0.0.1",
                        port: 22,
                    });
                    tunnel.on("error", () => socket.destroy());
                    socket.on("error", (err) => tunnel.destroy(err));
                    socket.pipe(tunnel).pipe(socket);
                }
                catch {
                    socket.destroy();
                }
            })();
        });
        await new Promise((resolve, reject) => {
            forwardServer.once("error", reject);
            forwardServer.listen({ host: listenHost, port: listenPort }, () => {
                forwardServer.off("error", reject);
                resolve();
            });
        });
        const addr = forwardServer.address();
        if (!addr || typeof addr === "string") {
            forwardServer.close();
            throw new Error("unexpected local forward server address");
        }
        const host = listenHost;
        const port = addr.port;
        const access = {
            host,
            port,
            user,
            identityFile: keyPath,
            command: `ssh -p ${port} -i ${keyPath} ` +
                `-o ForwardAgent=no -o ClearAllForwardings=yes -o IdentitiesOnly=yes ` +
                `-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${user}@${host}`,
            close: async () => {
                await new Promise((resolve) => forwardServer.close(() => resolve()));
                try {
                    fs.rmSync(tmpDir, { recursive: true, force: true });
                }
                catch {
                    // ignore
                }
                if (this.sshAccess === access) {
                    this.sshAccess = null;
                }
            },
        };
        this.sshAccess = access;
        return access;
    }
    /**
     * Get the current ingress routes (parsed from /etc/gondolin/listeners).
     */
    getIngressRoutes() {
        if (!this.gondolinEtc)
            return [];
        return this.gondolinEtc.listeners.getRoutes();
    }
    /**
     * Replace ingress routes and write the canonical /etc/gondolin/listeners file.
     */
    setIngressRoutes(routes) {
        if (!this.gondolinEtc) {
            throw new Error("/etc/gondolin mount is not available");
        }
        this.gondolinEtc.listeners.setRoutes(routes);
    }
    /**
     * Enable the host-side ingress gateway.
     *
     * The gateway listens on a single host port and routes requests to guest-local
     * HTTP servers as configured by /etc/gondolin/listeners.
     */
    async enableIngress(options = {}) {
        if (this.ingressAccess)
            return this.ingressAccess;
        await this.start();
        if (!this.gondolinEtc) {
            throw new Error("ingress requires the /etc/gondolin mount. Ensure VFS is enabled and that /etc/gondolin is not overridden by a custom mount.");
        }
        if (!this.server) {
            throw new Error("sandbox server is not available");
        }
        const gateway = new IngressGateway(this.server, this.gondolinEtc.listeners);
        const access = await gateway.listen(options);
        this.ingressAccess = access;
        return access;
    }
    execInternal(command, options) {
        const { cmd, argv } = normalizeCommand(command, options);
        const id = this.allocateId();
        const stdinSetting = options.stdin;
        const stdinEnabled = stdinSetting !== undefined && stdinSetting !== false;
        const stdout = resolveOutputMode(options.stdout, options.buffer, "stdout");
        const stderr = resolveOutputMode(options.stderr, options.buffer, "stderr");
        const session = createExecSession(id, {
            stdinEnabled,
            encoding: options.encoding,
            signal: options.signal,
            stdout,
            stderr,
            windowBytes: options.windowBytes,
        });
        // Setup abort handling
        if (options.signal) {
            const onAbort = () => {
                rejectExecSession(session, new Error("exec aborted"));
                this.sessions.delete(id);
            };
            options.signal.addEventListener("abort", onAbort, { once: true });
            session.signalListener = onAbort;
        }
        this.sessions.set(id, session);
        // Wire up credit-based flow control
        session.sendWindowUpdate = (stdoutBytes, stderrBytes) => {
            if (stdoutBytes <= 0 && stderrBytes <= 0)
                return;
            try {
                this.sendJson({
                    type: "exec_window",
                    id,
                    stdout: stdoutBytes > 0 ? stdoutBytes : undefined,
                    stderr: stderrBytes > 0 ? stderrBytes : undefined,
                });
            }
            catch {
                // ignore (e.g. connection closed)
            }
        };
        // Create the process handle
        const proc = new ExecProcess(session, {
            sendStdin: (id, data) => this.sendStdinData(id, data),
            sendStdinEof: (id) => this.sendStdinEof(id),
            sendResize: (id, rows, cols) => this.sendPtyResize(id, rows, cols),
            cleanup: (id) => this.sessions.delete(id),
        });
        // Start the command asynchronously
        this.startExec(id, cmd, argv, options, session, stdinSetting);
        return proc;
    }
    async startExec(id, cmd, argv, options, session, stdinSetting) {
        try {
            await this.start();
            const mergedEnv = mergeEnvInputs(this.defaultEnv, options.env);
            const message = {
                type: "exec",
                id,
                cmd,
                argv: argv.length ? argv : undefined,
                env: mergedEnv && mergedEnv.length ? mergedEnv : undefined,
                cwd: options.cwd,
                stdin: session.stdinEnabled ? true : undefined,
                pty: options.pty ? true : undefined,
                stdout_window: session.windowBytes,
                stderr_window: session.windowBytes,
            };
            this.sendJson(message);
            this.markSessionReady(session);
            // Pipe stdin if provided (and not just `true`)
            if (session.stdinEnabled &&
                stdinSetting !== true &&
                stdinSetting !== undefined) {
                void this.pipeStdin(id, stdinSetting, session);
            }
        }
        catch (err) {
            const error = err instanceof Error ? err : new Error(String(err));
            rejectExecSession(session, error);
            this.sessions.delete(id);
        }
    }
    ensureVmmAvailable() {
        if (this.vmmChecked)
            return;
        const server = this.server;
        if (!server) {
            throw new Error("sandbox server is not available");
        }
        const vmmPath = server.getVmmPath();
        execFileSync(vmmPath, ["--version"], { stdio: "ignore" });
        if (server.getVmmBackend() === "krun") {
            assertMacHypervisorEntitlement(vmmPath);
        }
        this.vmmChecked = true;
    }
    beginStartupGeneration() {
        this.startupGeneration += 1;
        return this.startupGeneration;
    }
    invalidateStartupGeneration(expectedGeneration) {
        if (expectedGeneration !== undefined &&
            this.startupGeneration !== expectedGeneration) {
            return null;
        }
        this.startupGeneration += 1;
        return this.startupGeneration;
    }
    ensureStartupGeneration(expectedGeneration) {
        if (this.startupGeneration === expectedGeneration)
            return;
        const error = new Error("vm startup was cancelled");
        error.code = "vm_start_cancelled";
        throw error;
    }
    async startInternal() {
        if (this.checkpointed) {
            throw new Error("vm was checkpointed and cannot be restarted; resume the checkpoint instead");
        }
        const startupGeneration = this.beginStartupGeneration();
        await this.withStartTimeout(async () => {
            this.ensureStartupGeneration(startupGeneration);
            this.ensureVmmAvailable();
            if (this.server) {
                await this.server.start();
            }
            this.ensureStartupGeneration(startupGeneration);
            await this.ensureConnection();
            this.ensureStartupGeneration(startupGeneration);
            await this.ensureRunning();
            this.ensureStartupGeneration(startupGeneration);
            await this.ensureRootfsResized();
            this.ensureStartupGeneration(startupGeneration);
            // If VFS is configured, also wait for mounts to be ready.
            await this.ensureVfsReady();
            this.ensureStartupGeneration(startupGeneration);
            await this.ensureSessionIpc(startupGeneration);
            this.ensureStartupGeneration(startupGeneration);
        }, "guest readiness", () => {
            const cleanupGeneration = this.invalidateStartupGeneration(startupGeneration);
            if (cleanupGeneration === null) {
                return;
            }
            setTimeout(() => {
                // A newer startup/close happened since this timeout fired.
                // Do not let stale timeout cleanup tear down current state.
                if (this.startupGeneration !== cleanupGeneration) {
                    return;
                }
                void this.close().catch(() => {
                    // ignore close errors after startup timeout
                });
            }, 0);
        });
    }
    async withStartTimeout(taskFactory, stage, onTimeout) {
        const timeoutMs = normalizeStartTimeoutMs(this.startTimeoutMs);
        if (timeoutMs <= 0)
            return taskFactory();
        let timer = null;
        try {
            return await Promise.race([
                taskFactory(),
                new Promise((_, reject) => {
                    timer = setTimeout(() => {
                        const timeoutError = new Error(`vm startup timed out after ${timeoutMs}ms while waiting for ${stage}`);
                        timeoutError.code = "vm_start_timeout";
                        reject(timeoutError);
                        if (onTimeout) {
                            queueMicrotask(() => {
                                try {
                                    onTimeout();
                                }
                                catch {
                                    // ignore timeout callback failures
                                }
                            });
                        }
                    }, timeoutMs);
                }),
            ]);
        }
        finally {
            if (timer)
                clearTimeout(timer);
        }
    }
    async ensureSessionIpc(startupGeneration) {
        if (this.sessionIpc)
            return;
        if (startupGeneration !== undefined) {
            this.ensureStartupGeneration(startupGeneration);
        }
        await gcSessions().catch(() => {
            // ignore gc failures
        });
        if (startupGeneration !== undefined) {
            this.ensureStartupGeneration(startupGeneration);
        }
        const { socketPath } = registerSession({
            id: this.id,
            label: this.sessionLabel,
        });
        let sessionIpc = null;
        try {
            sessionIpc = new SessionIpcServer(socketPath, (onMessage, onClose) => {
                const server = this.server;
                if (!server) {
                    throw new Error("sandbox server is not available");
                }
                return server.connect(onMessage, onClose);
            }, {
                onSnapshot: async (message) => {
                    const checkpoint = await this.checkpointInternal(message.path, {
                        keepSessionIpc: true,
                    });
                    return {
                        path: checkpoint.path,
                        name: checkpoint.name,
                        onResponseQueued: async () => {
                            await this.close();
                        },
                    };
                },
            });
            if (startupGeneration !== undefined) {
                this.ensureStartupGeneration(startupGeneration);
            }
            sessionIpc.start();
            if (startupGeneration !== undefined) {
                this.ensureStartupGeneration(startupGeneration);
            }
            this.sessionIpc = sessionIpc;
        }
        catch (err) {
            if (sessionIpc) {
                try {
                    await sessionIpc.close();
                }
                catch {
                    // ignore close errors
                }
            }
            unregisterSession(this.id);
            throw err;
        }
    }
    cleanupRootDiskSync() {
        if (!this.rootDisk?.deleteOnClose)
            return;
        try {
            fs.rmSync(this.rootDisk.path, { force: true });
        }
        catch {
            // ignore
        }
    }
    async closeInternal(options) {
        const keepSessionIpc = options?.keepSessionIpc ?? false;
        const keepSessionRegistration = options?.keepSessionRegistration ?? false;
        this.invalidateStartupGeneration();
        if (!keepSessionIpc && this.sessionIpc) {
            try {
                await this.sessionIpc.close();
            }
            catch {
                // ignore
            }
            finally {
                this.sessionIpc = null;
            }
        }
        if (!keepSessionRegistration) {
            unregisterSession(this.id);
        }
        if (this.ingressAccess) {
            try {
                await this.ingressAccess.close();
            }
            catch {
                // ignore
            }
            finally {
                this.ingressAccess = null;
            }
        }
        if (this.sshAccess) {
            try {
                await this.sshAccess.close();
            }
            catch {
                // ignore
            }
        }
        if (this.server) {
            await this.server.close();
        }
        if (this.vfs) {
            await this.vfs.close();
        }
        await this.disconnect();
        this.vfsReadyPromise = null;
        this.cleanupRootDiskSync();
    }
    allocateId() {
        for (let i = 0; i <= MAX_REQUEST_ID; i += 1) {
            const id = this.nextId;
            this.nextId = this.nextId + 1;
            if (this.nextId > MAX_REQUEST_ID)
                this.nextId = 1;
            if (!this.sessions.has(id))
                return id;
        }
        throw new Error("no available request ids");
    }
    async pipeStdin(id, input, session) {
        if (!session.stdinEnabled)
            return;
        try {
            if (typeof input === "string" || Buffer.isBuffer(input)) {
                this.sendStdinData(id, input);
                this.sendStdinEof(id);
            }
            else if (typeof input === "boolean") {
                // no-op for `true`
            }
            else {
                for await (const chunk of toAsyncIterable(input)) {
                    if (!this.sessions.has(id))
                        return;
                    this.sendStdinData(id, chunk);
                }
                if (this.sessions.has(id)) {
                    this.sendStdinEof(id);
                }
            }
        }
        catch (err) {
            const error = err instanceof Error ? err : new Error(String(err));
            rejectExecSession(session, error);
            this.sessions.delete(id);
        }
    }
    markSessionReady(session) {
        if (session.requestReady)
            return;
        session.requestReady = true;
        if (session.pendingResize) {
            const { rows, cols } = session.pendingResize;
            session.pendingResize = null;
            this.sendPtyResizeNow(session.id, rows, cols);
        }
        if (session.pendingStdin.length > 0) {
            const pending = session.pendingStdin;
            session.pendingStdin = [];
            for (const item of pending) {
                if (item.type === "data") {
                    this.sendStdinDataNow(session.id, item.data);
                }
                else {
                    this.sendStdinEofNow(session.id);
                }
            }
        }
    }
    sendStdinData(id, data) {
        const session = this.sessions.get(id);
        if (!session)
            return;
        if (!session.requestReady) {
            session.pendingStdin.push({ type: "data", data });
            return;
        }
        this.sendStdinDataNow(id, data);
    }
    sendStdinEof(id) {
        const session = this.sessions.get(id);
        if (!session)
            return;
        if (!session.requestReady) {
            session.pendingStdin.push({ type: "eof" });
            return;
        }
        this.sendStdinEofNow(id);
    }
    sendStdinDataNow(id, data) {
        const payload = typeof data === "string" ? Buffer.from(data) : Buffer.from(data);
        for (let offset = 0; offset < payload.length; offset += DEFAULT_STDIN_CHUNK) {
            const slice = payload.subarray(offset, offset + DEFAULT_STDIN_CHUNK);
            this.sendJson({
                type: "stdin",
                id,
                data: slice.toString("base64"),
            });
        }
    }
    sendStdinEofNow(id) {
        this.sendJson({
            type: "stdin",
            id,
            eof: true,
        });
    }
    sendPtyResize(id, rows, cols) {
        if (!Number.isFinite(rows) || !Number.isFinite(cols))
            return;
        const session = this.sessions.get(id);
        if (!session)
            return;
        const safeRows = Math.max(1, Math.trunc(rows));
        const safeCols = Math.max(1, Math.trunc(cols));
        if (!session.requestReady) {
            session.pendingResize = { rows: safeRows, cols: safeCols };
            return;
        }
        this.sendPtyResizeNow(id, safeRows, safeCols);
    }
    sendPtyResizeNow(id, rows, cols) {
        if (!this.connection)
            return;
        this.sendJson({
            type: "pty_resize",
            id,
            rows,
            cols,
        });
    }
    async ensureConnection() {
        if (this.connection)
            return;
        if (this.connectPromise)
            return this.connectPromise;
        const server = this.server;
        if (!server) {
            throw new Error("sandbox server is not available");
        }
        this.resetConnectionState();
        this.connectPromise = (async () => {
            await server.start();
            this.connection = server.connect((data, isBinary) => {
                this.handleMessage(data, isBinary);
            }, () => {
                this.handleDisconnect(new Error("sandbox connection closed"));
            });
        })().finally(() => {
            this.connectPromise = null;
        });
        return this.connectPromise;
    }
    resetConnectionState() {
        this.state = "unknown";
        this.bootSent = false;
        this.vfsReadyPromise = null;
        this.initStatusPromise();
    }
    initStatusPromise() {
        this.statusPromise = new Promise((resolve, reject) => {
            this.statusResolve = resolve;
            this.statusReject = reject;
        });
    }
    ensureBoot() {
        if (this.bootSent)
            return;
        this.bootSent = true;
        this.state = "unknown";
        this.initStatusPromise();
        this.sendJson({
            type: "boot",
            fuseMount: this.fuseMount,
            fuseBinds: this.fuseBinds,
        });
    }
    async ensureRunning() {
        const state = await this.waitForStatus();
        if (state === "stopped" && !this.autoStart) {
            throw new Error("sandbox is stopped");
        }
        this.ensureBoot();
        const nextState = await this.waitForStatus();
        if (nextState === "running")
            return;
        await this.waitForState("running");
    }
    async ensureRootfsResized() {
        if (!this.rootfsGuestResizePending || this.rootfsGuestResizeDone)
            return;
        const result = await this.execInternalNoVfsWait([
            "/bin/sh",
            "-c",
            [
                "if ! command -v resize2fs >/dev/null 2>&1; then",
                "  echo 'rootfs.size requires resize2fs in the guest image (install e2fsprogs)' >&2;",
                "  exit 127;",
                "fi;",
                "resize2fs /dev/vda",
            ].join("\n"),
        ]);
        if (result.exitCode !== 0) {
            const detail = (result.stderr || result.stdout).trim();
            const suffix = detail ? `: ${detail}` : "";
            throw new Error(`failed to resize rootfs inside guest (exit ${result.exitCode})${suffix}`);
        }
        this.rootfsGuestResizeDone = true;
    }
    async ensureVfsReady() {
        if (!this.vfs)
            return;
        if (!this.vfsReadyPromise) {
            this.vfsReadyPromise = this.waitForVfsReadyInternal().catch((error) => {
                this.vfsReadyPromise = null;
                throw error;
            });
        }
        await this.vfsReadyPromise;
    }
    async waitForVfsReadyInternal() {
        await this.waitForMount(this.fuseMount, "fuse.sandboxfs");
        for (const mountPoint of this.fuseBinds) {
            await this.waitForBindMount(mountPoint);
        }
    }
    async waitForMount(mountPoint, fsType) {
        const mountCheck = fsType
            ? `grep -q " $1 ${fsType} " /proc/mounts`
            : `grep -q " $1 " /proc/mounts`;
        const script = `for i in $(seq 1 ${VFS_READY_ATTEMPTS}); do ${mountCheck} && exit 0; sleep ${VFS_READY_SLEEP_SECONDS}; done; exit 1`;
        // Use internal exec that bypasses VFS check
        const result = await this.execInternalNoVfsWait([
            "/bin/sh",
            "-c",
            script,
            "sh",
            mountPoint,
        ]);
        if (result.exitCode !== 0) {
            throw new Error(`vfs mount ${mountPoint} not ready (exit ${result.exitCode}): ${result.stderr.trim()}`);
        }
    }
    async waitForBindMount(mountPoint) {
        if (mountPoint === this.fuseMount)
            return;
        if (this.fuseMount === "/") {
            await this.waitForPath(mountPoint);
            return;
        }
        const source = `${this.fuseMount}${mountPoint}`;
        const script = `for i in $(seq 1 ${VFS_READY_ATTEMPTS}); do if grep -q " $1 " /proc/mounts; then exit 0; fi; mkdir -p "$1"; mount --bind "$2" "$1" > /dev/null 2>&1 || true; sleep ${VFS_READY_SLEEP_SECONDS}; done; exit 1`;
        const result = await this.execInternalNoVfsWait([
            "/bin/sh",
            "-c",
            script,
            "sh",
            mountPoint,
            source,
        ]);
        if (result.exitCode !== 0) {
            throw new Error(`vfs mount ${mountPoint} not ready (exit ${result.exitCode}): ${result.stderr.trim()}`);
        }
    }
    async waitForPath(entryPath) {
        const script = `for i in $(seq 1 ${VFS_READY_ATTEMPTS}); do [ -e "$1" ] && exit 0; sleep ${VFS_READY_SLEEP_SECONDS}; done; exit 1`;
        const result = await this.execInternalNoVfsWait([
            "/bin/sh",
            "-c",
            script,
            "sh",
            entryPath,
        ]);
        if (result.exitCode !== 0) {
            throw new Error(`vfs path ${entryPath} not ready (exit ${result.exitCode}): ${result.stderr.trim()}`);
        }
    }
    async execInternalNoVfsWait(command) {
        const { cmd, argv } = normalizeCommand(command, {});
        const id = this.allocateId();
        const session = createExecSession(id, {
            stdinEnabled: false,
            stdout: { mode: "buffer" },
            stderr: { mode: "buffer" },
        });
        this.sessions.set(id, session);
        session.sendWindowUpdate = (stdoutBytes, stderrBytes) => {
            if (stdoutBytes <= 0 && stderrBytes <= 0)
                return;
            try {
                this.sendJson({
                    type: "exec_window",
                    id,
                    stdout: stdoutBytes > 0 ? stdoutBytes : undefined,
                    stderr: stderrBytes > 0 ? stderrBytes : undefined,
                });
            }
            catch {
                // ignore
            }
        };
        const message = {
            type: "exec",
            id,
            cmd,
            argv: argv.length ? argv : undefined,
            stdout_window: session.windowBytes,
            stderr_window: session.windowBytes,
        };
        try {
            this.sendJson(message);
            this.markSessionReady(session);
        }
        catch (err) {
            const error = err instanceof Error ? err : new Error(String(err));
            this.sessions.delete(id);
            rejectExecSession(session, error);
        }
        return session.resultPromise;
    }
    async waitForStatus() {
        if (this.state !== "unknown")
            return this.state;
        if (!this.statusPromise) {
            this.initStatusPromise();
        }
        return this.statusPromise;
    }
    waitForState(state) {
        if (this.state === state)
            return Promise.resolve();
        return new Promise((resolve, reject) => {
            this.stateWaiters.push({ state, resolve, reject });
        });
    }
    handleMessage(data, isBinary) {
        if (isBinary) {
            const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data);
            const frame = decodeOutputFrame(buffer);
            const session = this.sessions.get(frame.id);
            if (!session)
                return;
            applyOutputChunk(session, frame.stream, frame.data);
            return;
        }
        let message;
        try {
            message = JSON.parse(typeof data === "string" ? data : data.toString());
        }
        catch {
            return;
        }
        if (message.type === "status") {
            this.updateState(message.state);
            return;
        }
        if (message.type === "exec_response") {
            this.handleExecResponse(message);
            return;
        }
        if (message.type === "error") {
            this.handleError(message);
        }
    }
    updateState(state) {
        this.state = state;
        if (this.statusResolve) {
            this.statusResolve(state);
            this.statusResolve = null;
            this.statusReject = null;
            this.statusPromise = null;
        }
        if (this.stateWaiters.length > 0) {
            const remaining = [];
            for (const waiter of this.stateWaiters) {
                if (waiter.state === state) {
                    waiter.resolve();
                }
                else {
                    remaining.push(waiter);
                }
            }
            this.stateWaiters = remaining;
        }
    }
    handleExecResponse(message) {
        const session = this.sessions.get(message.id);
        if (!session)
            return;
        this.sessions.delete(message.id);
        finishExecSession(session, message.exit_code ?? 1, message.signal);
    }
    handleError(message) {
        const error = new Error(`error ${message.code}: ${message.message}`);
        if (message.id === undefined) {
            if (this.statusReject) {
                this.statusReject(error);
                this.statusReject = null;
                this.statusResolve = null;
                this.statusPromise = null;
            }
            if (this.stateWaiters.length > 0) {
                for (const waiter of this.stateWaiters) {
                    waiter.reject(error);
                }
                this.stateWaiters = [];
            }
            this.rejectAll(error);
            return;
        }
        const session = this.sessions.get(message.id);
        if (session) {
            this.sessions.delete(message.id);
            rejectExecSession(session, error);
        }
    }
    rejectAll(error) {
        for (const session of this.sessions.values()) {
            rejectExecSession(session, error);
        }
        this.sessions.clear();
    }
    handleDisconnect(error) {
        this.connection = null;
        const disconnectError = error ?? new Error("sandbox connection disconnected");
        if (this.statusReject) {
            this.statusReject(disconnectError);
            this.statusReject = null;
            this.statusResolve = null;
            this.statusPromise = null;
        }
        if (this.stateWaiters.length > 0) {
            for (const waiter of this.stateWaiters) {
                waiter.reject(disconnectError);
            }
            this.stateWaiters = [];
        }
        this.rejectAll(disconnectError);
    }
    async disconnect() {
        if (!this.connection)
            return;
        const connection = this.connection;
        this.connection = null;
        connection.close();
    }
    /**
     * Create a disk-only checkpoint of the VM root disk.
     *
     * This stops the VM and materializes its writable qcow2 overlay at
     * `checkpointPath`.
     *
     * The checkpoint metadata is stored as a JSON trailer appended to the qcow2
     * file so the checkpoint is a single file.
     */
    async checkpoint(checkpointPath) {
        return await this.checkpointInternal(checkpointPath, {
            keepSessionIpc: false,
        });
    }
    async checkpointInternal(checkpointPath, options) {
        if (!checkpointPath) {
            throw new Error("checkpointPath is required");
        }
        if (!path.isAbsolute(checkpointPath)) {
            throw new Error(`checkpointPath must be an absolute path (got: ${checkpointPath})`);
        }
        const rootDisk = this.rootDisk;
        if (!rootDisk) {
            throw new Error("vm has no root disk");
        }
        if (rootDisk.snapshot) {
            throw new Error("cannot checkpoint: root disk is running in ephemeral snapshot mode");
        }
        if (rootDisk.format !== "qcow2") {
            throw new Error(`cannot checkpoint: root disk must be qcow2 (got ${rootDisk.format})`);
        }
        // Ensure the disk isn't deleted by close().
        rootDisk.deleteOnClose = false;
        // Best-effort flush of guest filesystem buffers so the checkpoint captures
        // recent writes even though we currently stop QEMU abruptly.
        if (this.server && this.server.getState() === "running") {
            try {
                await this.exec(["/bin/sh", "-c", "sync; sync"]);
            }
            catch {
                // ignore
            }
        }
        if (options.keepSessionIpc) {
            await this.closeSingleflight.run(() => this.closeInternal({
                keepSessionIpc: true,
                keepSessionRegistration: true,
            }));
        }
        else {
            await this.close();
        }
        const resolvedCheckpointPath = path.resolve(checkpointPath);
        const rootfsPath = path.resolve(this.resolvedSandboxOptions.rootfsPath);
        const backingPath = resolveQcow2BackingPath(rootDisk.path);
        if (backingPath && backingPath !== rootfsPath) {
            // Collapse resume-generated checkpoint ancestry before we publish this
            // overlay as the new checkpoint file.
            ensureQemuImgAvailable();
            rebaseQcow2InPlace(rootDisk.path, rootfsPath, inferDiskFormatFromPath(rootfsPath), "safe");
            const rebasedBackingPath = resolveQcow2BackingPath(rootDisk.path);
            if (rebasedBackingPath === resolvedCheckpointPath) {
                throw new Error(`cannot checkpoint: rebased overlay still points to destination checkpoint path (${resolvedCheckpointPath})`);
            }
        }
        fs.mkdirSync(path.dirname(resolvedCheckpointPath), { recursive: true });
        fs.rmSync(resolvedCheckpointPath, { force: true });
        moveFile(rootDisk.path, resolvedCheckpointPath);
        const checkpointName = path.basename(resolvedCheckpointPath, path.extname(resolvedCheckpointPath));
        const guestAssets = {
            kernelPath: this.resolvedSandboxOptions.kernelPath,
            initrdPath: this.resolvedSandboxOptions.initrdPath,
            rootfsPath: this.resolvedSandboxOptions.rootfsPath,
        };
        const commonDir = path.dirname(guestAssets.kernelPath) ===
            path.dirname(guestAssets.initrdPath) &&
            path.dirname(guestAssets.kernelPath) ===
                path.dirname(guestAssets.rootfsPath)
            ? path.dirname(guestAssets.kernelPath)
            : null;
        const manifest = commonDir ? loadAssetManifest(commonDir) : null;
        const guestAssetBuildId = manifest?.buildId;
        if (!guestAssetBuildId) {
            throw new Error("cannot checkpoint: guest assets are missing manifest buildId (rebuild guest assets with a newer gondolin build)");
        }
        const createdWithVmm = this.resolvedSandboxOptions.vmm;
        const compatibleVmm = manifest?.assets?.krunKernel || createdWithVmm === "krun"
            ? ["qemu", "krun"]
            : ["qemu"];
        const data = {
            version: 1,
            name: checkpointName,
            createdAt: new Date().toISOString(),
            // Kept for schema compatibility (ignored for single-file checkpoints)
            diskFile: path.basename(resolvedCheckpointPath),
            guestAssetBuildId,
            snapshotKind: "disk",
            createdWithVmm,
            compatibleVmm,
        };
        VmCheckpoint.writeTrailer(resolvedCheckpointPath, data);
        // Mark this VM as consumed.
        this.rootDisk = null;
        this.checkpointed = true;
        return new VmCheckpoint(resolvedCheckpointPath, data, this.baseOptionsForClone);
    }
    sendJson(message) {
        if (!this.connection) {
            throw new Error("sandbox connection is not available");
        }
        this.connection.send(message);
    }
}
registerVmCreate((options) => VM.create(options));
function installRootDisk(resolved, rootDisk) {
    resolved.rootDiskPath = rootDisk.path;
    resolved.rootDiskFormat = rootDisk.format;
    resolved.rootDiskReadOnly = rootDisk.readOnly;
    return rootDisk;
}
function prepareConfiguredRootDisk(resolved, options) {
    const rootDiskPath = options.rootDiskPath ?? resolved.rootDiskPath;
    const deleteOnClose = options.rootDiskDeleteOnClose ?? false;
    if (deleteOnClose &&
        options.rootDiskPath === undefined &&
        rootDiskPath === resolved.rootfsPath) {
        throw new Error("sandbox.rootDiskDeleteOnClose requires sandbox.rootDiskPath (refusing to delete base rootfs)");
    }
    return installRootDisk(resolved, {
        path: rootDiskPath,
        format: options.rootDiskFormat ??
            resolved.rootDiskFormat ??
            inferDiskFormatFromPath(rootDiskPath),
        snapshot: false,
        readOnly: options.rootDiskReadOnly ?? resolved.rootDiskReadOnly ?? false,
        deleteOnClose,
    });
}
function prepareBaseRootDisk(resolved, opts) {
    return installRootDisk(resolved, {
        path: resolved.rootfsPath,
        format: inferDiskFormatFromPath(resolved.rootfsPath),
        snapshot: opts.snapshot,
        readOnly: opts.readOnly,
        deleteOnClose: false,
    });
}
function prepareOverlayRootDisk(resolved) {
    ensureQemuImgAvailable();
    return installRootDisk(resolved, {
        path: createTempQcow2Overlay(resolved.rootfsPath, inferDiskFormatFromPath(resolved.rootfsPath)),
        format: "qcow2",
        snapshot: false,
        readOnly: false,
        deleteOnClose: true,
    });
}
function isSameExistingFile(a, b) {
    const resolvedA = path.resolve(a);
    const resolvedB = path.resolve(b);
    if (resolvedA === resolvedB)
        return true;
    try {
        const statA = fs.statSync(resolvedA);
        const statB = fs.statSync(resolvedB);
        return statA.dev === statB.dev && statA.ino === statB.ino;
    }
    catch {
        return false;
    }
}
function prepareRootDiskResize(rootDisk, rootfsPath, sizeBytes) {
    if (!rootDisk) {
        throw new Error("rootfs.size requires a root disk");
    }
    if (rootDisk.readOnly) {
        throw new Error("rootfs.size requires a writable root disk (rootfs.mode cannot be readonly)");
    }
    if (isSameExistingFile(rootDisk.path, rootfsPath)) {
        throw new Error("rootfs.size refuses to resize the base rootfs image; use rootfs.mode='cow' or provide a separate sandbox.rootDiskPath");
    }
    ensureQemuImgAvailable();
    ensureDiskImageMinimumSize(rootDisk.path, sizeBytes);
}
function resolveManifestRootfsMode(resolved) {
    const kernelDir = path.dirname(resolved.kernelPath);
    const initrdDir = path.dirname(resolved.initrdPath);
    const rootfsDir = path.dirname(resolved.rootfsPath);
    if (kernelDir !== initrdDir || kernelDir !== rootfsDir) {
        return undefined;
    }
    const manifest = loadAssetManifest(kernelDir);
    const mode = manifest?.runtimeDefaults?.rootfsMode;
    return isRootfsMode(mode) ? mode : undefined;
}
function resolveVmVfs(options, injectedMounts) {
    if (options === null) {
        return { provider: null, mounts: {} };
    }
    const hooks = options?.hooks ?? {};
    const mounts = {
        ...(options?.mounts ?? {}),
    };
    if (injectedMounts) {
        for (const [mountPath, provider] of Object.entries(injectedMounts)) {
            if (!(mountPath in mounts)) {
                mounts[mountPath] = provider;
            }
        }
    }
    const mountKeys = Object.keys(mounts);
    if (mountKeys.length === 0) {
        return { provider: wrapProvider(new MemoryProvider(), hooks), mounts };
    }
    const normalized = normalizeMountMap(mounts);
    let provider;
    if (normalized.size === 1 && normalized.has("/")) {
        provider = normalized.get("/");
    }
    else {
        provider = new MountRouterProvider(normalized);
    }
    return { provider: wrapProvider(provider, hooks), mounts };
}
function resolveFuseConfig(options, mounts) {
    const fuseMount = normalizeMountPath(options?.fuseMount ?? "/data");
    const mountPaths = listMountPaths(mounts ?? options?.mounts);
    const fuseBinds = mountPaths.filter((mountPath) => mountPath !== "/");
    return { fuseMount, fuseBinds };
}
/** @internal */
// Expose internal helpers for unit tests. Not part of the public API.
export const __test = {
    normalizeCommand,
    resolveVmVfs,
    resolveFuseConfig,
    resolveMitmMounts,
    createMitmCaProvider,
    composeVfsHooks,
    buildShellEnv,
    mergeEnvInputs,
    envInputToEntries,
    parseEnvEntry,
    mapToEnvArray,
    normalizeStartTimeoutMs,
    parseDiskSizeToBytes,
};
//# sourceMappingURL=core.js.map