/**
 * Pure argv helpers for `--device` passthrough — build the remote `agents …`
 * invocation and strip the local-only routing flags before forwarding.
 *
 * Kept free of any SSH/process side effects so the two-layer quoting and the
 * flag-stripping edge cases (glued short forms, `=value`, variadic) are unit
 * testable without a live host. The transport itself lives in `ssh-exec.ts`
 * (`sshExec`/`sshStream`); orchestration lives in `passthrough.ts`.
 */
import { shellQuote } from '../ssh-exec.js';
/**
 * Remove routing flags (and their values) from a command's args, leaving the
 * rest untouched and in order so they forward verbatim to the remote binary.
 * Handles every form commander accepts: `--host h`, `--host=h`, `-H h`, `-H=h`,
 * and the glued short form `-Hh`.
 *
 * @param args the command's args (already past the command name).
 */
export function stripRoutingFlags(args, specs) {
    const out = [];
    for (let i = 0; i < args.length; i++) {
        const a = args[i];
        const spec = specs.find((s) => {
            if (a === `--${s.long}` || a.startsWith(`--${s.long}=`))
                return true;
            if (s.short && (a === `-${s.short}` || a.startsWith(`-${s.short}=`) || new RegExp(`^-${s.short}.+`).test(a)))
                return true;
            return false;
        });
        if (!spec) {
            out.push(a);
            continue;
        }
        // Consume a separate value token only for the exact-match (space-separated) forms.
        const isExact = a === `--${spec.long}` || (spec.short && a === `-${spec.short}`);
        if (spec.takesValue && isExact && i + 1 < args.length)
            i++;
    }
    return out;
}
/**
 * The routing flags every `--device`-capable command shares. Stripped before
 * forwarding so the flag never leaks to the remote binary (which would
 * re-trigger routing). `--host`/`-H` are kept for backward-compat strip only
 * (they are no longer user-facing routing flags).
 */
export const HOST_ROUTING_SPECS = [
    { long: 'device', short: 'D', takesValue: true },
    { long: 'host', short: 'H', takesValue: true },
    { long: 'remote-cwd', takesValue: true },
];
/**
 * The forwarding contract for `agents run … --device`: every option of the `run`
 * command is classified here, keyed by its commander attribute name. A
 * commander-introspection test (run-forwarding.test.ts) fails when a run
 * option is missing from this table, so a new option can never silently drop
 * at the SSH boundary again — the exact bug this table exists to prevent
 * (--secrets/--effort/--env/--timeout historically vanished on --host runs
 * with no error).
 *
 * Rejections are value-aware at the call site (exec.ts): `--secrets` only
 * rejects when a bundle was actually passed, `--resume` only when bare.
 */
