// Command execution — the spawn boundary. Deny-by-default allowlist is checked BEFORE any
// spawn; the child runs with a clean name-allowlisted env, no shell, and a resolved-in-workspace
// cwd. Timeout and abort both kill the process group (SIGTERM→SIGKILL after the grace period).
// stdout/stderr are byte-capped and redacted before they leave this layer (ADR-0006 D3/D5).
//
// node:child_process is imported ONLY for the default SpawnFn adapter; all decision logic lives
// in sandbox.ts (pure). Tests inject a fake SpawnFn for the allowlist/timeout/cancel paths and a
// real `node`-spawn for the env-isolation / no-shell / real-cancellation integration cases.
import { spawn as nodeSpawn } from "node:child_process";
import { accessSync, constants, mkdtempSync, realpathSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { delimiter, join, resolve as resolvePath } from "node:path";
import { redact } from "@oscharko-dev/keiko-security";
import { containedRealPathInfo, isDenied, isWithinWorkspace, PathDeniedError, resolveWithinWorkspace, } from "@oscharko-dev/keiko-workspace";
import { nodeWorkspaceFs } from "@oscharko-dev/keiko-workspace/internal/fs";
import { CommandCancelledError, CommandDeniedError, CommandTimeoutError } from "./errors.js";
import { buildSandboxEnv, collectSensitiveEnvValues, isCommandAllowed } from "./sandbox.js";
export const nodeSpawnFn = (command, args, options) => nodeSpawn(command, [...args], options);
export const nodeHomeProvider = {
    make: () => mkdtempSync(join(tmpdir(), "keiko-home-")),
    cleanup: (dir) => {
        // Best-effort: a leftover temp dir is not worth failing or rejecting the command over.
        rmSync(dir, { recursive: true, force: true });
    },
};
const POSIX = process.platform !== "win32";
// Kills the whole process group on POSIX (negative pid) so orphaned grandchildren die too;
// on Windows, best-effort child.kill() (a tree-kill needs a dependency we cannot add).
function killGroup(child, sig) {
    const pid = child.pid;
    if (pid === undefined) {
        return;
    }
    try {
        if (POSIX) {
            process.kill(-pid, sig);
        }
        else {
            child.kill(sig);
        }
    }
    catch {
        // The child already exited; nothing to signal. Swallowing here keeps termination idempotent.
    }
}
const TRUNCATED_OUTPUT_MARKER = "[TRUNCATED OUTPUT REDACTED]";
function hasPathSeparator(value) {
    return value.includes("/") || value.includes("\\");
}
function hasNul(value) {
    return value.includes("\u0000");
}
function realRoot(fs, root) {
    try {
        return fs.realPath(root);
    }
    catch {
        return root;
    }
}
function assertBareExecutable(command) {
    if (command.length === 0 || hasNul(command) || hasPathSeparator(command)) {
        throw new CommandDeniedError("executable must be a bare PATH-resolved name", command);
    }
}
function pathEntries(processEnv) {
    const pathValue = processEnv.PATH ?? "";
    return pathValue.length === 0 ? [] : pathValue.split(delimiter).filter(Boolean);
}
function executableExtensions(processEnv) {
    if (process.platform !== "win32") {
        return [""];
    }
    return (processEnv.PATHEXT ?? ".EXE;.CMD;.BAT;.COM")
        .split(";")
        .filter((value) => value.length > 0);
}
function candidateExecutable(command, rawEntry, ext) {
    const candidate = resolvePath(resolvePath(rawEntry), command + ext);
    try {
        accessSync(candidate, constants.X_OK);
        return { path: candidate, real: realpathSync(candidate) };
    }
    catch {
        return undefined;
    }
}
function assertExecutableOutsideWorkspace(command, lexicalWorkspaceRoot, realWorkspaceRoot, candidate) {
    if (isWithinWorkspace(lexicalWorkspaceRoot, candidate.path) ||
        isWithinWorkspace(realWorkspaceRoot, candidate.real)) {
        throw new CommandDeniedError(`executable resolves inside workspace: ${command}`, command);
    }
}
function defaultResolveExecutable(command, deps) {
    assertBareExecutable(command);
    const fs = deps.fs ?? nodeWorkspaceFs;
    const lexicalWorkspaceRoot = deps.workspace.root;
    const realWorkspaceRoot = realRoot(fs, lexicalWorkspaceRoot);
    for (const rawEntry of pathEntries(deps.processEnv)) {
        for (const ext of executableExtensions(deps.processEnv)) {
            const candidate = candidateExecutable(command, rawEntry, ext);
            if (candidate === undefined) {
                continue;
            }
            assertExecutableOutsideWorkspace(command, lexicalWorkspaceRoot, realWorkspaceRoot, candidate);
            return candidate.real;
        }
    }
    throw new CommandDeniedError(`executable not found on PATH: ${command}`, command);
}
function appendCapped(buffers, sink, chunk, max) {
    if (buffers.truncated) {
        return false;
    }
    const remaining = max - buffers.total;
    if (chunk.length <= remaining) {
        sink.push(chunk);
        buffers.total += chunk.length;
        return false;
    }
    if (remaining > 0) {
        sink.push(chunk.subarray(0, remaining));
        buffers.total = max;
    }
    buffers.truncated = true;
    return true; // signals the caller to kill the child (flood protection)
}
// Resolves the validated cwd. Lexical containment first, then symlink containment via realpath
// (S-H1): a cwd that is a symlink escaping the root or resolving into an always-denied path must
// not become the spawn cwd. Both cases surface as workspace path errors, which the host maps to a
// tool error — the command never spawns.
function resolveCwd(deps, cwd) {
    const lexical = resolveWithinWorkspace(deps.workspace.root, cwd ?? ".");
    const fs = deps.fs ?? nodeWorkspaceFs;
    const rel = lexical.slice(deps.workspace.root.length).replace(/^[/\\]/, "");
    if (isDenied(rel === "" ? (cwd ?? ".") : rel)) {
        throw new PathDeniedError("path matches an always-on deny pattern", cwd ?? ".");
    }
    const info = containedRealPathInfo(fs, deps.workspace.root, lexical);
    if (isDenied(info.realRelative)) {
        throw new PathDeniedError("path matches an always-on deny pattern", cwd ?? ".");
    }
    return lexical;
}
function buildResult(input, buffers, state, exitCode, termSignal, deps, startedAt) {
    const secrets = collectSensitiveEnvValues(deps.processEnv, deps.policy.envAllowlist);
    if (buffers.truncated) {
        return {
            command: input.command,
            args: input.args,
            exitCode,
            signal: termSignal,
            stdout: TRUNCATED_OUTPUT_MARKER,
            stderr: TRUNCATED_OUTPUT_MARKER,
            durationMs: deps.now() - startedAt,
            timedOut: state.timedOut,
            truncated: true,
        };
    }
    return {
        command: input.command,
        args: input.args,
        exitCode,
        signal: termSignal,
        stdout: redact(Buffer.concat(buffers.out).toString("utf8"), secrets),
        stderr: redact(Buffer.concat(buffers.err).toString("utf8"), secrets),
        durationMs: deps.now() - startedAt,
        timedOut: state.timedOut,
        truncated: buffers.truncated,
    };
}
function cleanup(state, signal) {
    if (state.timer !== undefined) {
        clearTimeout(state.timer);
    }
    if (state.graceTimer !== undefined) {
        clearTimeout(state.graceTimer);
    }
    if (state.onAbort !== undefined) {
        signal.removeEventListener("abort", state.onAbort);
    }
    // Remove the ephemeral HOME exactly once, on whichever settle path fires first (C5).
    if (!state.homeCleaned && state.home !== undefined && state.homeDir !== undefined) {
        state.homeCleaned = true;
        state.home.cleanup(state.homeDir);
    }
}
// Escalates from SIGTERM to SIGKILL after the grace period so a child ignoring SIGTERM is still
// guaranteed to terminate within terminationGraceMs of the trigger.
function terminate(child, policy, state) {
    killGroup(child, "SIGTERM");
    state.graceTimer = setTimeout(() => {
        killGroup(child, "SIGKILL");
    }, policy.terminationGraceMs);
    state.graceTimer.unref();
}
function wireStreams(child, buffers, policy, state) {
    const onData = (sink) => (chunk) => {
        if (appendCapped(buffers, sink, chunk, policy.maxOutputBytes)) {
            terminate(child, policy, state); // output flood → kill
        }
    };
    child.stdout?.on("data", onData(buffers.out));
    child.stderr?.on("data", onData(buffers.err));
}
function settleOnClose(ctx, resolve, reject) {
    ctx.child.on("close", (code, signalName) => {
        if (ctx.state.settled) {
            return;
        }
        ctx.state.settled = true;
        cleanup(ctx.state, ctx.input.signal);
        if (ctx.state.timedOut) {
            reject(new CommandTimeoutError("command timed out", timeoutOf(ctx)));
            return;
        }
        if (ctx.input.signal.aborted) {
            reject(new CommandCancelledError("command cancelled"));
            return;
        }
        resolve(buildResult(ctx.input, ctx.buffers, ctx.state, code, signalName, ctx.deps, ctx.startedAt));
    });
    ctx.child.on("error", (error) => {
        if (ctx.state.settled) {
            return;
        }
        ctx.state.settled = true;
        cleanup(ctx.state, ctx.input.signal);
        reject(error);
    });
}
function timeoutOf(ctx) {
    return ctx.input.timeoutMs ?? ctx.deps.policy.defaultTimeoutMs;
}
function armTimersAndAbort(ctx) {
    const ms = timeoutOf(ctx);
    ctx.state.timer = setTimeout(() => {
        ctx.state.timedOut = true;
        terminate(ctx.child, ctx.deps.policy, ctx.state);
    }, ms);
    ctx.state.timer.unref();
    const onAbort = () => {
        terminate(ctx.child, ctx.deps.policy, ctx.state);
    };
    ctx.state.onAbort = onAbort;
    if (ctx.input.signal.aborted) {
        onAbort();
    }
    else {
        ctx.input.signal.addEventListener("abort", onAbort, { once: true });
    }
}
function asError(error, fallback) {
    return error instanceof Error ? error : new Error(fallback);
}
function validateRunCommandInput(input, deps) {
    if (!Array.isArray(deps.policy.envAllowlist) || deps.policy.envAllowlist.length === 0) {
        throw new CommandDeniedError("sandbox envAllowlist must be a non-empty array", input.command);
    }
    const decision = isCommandAllowed(deps.commandRules, input.command, input.args);
    if (!decision.allowed) {
        throw new CommandDeniedError(decision.reason ?? "command denied", input.command);
    }
}
function resolveExecutable(input, deps) {
    const resolver = deps.resolveExecutable ?? defaultResolveExecutable;
    return resolver(input.command, {
        workspace: deps.workspace,
        processEnv: deps.processEnv,
        fs: deps.fs,
    });
}
function createRunState(home, homeDir) {
    return {
        settled: false,
        timedOut: false,
        timer: undefined,
        graceTimer: undefined,
        onAbort: undefined,
        home,
        homeDir,
        homeCleaned: false,
    };
}
function spawnChild(input, deps, executable, cwd, env, state) {
    try {
        return deps.spawn(executable, input.args, { cwd, env, shell: false, detached: POSIX });
    }
    catch (error) {
        cleanup(state, input.signal);
        throw asError(error, "spawn failed");
    }
}
function runSpawnedChild(ctx) {
    return new Promise((resolve, reject) => {
        wireStreams(ctx.child, ctx.buffers, ctx.deps.policy, ctx.state);
        settleOnClose(ctx, resolve, reject);
        armTimersAndAbort(ctx);
    });
}
// Runs an allowlisted command. Rejects with CommandDeniedError (before spawn) for a denied
// command or a workspace-escaping cwd (PathEscapeError), CommandTimeoutError on timeout, and
// CommandCancelledError on abort; otherwise resolves a redacted, byte-capped CommandResult. All
// failure paths are Promise rejections — the function never throws synchronously.
export function runCommand(input, deps) {
    try {
        validateRunCommandInput(input, deps);
        const executable = resolveExecutable(input, deps);
        const cwd = resolveCwd(deps, input.cwd);
        const env = buildSandboxEnv(deps.processEnv, deps.policy.envAllowlist);
        const home = deps.home ?? nodeHomeProvider;
        const homeDir = home.make();
        env.HOME = homeDir;
        env.USERPROFILE = homeDir;
        const state = createRunState(home, homeDir);
        const child = spawnChild(input, deps, executable, cwd, env, state);
        const buffers = { out: [], err: [], total: 0, truncated: false };
        const ctx = { child, input, deps, buffers, state, startedAt: deps.now() };
        return runSpawnedChild(ctx);
    }
    catch (error) {
        return Promise.reject(asError(error, "command execution failed"));
    }
}
