|
| 1 | +/** |
| 2 | + * @file Exec-backend seam: WHERE a shell command runs, a separate axis from |
| 3 | + * WHICH model produced it (`ai/backends`). The lib owns the INTERFACE plus |
| 4 | + * the cheap built-in `real` runner (the host shell via lib `spawn`); a |
| 5 | + * SANDBOXED runner is INJECTED by the caller, never imported here. That keeps |
| 6 | + * the small-dist lib free of a heavy sandbox dependency — the fleet's sandbox |
| 7 | + * of choice (`just-bash`, ~40MB incl. WASM) is owned by the wheelhouse hook / |
| 8 | + * CI tooling and passed in, so a lib consumer that never sandboxes pays |
| 9 | + * nothing. Layering: ExecRunner.run() — the injectable primitive (real | |
| 10 | + * sandboxed) composed into ExecContext — { runners: { real, sandboxed? }, |
| 11 | + * resolve(trust) } used by runShell(script, { context, trust }) — the |
| 12 | + * ergonomic entry point Pick a runner by TRUST LEVEL, never by model. |
| 13 | + * `untrusted` resolves to the sandboxed runner — which a caller that hasn't |
| 14 | + * injected one cannot run, so `resolve` throws a clear "provide a sandboxed |
| 15 | + * runner" error rather than silently falling back to the host shell. Both |
| 16 | + * runners normalize to one `ExecResult` so callers swap trust levels without |
| 17 | + * reshaping result handling. |
| 18 | + */ |
| 19 | + |
| 20 | +import { spawn } from '../process/spawn/child' |
| 21 | +import { isSpawnError } from '../process/spawn/errors' |
| 22 | + |
| 23 | +// Where the shell runs: the host FS (`real`) or an injected sandbox. |
| 24 | +export type ExecBackend = 'real' | 'sandboxed' |
| 25 | + |
| 26 | +// Trust level of the script's source. Untrusted (model-generated, third-party, |
| 27 | +// unreviewed) routes to the sandbox; trusted routes to the host shell. |
| 28 | +export type ExecTrust = 'trusted' | 'untrusted' |
| 29 | + |
| 30 | +// Normalized result shape every runner returns. |
| 31 | +export interface ExecResult { |
| 32 | + readonly stdout: string |
| 33 | + readonly stderr: string |
| 34 | + readonly exitCode: number |
| 35 | +} |
| 36 | + |
| 37 | +// Per-run options handed to a runner. `files` seeds a sandbox's virtual FS and |
| 38 | +// is ignored by the real runner (which already sees the host FS). |
| 39 | +export interface ExecRunOptions { |
| 40 | + readonly cwd?: string | undefined |
| 41 | + readonly env?: Record<string, string> | undefined |
| 42 | + readonly files?: Record<string, string> | undefined |
| 43 | + readonly signal?: AbortSignal | undefined |
| 44 | + readonly stdin?: string | undefined |
| 45 | +} |
| 46 | + |
| 47 | +// The injectable execution primitive: run one script, resolve to an ExecResult |
| 48 | +// (a non-zero exit is a result, never a throw — both real and injected runners |
| 49 | +// MUST honor this so callers branch on exitCode, not try/catch). |
| 50 | +export interface ExecRunner { |
| 51 | + run(script: string, options?: ExecRunOptions | undefined): Promise<ExecResult> |
| 52 | +} |
| 53 | + |
| 54 | +// Registry of runners + the trust→runner policy. `real` is always present |
| 55 | +// (the lib provides it); `sandboxed` is whatever the caller injected, if any. |
| 56 | +export interface ExecContext { |
| 57 | + readonly runners: { |
| 58 | + readonly real: ExecRunner |
| 59 | + readonly sandboxed?: ExecRunner | undefined |
| 60 | + } |
| 61 | + // Map a trust level to a concrete runner. Throws when `untrusted` is asked |
| 62 | + // for but no sandboxed runner was injected — never silently downgrades to the |
| 63 | + // host shell. |
| 64 | + resolve(trust: ExecTrust): ExecRunner |
| 65 | +} |
| 66 | + |
| 67 | +// The built-in `real` runner: the host shell via lib spawn. `bash -c <script>` |
| 68 | +// so the script string is interpreted, matching a sandbox's single-string exec. |
| 69 | +// Exported so a caller can compose or wrap it. |
| 70 | +export const realRunner: ExecRunner = { |
| 71 | + async run( |
| 72 | + script: string, |
| 73 | + options?: ExecRunOptions | undefined, |
| 74 | + ): Promise<ExecResult> { |
| 75 | + const { cwd, env, signal } = { |
| 76 | + __proto__: null, |
| 77 | + ...options, |
| 78 | + } as ExecRunOptions |
| 79 | + // NOTE: `stdin` is intentionally NOT forwarded to the real runner. lib |
| 80 | + // `spawn` exposes stdin only via the interactive `result.stdin` stream |
| 81 | + // (write/end), which doesn't fit a one-shot run; piping without closing |
| 82 | + // hangs a reader like `cat`. A script needing stdin should use a sandboxed |
| 83 | + // runner (which feeds stdin in-memory) or embed the data via a here-doc. |
| 84 | + const spawnOptions = { |
| 85 | + ...(cwd ? { cwd } : {}), |
| 86 | + ...(env ? { env } : {}), |
| 87 | + ...(signal ? { signal } : {}), |
| 88 | + stdioString: true, |
| 89 | + } |
| 90 | + try { |
| 91 | + const result = await spawn('bash', ['-c', script], spawnOptions) |
| 92 | + return { |
| 93 | + stdout: String(result.stdout ?? ''), |
| 94 | + stderr: String(result.stderr ?? ''), |
| 95 | + // Resolved result carries the status on `code` (0 here — a non-zero |
| 96 | + // exit rejects, handled below). |
| 97 | + exitCode: typeof result.code === 'number' ? result.code : 0, |
| 98 | + } |
| 99 | + } catch (e) { |
| 100 | + // lib spawn (via @npmcli/promise-spawn) REJECTS on a non-zero exit; an |
| 101 | + // ExecRunner must surface that as a result, so normalize a SpawnError. |
| 102 | + // A non-SpawnError (failed to launch, e.g. no bash) is a genuine fault. |
| 103 | + if (!isSpawnError(e)) { |
| 104 | + throw e |
| 105 | + } |
| 106 | + return { |
| 107 | + stdout: String(e.stdout ?? ''), |
| 108 | + stderr: String(e.stderr ?? ''), |
| 109 | + // A signal-kill (code null) becomes non-zero so it isn't read as |
| 110 | + // success. |
| 111 | + exitCode: typeof e.code === 'number' ? e.code : 1, |
| 112 | + } |
| 113 | + } |
| 114 | + }, |
| 115 | +} |
| 116 | + |
| 117 | +// Map a trust level to a backend name. The single decision point so callers |
| 118 | +// express intent ("this script is untrusted") rather than naming a backend. |
| 119 | +export function backendForTrust(trust: ExecTrust): ExecBackend { |
| 120 | + return trust === 'trusted' ? 'real' : 'sandboxed' |
| 121 | +} |
| 122 | + |
| 123 | +// Build an ExecContext. `real` is the lib's built-in runner unless overridden; |
| 124 | +// pass `sandboxed` (e.g. the wheelhouse's just-bash-backed runner) to enable the |
| 125 | +// untrusted path. `resolve` throws when the untrusted path is used without one. |
| 126 | +export function createExecContext( |
| 127 | + options?: |
| 128 | + | { real?: ExecRunner | undefined; sandboxed?: ExecRunner | undefined } |
| 129 | + | undefined, |
| 130 | +): ExecContext { |
| 131 | + const { real = realRunner, sandboxed } = { |
| 132 | + __proto__: null, |
| 133 | + ...options, |
| 134 | + } as { real?: ExecRunner | undefined; sandboxed?: ExecRunner | undefined } |
| 135 | + const runners = { real, sandboxed } |
| 136 | + return { |
| 137 | + runners, |
| 138 | + resolve(trust: ExecTrust): ExecRunner { |
| 139 | + if (backendForTrust(trust) === 'real') { |
| 140 | + return runners.real |
| 141 | + } |
| 142 | + if (!runners.sandboxed) { |
| 143 | + throw new Error( |
| 144 | + 'No sandboxed exec runner provided.\n' + |
| 145 | + '→ An `untrusted` script must run in a sandbox, not the host shell.\n' + |
| 146 | + '→ Fix: pass a `sandboxed` runner to createExecContext() (e.g. the ' + |
| 147 | + 'wheelhouse just-bash runner), or mark the script `trusted` if it is.', |
| 148 | + ) |
| 149 | + } |
| 150 | + return runners.sandboxed |
| 151 | + }, |
| 152 | + } |
| 153 | +} |
| 154 | + |
| 155 | +// Run `script` under the context's runner for `trust`. Defaults: a real-only |
| 156 | +// context (so `trusted` works out of the box) and `untrusted` trust (fail safe — |
| 157 | +// an unspecified caller is routed to the sandbox, which errors loudly if none |
| 158 | +// was injected rather than quietly using the host shell). |
| 159 | +export async function runShell( |
| 160 | + script: string, |
| 161 | + options?: |
| 162 | + | (ExecRunOptions & { |
| 163 | + context?: ExecContext | undefined |
| 164 | + trust?: ExecTrust | undefined |
| 165 | + }) |
| 166 | + | undefined, |
| 167 | +): Promise<ExecResult> { |
| 168 | + const { |
| 169 | + context, |
| 170 | + cwd, |
| 171 | + env, |
| 172 | + files, |
| 173 | + signal, |
| 174 | + stdin, |
| 175 | + trust = 'untrusted', |
| 176 | + } = { __proto__: null, ...options } as ExecRunOptions & { |
| 177 | + context?: ExecContext | undefined |
| 178 | + trust?: ExecTrust | undefined |
| 179 | + } |
| 180 | + const ctx = context ?? createExecContext() |
| 181 | + return await ctx |
| 182 | + .resolve(trust) |
| 183 | + .run(script, { cwd, env, files, signal, stdin }) |
| 184 | +} |
0 commit comments