export const RUN_OPTION_FORWARDING = {
    // forwarded — the remote run behaves exactly like a local one
    mode: 'forward',
    effort: 'forward',
    model: 'forward',
    env: 'forward',
    addDir: 'forward',
    name: 'forward',
    resume: 'forward', // concrete id only — bare `--resume` rejects (picker can't cross SSH)
    sessionId: 'forward',
    timeout: 'forward',
    fallback: 'forward',
    balanced: 'forward',
    strategy: 'forward',
    account: 'forward',
    loop: 'forward',
    maxIterations: 'forward',
    budget: 'forward',
    until: 'forward',
    interval: 'forward',
    json: 'forward', // remote emits ndjson into its log; the local follow streams it verbatim
    verbose: 'forward',
    yes: 'forward', // a detached remote run can't answer the budget-confirm prompt
    acp: 'forward', // the remote CLI routes through ACP on ITS side of the wire
    autoSecrets: 'forward', // workflow frontmatter secrets resolve on the REMOTE keychain
    emitSessionId: 'forward', // remote prints its session id as a stdout sentinel the launcher captures (session-marker.ts)
    // rejected — cannot cross the SSH boundary; fail loud, never degrade
    terminal: 'reject', // opens a tab on THIS machine's desktop; a remote tab is a different request
    secrets: 'reject',
    secretsKeys: 'reject',
    allowExpired: 'reject',
    resumeCheckpoint: 'reject',
    // local-only — routing, dispatch-path choice, and follow rendering
    quiet: 'local-only', // the remote argv always carries --quiet
    headless: 'local-only',
    interactive: 'local-only', // the interactive path forwards --interactive itself
    cwd: 'local-only', // made portable into remoteCwd
    project: 'local-only',
    remoteCwd: 'local-only',
    raw: 'local-only', // interactive builder forwards --raw itself
    tmux: 'local-only',
    disableTmux: 'local-only',
    device: 'local-only',
    where: 'local-only', // expands into host/lease before dispatch; never re-forwarded
    on: 'local-only',
    computer: 'local-only',
    any: 'local-only',
    follow: 'local-only',
    lease: 'local-only',
    box: 'local-only',
    keepBox: 'local-only',
    fresh: 'local-only', // skips the warm-pool reuse for --lease; the lease path is always local
    reuse: 'local-only', // reuse-picker choice for --lease; the lease path is always local
    bare: 'local-only', // skips the local setup-copy push; lease-only concern
    tailscale: 'local-only', // --tailscale/--no-tailscale gate the lease net mode; never forwarded
    copyCreds: 'local-only', // copies creds TO the host before dispatch — local concern only
    // Cloud placement: chosen and dispatched from THIS machine via the provider
    // registry; mutually exclusive with --device (placement conflict dies before
    // dispatch), so these never have a remote argv to ride.
    cloud: 'local-only',
    provider: 'local-only',
    repo: 'local-only',
    branch: 'local-only',
    cloudEnv: 'local-only',
    authCheck: 'local-only', // --no-auth-check gates the local interactive login preflight; --host runs skip that preflight entirely
    // The notification must land on the box the PERSON is at — the one that
    // dispatched — not on a headless worker with no desktop to post to. The local
    // process follows the remote run to completion, so its exit handler fires at
    // the right moment anyway.
    notify: 'local-only',
    // --no-trace-sync (traceSync=false) gates the LOCAL run-exit trace auto-sync,
    // which only arms for local runs anyway (exec.ts skips it for --device/--lease).
    // On a --device dispatch the remote box runs its own run-exit sync, so this
    // flag is never forwarded — it is a local-exit-behavior toggle, not remote.
    traceSync: 'local-only',
    // Deprecated alias for --device auto; resolved on the launching box before SSH.
    smart: 'local-only',
    // Broadcast mode (agents run --broadcast) is its own fan-out dispatch — mutually
    // exclusive with --host. All broadcast options are local-only; exec.ts handles them
    // before any SSH dispatch path is reached.
    broadcast: 'local-only',
    task: 'local-only',
    listTasks: 'local-only',
    results: 'local-only',
    concurrency: 'local-only',
};
/** Actionable messages for value-aware rejections, keyed by attribute name. */
export const RUN_OPTION_REJECT_MESSAGES = {
    terminal: '--terminal opens a tab on THIS machine; it cannot be combined with --device. ' +
        'Drop --terminal to dispatch to the device, or drop --device to open the tab here. ' +
        'To watch a remote run in a terminal, dispatch it and follow with `agents sessions focus <id>`.',
    secrets: '--secrets cannot cross the SSH boundary — Keychain values are never sent to a host implicitly. ' +
        'Provision the bundle on the host first (agents secrets export --device <name>), then run without --secrets; ' +
        'workflow frontmatter secrets resolve from the HOST\'s own keychain.',
    secretsKeys: '--secrets-keys applies to --secrets bundles, which cannot cross the SSH boundary (see --secrets).',
    allowExpired: '--allow-expired applies to --secrets bundles, which cannot cross the SSH boundary (see --secrets).',
    resumeCheckpoint: '--resume-checkpoint reads a local checkpoint.json — it cannot resume a run on another machine. Run it locally, or start a fresh --loop run on the host.',
    resumeBare: '--resume with no id opens the interactive picker, which cannot run across a detached host dispatch. Pass a concrete session id: agents run <agent> --resume <id> --device <name>.',
};
/**
 * Build the single command string for `ssh <target> <cmd>`. The forwarded args
 * are quoted for the inner login shell, then the whole `agents …` invocation is
 * quoted again so it survives `bash -lc <...>` — `bash -lc` so the remote login
 * PATH resolves `agents`. An optional `cd` runs first for `--remote-cwd`.
 *
 * `os` selects the remote shell dialect: a Windows target gets a PowerShell
 * invocation instead (ssh lands in cmd.exe/PowerShell there, where `bash -lc`
 * does not exist). Anything else — including an unknown/absent OS — keeps the
 * POSIX form, so linux/macos are byte-for-byte unchanged.
 */
export function buildRemoteAgentsInvocation(forwardedArgs, remoteCwd, os, env) {
    if (remoteShellFor(os) === 'powershell') {
        return buildWindowsAgentsCommand({ args: forwardedArgs, cwd: remoteCwd, env });
    }
    const inner = ['agents', ...forwardedArgs].map(shellQuote).join(' ');
    const withCwd = remoteCwd ? `cd ${shellQuote(remoteCwd)} && ${inner}` : inner;
    // Prepend env exports so the remote command sees the shims dir even when the
    // login shell hasn't sourced the interactive rc files that usually add it.
    const exports = posixEnvExports(env);
    if (!exports) {
        return `bash -lc ${shellQuote(withCwd)}`;
    }
    return `bash -lc ${shellQuote(`${exports}; ${withCwd}`)}`;
}
/**
 * Keys whose values are trusted-static and legitimately need remote shell
 * expansion — `PATH` references the remote `$HOME`/`$PATH`. Every other key is
 * rendered as a shell literal, so an attacker-influenceable value (notably actor
 * provenance, whose name/email can come from a tailnet peer's whois or an
 * unvalidated `AGENTS_ACTOR_*` env var) can never inject shell into a dispatch.
 */
const EXPAND_KEYS = new Set(['PATH']);
/**
 * Build a POSIX `export K=V; …` prefix from an env map — empty string when the
 * map is missing or empty. Values are rendered as shell LITERALS by default
 * (single-quoted via {@link shellQuote}), so a `$(...)` or backtick in a value
 * can never execute on the SSH target. Only {@link EXPAND_KEYS} (`PATH`) keep the
 * expanding double-quote form (`\` and `"` escaped) so the remote `$HOME`/`$PATH`
 * still resolve. Shared by {@link buildRemoteAgentsInvocation} and the
 * detached/interactive dispatch builders (dispatch.ts) so every remote path
 * exports env identically.
 */
export function posixEnvExports(env) {
    if (!env || Object.keys(env).length === 0)
        return '';
    return Object.entries(env)
        .map(([k, v]) => EXPAND_KEYS.has(k)
        ? `export ${shellQuote(k)}="${v.replace(/[\\"]/g, '\\$&')}"`
        : `export ${shellQuote(k)}=${shellQuote(v)}`)
        .join('; ');
}
/**
 * Pick the remote shell dialect from a recorded OS/platform string. A Windows
 * host (device-registry `platform: 'windows'`, or an enrolled `HostEntry.os`
 * that reads `windows`/`Windows`/`win32`/…) speaks PowerShell; everything else,
 * including `undefined`/unknown, defaults to POSIX so linux/macos never regress.
 */
export function remoteShellFor(os) {
    return /^win/i.test((os ?? '').trim()) ? 'powershell' : 'posix';
}
/**
 * PowerShell single-quoted literal: wrap in `'…'` and double any embedded `'`.
 * Single-quoted PS strings are fully literal (no `$var`, no backtick escapes),
 * so this neutralises every metacharacter the same way POSIX `shellQuote` does.
 */
export function powershellQuote(s) {
    return "'" + s.replace(/'/g, "''") + "'";
}
/**
 * Encode a PowerShell script for `powershell -EncodedCommand`: base64 of its
 * UTF-16LE bytes. The payload is a bare base64 word (no spaces or shell
 * metacharacters), so it survives being handed to ssh as a single argument and
 * re-parsed by the remote's cmd.exe/PowerShell with zero quoting hazards —
 * the robust way to ship a complex command to a Windows box over SSH.
 */
export function encodePowershell(script) {
    return Buffer.from(script, 'utf16le').toString('base64');
}
/** Inverse of {@link encodePowershell} — decode an `-EncodedCommand` payload
 * back to its script. Used by tests to assert on the built command. */
export function decodePowershell(encoded) {
    return Buffer.from(encoded, 'base64').toString('utf16le');
}
/**
 * The PowerShell script (pre-encoding) that {@link buildWindowsAgentsCommand}
 * runs. Exposed so tests can decode the `-EncodedCommand` payload and compare
 * against the exact script, without needing PowerShell on the test host.
 *
 * `& agents …` invokes the CLI from the machine PATH (Windows has no login
 * shell, so there is no `bash -lc` equivalent — the shim is simply on PATH).
 */
/**
 * PowerShell prelude that silences the progress stream. PowerShell 5.1 serializes
 * progress records ("Preparing modules for first use.") to CLIXML when stderr is a
 * redirected pipe (an ssh capture, not a console), so a remote `agents …` result
 * otherwise comes back wrapped in a raw `#< CLIXML <Objs …>` blob — unreadable to
 * humans and to the JSON parsers that consume doctor / fleet-status output. Prepend
 * this to EVERY Windows script that invokes `agents` (there is more than one builder
 * in this file). Verified against a live win-mini (PowerShell 5.1): this clears it.
 * (`-OutputFormat Text` does NOT — it governs success-stream object serialization,
 * not the progress stream.)
 */
export const POWERSHELL_PROGRESS_SILENCE = "$ProgressPreference = 'SilentlyContinue'";
/**
 * Strip a PowerShell CLIXML wrapper from stdout relayed off a Windows host.
 *
 * PowerShell 5.1 serializes progress / error / verbose records to CLIXML when a
 * stream is a redirected pipe (an ssh capture, not a console): a `#< CLIXML`
 * banner followed by one or more `<Objs …>…</Objs>` elements. {@link
 * POWERSHELL_PROGRESS_SILENCE} suppresses the common "Preparing modules for
 * first use." progress record at the source, but a Windows peer reached WITHOUT
 * that prelude — a raw `agents ssh <win> 'agents … --json'`, an older peer, or a
 * record on a stream we did not silence — can still emit the banner ahead of the
 * real payload, which breaks a naive `JSON.parse` of the relayed `--json`
 * (RUSH-2286). Remove the banner and every self-contained `<Objs …>…</Objs>`
 * block, leaving the genuine payload untouched. A no-op (returns the input
 * unchanged) when no `#< CLIXML` marker is present, so it is safe to apply on
 * every remote-JSON boundary regardless of the peer's OS.
 */
export function stripClixml(stdout) {
    if (!stdout.includes('#< CLIXML'))
        return stdout;
    // A CLIXML flush is the `#< CLIXML` banner immediately followed by one or more
    // <Objs …>…</Objs> elements, as one contiguous block. Remove that whole unit,
    // ANCHORED to the banner: the <Objs> removal is scoped to blocks that follow a
    // banner, so a stray `<Objs>` appearing inside a legitimate JSON string value
    // (a session title/prompt that quotes CLIXML text) is never touched — stripping
    // <Objs> globally would silently delete JSON between two such substrings.
    return stdout
        .replace(/#< CLIXML[^\n]*\r?\n?(?:\s*<Objs\b[\s\S]*?<\/Objs>)*/g, '')
        .trim();
}
export function windowsAgentsScript(cmd) {
    const { args, env, cwd, propagateExit = true, remapExit255 = false } = cmd;
    const parts = [POWERSHELL_PROGRESS_SILENCE];
    if (env)
        for (const [k, v] of Object.entries(env))
            parts.push(`$env:${k} = ${powershellQuote(v)}`);
    if (cwd)
        parts.push(`Set-Location -LiteralPath ${powershellQuote(cwd)}`);
    parts.push(`& ${['agents', ...args].map(powershellQuote).join(' ')}`);
    if (propagateExit) {
        if (remapExit255)
            parts.push('$agentsExit = $LASTEXITCODE', 'if ($agentsExit -eq 255) { exit 254 }', 'exit $agentsExit');
        else
            parts.push('exit $LASTEXITCODE');
    }
    return parts.join('; ');
}
/**
 * Build the `ssh <target> <cmd>` string for one `agents …` invocation on a
 * Windows remote: a `powershell -NoProfile -EncodedCommand <base64>` call. The
 * Windows counterpart of `bash -lc '<...>'`, shared by every `--device` site.
 */
export function buildWindowsAgentsCommand(cmd) {
    return `powershell -NoProfile -EncodedCommand ${encodePowershell(windowsAgentsScript(cmd))}`;
}
/**
 * Build the `ssh <target> <cmd>` string for `agents secrets import` on a Windows
 * remote where the `.env` is piped over ssh stdin.
 *
 * We can't just run `agents secrets import <bundle> --from -`: the npm
 * `agents.ps1` shim does NOT forward the ssh-piped stdin down to the underlying
 * node process, so a raw fd-0 read (`--from -`) hangs forever (observed: the
 * push to a Windows host times out). PowerShell ITSELF can read the pipe, so we
 * read stdin into a temp file in PowerShell, import `--from <file>` (a plain
 * file read, which the shim handles fine), and delete the temp file afterwards
 * — success or failure. Backend defaults to the platform native store
 * (Credential Manager, or the headless file store when there's no logon
 * session), matching a local `agents secrets import`.
 */
/**
 * Run `agents <args> --from <tmp>` on a Windows peer, feeding the ssh-piped stdin
 * through a temp file — the `agents.ps1` shim does not forward piped stdin to the
 * node process, so a verb that reads stdin must be handed a file instead. The
 * generic sibling of {@link buildWindowsStdinImportCommand}; the receiving verb
 * MUST accept `--from <path>` (see `usage-ingest.ts`). The temp file is removed in
 * a `finally` so a throw mid-run never leaves the payload behind.
 */
export function buildWindowsStdinAgentsCommand(args) {
    const forwarded = args.map(powershellQuote).join(' ');
    const script = [
        POWERSHELL_PROGRESS_SILENCE,
        '$in = [Console]::In.ReadToEnd()',
        '$tmp = $null',
        `try { $tmp = [System.IO.Path]::GetTempFileName(); [System.IO.File]::WriteAllText($tmp, $in); ` +
            `& agents ${forwarded} --from $tmp; $code = $LASTEXITCODE } ` +
            `finally { if ($tmp) { Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue } }`,
        'if ($null -eq $code) { $code = 1 }',
        'exit $code',
    ].join('; ');
    return `powershell -NoProfile -EncodedCommand ${encodePowershell(script)}`;
}
export function buildWindowsStdinImportCommand(bundle, opts = {}) {
    const force = opts.force ? ' --force' : '';
    const policy = opts.policyNever ? ' --policy never --i-understand' : '';
    // Create AND write the temp file INSIDE the try so its finally always cleans
    // up: if GetTempFileName succeeds but WriteAllText (or the import) then throws,
    // the secret-bearing temp file would otherwise be left behind (RUSH-1764). $tmp
    // starts null so a GetTempFileName that itself throws leaves nothing to remove.
    const script = [
        // Same CLIXML guard as windowsAgentsScript — this builder also runs `& agents …`
        // and its raw stderr is printed to the user on failure (secrets export --device <win>).
        POWERSHELL_PROGRESS_SILENCE,
        '$in = [Console]::In.ReadToEnd()',
        '$tmp = $null',
        `try { $tmp = [System.IO.Path]::GetTempFileName(); [System.IO.File]::WriteAllText($tmp, $in); ` +
            `& agents secrets import ${powershellQuote(bundle)} --from $tmp${force}${policy}; $code = $LASTEXITCODE } ` +
            `finally { if ($tmp) { Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue } }`,
        'if ($null -eq $code) { $code = 1 }',
        'exit $code',
    ].join('; ');
    return `powershell -NoProfile -EncodedCommand ${encodePowershell(script)}`;
}
