mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-13 04:03:30 +00:00
Buy the grammar, own the execution: @yarnpkg/parsers parses the command line - aliased at bundle time to its shell entry so the root barrel's syml/js-yaml closure stays out of the worker - and a VFS-backed evaluator with a coreutils command table runs it inside the worker host. Each shell process is a real child WebWorker spawned from the same bundle (the first frame decides the role), so the TERM-then-KILL ladder is real - TERM requests, KILL terminates the worker - and the file face stays asynchronous end to end, since the deployment target serves no COOP/COEP headers and SharedArrayBuffer never exists there. node:child_process reports through the ChildProcess surface the subprocess service consumes; execSync, execFileSync and fork refuse, and node-pty stays stubbed.
390 lines
16 KiB
TypeScript
390 lines
16 KiB
TypeScript
/**
|
|
* The interpreter: it walks the parsed command line and runs the command table
|
|
* against the VFS. Structure (`;` `&` `|` `|&` `&&` `||`, subshells, groups,
|
|
* redirections, prefix assignments) is honored here; what a command *does*
|
|
* belongs to its program in `./programs/`.
|
|
*
|
|
* Output is text, not streams: every program is a JavaScript function that
|
|
* returns before the next one runs, so a pipeline hands a string along instead
|
|
* of plumbing byte streams a browser worker has no way to schedule between.
|
|
* @module @deepseek-ai/dsh-experimental-webworker-runtime/src/shell/interpret
|
|
*/
|
|
|
|
import { parseShell } from '@yarnpkg/parsers'
|
|
import type { Command, CommandChain, CommandLine, RedirectArgument, ShellLine, ValueArgument } from './ast.ts'
|
|
import { expandArgument, isGlobPattern } from './expand.ts'
|
|
import type { ExpansionContext } from './expand.ts'
|
|
import { describeFailure, hostFileSystem, resolveIn } from './fs-access.ts'
|
|
import { standardPrograms } from './programs/index.ts'
|
|
import type { ShellFileSystem, ShellIo, ShellProgram, ShellRunOutcome, ShellState } from './types.ts'
|
|
|
|
/** Status a command line reports once the caller's abort signal has fired. */
|
|
const ABORTED_STATUS = 130
|
|
|
|
/** Status of a command name the table does not hold, as POSIX shells report it. */
|
|
const NOT_FOUND_STATUS = 127
|
|
|
|
/** Nesting limit for `$( … )`; a deeper line is a runaway, not a command. */
|
|
const MAX_SUBSTITUTION_DEPTH = 16
|
|
|
|
/** Everything one `bash -c` invocation needs. */
|
|
export interface ShellRunOptions {
|
|
/** Working directory the line starts in. */
|
|
cwd: string
|
|
/** Environment the line starts with. */
|
|
env: Record<string, string>
|
|
/** Standard input contents; absent means empty. */
|
|
stdin?: string | undefined
|
|
/** Cancellation: an aborted line stops before its next command. */
|
|
signal?: AbortSignal | undefined
|
|
/**
|
|
* The filesystem this run acts on; defaults to the VFS mounted in this
|
|
* thread. A run inside a process worker passes the message-backed one.
|
|
*/
|
|
fs?: ShellFileSystem | undefined
|
|
/**
|
|
* Called with each write as it happens, before the run settles. The returned
|
|
* outcome still carries the complete text; this only lets a caller that
|
|
* reports progress (a background job's incremental reads) see output while
|
|
* the line is still running.
|
|
*/
|
|
onOutput?: ((stream: 'stdout' | 'stderr', text: string) => void) | undefined
|
|
}
|
|
|
|
/** Accumulates one output stream. */
|
|
interface Sink {
|
|
write: (text: string) => void
|
|
}
|
|
|
|
/** A sink over a string buffer, for pipelines and command substitution. */
|
|
function buffer(): Sink & { text(): string } {
|
|
const chunks: string[] = []
|
|
return {
|
|
write: (text: string) => { chunks.push(text) },
|
|
text: () => chunks.join(''),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Run one shell command line to completion.
|
|
* @param source - the command source, exactly as `bash -c` would receive it.
|
|
* @param options - starting directory, environment, standard input, cancellation, filesystem, output callback.
|
|
* @returns the exit status and the complete standard output and standard error.
|
|
*/
|
|
export async function runShellCommand(source: string, options: ShellRunOptions): Promise<ShellRunOutcome> {
|
|
const run = startRun(options)
|
|
let line: ShellLine
|
|
try {
|
|
line = parseShell(source, { isGlobPattern })
|
|
} catch (error) {
|
|
run.io.err(`bash: syntax error: ${error instanceof Error ? error.message.split('\n')[0] : String(error)}\n`)
|
|
return run.settle(2)
|
|
}
|
|
const interpreter = new Interpreter(standardPrograms(), options.fs ?? hostFileSystem(), options.signal)
|
|
return run.settle(await interpreter.line(line, run.state, run.io))
|
|
}
|
|
|
|
/**
|
|
* Run one program directly, without a command line to parse.
|
|
*
|
|
* This is the path for an argv the caller already has in pieces — a spawn that
|
|
* names a program instead of handing `bash` a script — so nothing re-quotes
|
|
* words that were never quoted in the first place.
|
|
* @param argv - the program name at index 0, then its arguments.
|
|
* @param options - starting directory, environment, standard input, cancellation, filesystem, output callback.
|
|
* @returns the exit status and the complete standard output and standard error.
|
|
*/
|
|
export async function runShellProgram(argv: readonly string[], options: ShellRunOptions): Promise<ShellRunOutcome> {
|
|
const run = startRun(options)
|
|
const name = argv[0]
|
|
const program = name === undefined ? undefined : standardPrograms().get(name)
|
|
if (name === undefined || program === undefined) {
|
|
run.io.err(`bash: ${name ?? ''}: command not found\n`)
|
|
return run.settle(NOT_FOUND_STATUS)
|
|
}
|
|
if (options.signal?.aborted === true) return run.settle(ABORTED_STATUS)
|
|
try {
|
|
return run.settle(await program(argv, run.io, run.state, options.fs ?? hostFileSystem()))
|
|
} catch (error) {
|
|
run.io.err(`bash: ${name}: ${error instanceof Error ? error.message : String(error)}\n`)
|
|
return run.settle(1)
|
|
}
|
|
}
|
|
|
|
/** Build the state, the sinks, and the settlement one run reports through. */
|
|
function startRun(options: ShellRunOptions): {
|
|
state: ShellState
|
|
io: ShellIo
|
|
settle: (exitCode: number) => ShellRunOutcome
|
|
} {
|
|
const stdout = buffer()
|
|
const stderr = buffer()
|
|
const report = options.onOutput
|
|
return {
|
|
state: {
|
|
cwd: options.cwd,
|
|
environment: { ...options.env },
|
|
variables: {},
|
|
lastStatus: 0,
|
|
exitRequested: undefined,
|
|
signal: options.signal,
|
|
},
|
|
io: {
|
|
stdin: options.stdin ?? '',
|
|
out: (text: string) => {
|
|
stdout.write(text)
|
|
report?.('stdout', text)
|
|
},
|
|
err: (text: string) => {
|
|
stderr.write(text)
|
|
report?.('stderr', text)
|
|
},
|
|
},
|
|
settle: (exitCode: number) => ({ exitCode, stdout: stdout.text(), stderr: stderr.text() }),
|
|
}
|
|
}
|
|
|
|
/** One interpretation pass; holds what every nested command shares. */
|
|
class Interpreter {
|
|
constructor(
|
|
private readonly programs: ReadonlyMap<string, ShellProgram>,
|
|
private readonly fs: ShellFileSystem,
|
|
private readonly signal: AbortSignal | undefined,
|
|
private readonly depth = 0,
|
|
) {}
|
|
|
|
/**
|
|
* Run every command of one line, left to right.
|
|
* @param line - the parsed line.
|
|
* @param state - shell state the line reads and mutates.
|
|
* @param io - standard input and the output sinks.
|
|
* @returns the status of the last command that ran.
|
|
*/
|
|
async line(line: ShellLine, state: ShellState, io: ShellIo): Promise<number> {
|
|
let status = state.lastStatus
|
|
for (const entry of line) {
|
|
if (this.signal?.aborted === true) return ABORTED_STATUS
|
|
// `&` starts no background job here: the worker has no scheduler that
|
|
// could run one, so a backgrounded command runs to completion in place.
|
|
status = await this.commandLine(entry.command, state, io)
|
|
state.lastStatus = status
|
|
if (state.exitRequested !== undefined) return state.exitRequested
|
|
}
|
|
return status
|
|
}
|
|
|
|
/**
|
|
* Run one `&&` / `||` chain.
|
|
*
|
|
* The grammar nests these to the right, while a shell evaluates them left to
|
|
* right: `false && a || b` runs `b`. Flattening first is what makes the
|
|
* skipped `&&` hand its status to the following `||` instead of taking the
|
|
* whole remainder of the line with it.
|
|
*/
|
|
private async commandLine(commandLine: CommandLine, state: ShellState, io: ShellIo): Promise<number> {
|
|
const links: { type: '&&' | '||'; chain: CommandChain }[] = []
|
|
for (let current = commandLine.then; current !== undefined; current = current.line.then) {
|
|
links.push({ type: current.type, chain: current.line.chain })
|
|
}
|
|
let status = await this.pipeline(commandLine.chain, state, io)
|
|
state.lastStatus = status
|
|
for (const link of links) {
|
|
if (state.exitRequested !== undefined) return status
|
|
if (link.type === '&&' ? status !== 0 : status === 0) continue
|
|
status = await this.pipeline(link.chain, state, io)
|
|
state.lastStatus = status
|
|
}
|
|
return status
|
|
}
|
|
|
|
/** Run one `|` / `|&` pipeline; its status is the last stage's. */
|
|
private async pipeline(chain: CommandChain, state: ShellState, io: ShellIo): Promise<number> {
|
|
const stages: { command: CommandChain; mergesStderr: boolean }[] = []
|
|
for (let current: CommandChain | undefined = chain; current !== undefined;) {
|
|
const link: CommandChain['then'] = current.then
|
|
stages.push({ command: current, mergesStderr: link?.type === '|&' })
|
|
current = link?.chain
|
|
}
|
|
let input = io.stdin
|
|
let status = 0
|
|
for (const [index, stage] of stages.entries()) {
|
|
if (this.signal?.aborted === true) return ABORTED_STATUS
|
|
const last = index === stages.length - 1
|
|
const piped = buffer()
|
|
const stageIo: ShellIo = last
|
|
? { stdin: input, out: io.out, err: io.err }
|
|
: { stdin: input, out: piped.write, err: stage.mergesStderr ? piped.write : io.err }
|
|
status = await this.command(stage.command, state, stageIo)
|
|
if (!last) input = piped.text()
|
|
if (state.exitRequested !== undefined) return status
|
|
}
|
|
return status
|
|
}
|
|
|
|
/** Run one command node: a program call, a subshell, a group, or bare assignments. */
|
|
private async command(command: Command, state: ShellState, io: ShellIo): Promise<number> {
|
|
switch (command.type) {
|
|
case 'envs':
|
|
for (const env of command.envs) assign(state, env.name, await this.assignedValue(env.args[0], state))
|
|
return 0
|
|
case 'subshell': {
|
|
// A subshell sees a copy: its `cd` and its assignments die with it.
|
|
const nested = { ...state, environment: { ...state.environment }, variables: { ...state.variables } }
|
|
return await this.redirected(command.args, state, io, async inner => await this.line(command.subshell, nested, inner))
|
|
}
|
|
case 'group':
|
|
return await this.redirected(command.args, state, io, async inner => await this.line(command.group, state, inner))
|
|
case 'command':
|
|
return await this.program(command, state, io)
|
|
}
|
|
}
|
|
|
|
/** Expand a command's words and run the program they name. */
|
|
private async program(command: Extract<Command, { type: 'command' }>, state: ShellState, io: ShellIo): Promise<number> {
|
|
const argv: string[] = []
|
|
const redirections: RedirectArgument[] = []
|
|
for (const argument of command.args) {
|
|
if (argument.type === 'redirection') {
|
|
redirections.push(argument)
|
|
continue
|
|
}
|
|
argv.push(...await expandArgument(argument, this.context(state)))
|
|
}
|
|
const prefix: Record<string, string> = {}
|
|
for (const env of command.envs) prefix[env.name] = await this.assignedValue(env.args[0], state)
|
|
|
|
if (argv.length === 0) {
|
|
for (const [name, value] of Object.entries(prefix)) assign(state, name, value)
|
|
return 0
|
|
}
|
|
// A prefixed command sees the assignments as environment for its run only,
|
|
// which also means it cannot change the caller's directory.
|
|
const scope = Object.keys(prefix).length === 0
|
|
? state
|
|
: { ...state, environment: { ...state.environment, ...prefix } }
|
|
|
|
const name = argv[0] as string
|
|
const program = this.programs.get(name)
|
|
if (program === undefined) {
|
|
io.err(`bash: ${name}: command not found\n`)
|
|
return NOT_FOUND_STATUS
|
|
}
|
|
return await this.redirected(redirections, state, io, async (inner) => {
|
|
try {
|
|
return await program(argv, inner, scope, this.fs)
|
|
} catch (error) {
|
|
// A program's own defect must not take the whole worker down with it.
|
|
inner.err(`bash: ${name}: ${error instanceof Error ? error.message : String(error)}\n`)
|
|
return 1
|
|
}
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Apply redirections around one body, then restore nothing: every sink is a
|
|
* value, so the caller's own `io` is untouched by construction.
|
|
*/
|
|
private async redirected(
|
|
redirections: readonly RedirectArgument[],
|
|
state: ShellState,
|
|
io: ShellIo,
|
|
body: (io: ShellIo) => Promise<number>,
|
|
): Promise<number> {
|
|
let stdin = io.stdin
|
|
let out = io.out
|
|
let err = io.err
|
|
// Every file write this redirection set started, awaited before the
|
|
// command's status is reported: a `> file` must be complete on return.
|
|
const writes: Promise<void>[] = []
|
|
for (const redirection of redirections) {
|
|
const targets: string[] = []
|
|
for (const argument of redirection.args) targets.push(...await expandArgument(argument, this.context(state)))
|
|
const target = targets[0]
|
|
if (target === undefined || targets.length > 1) {
|
|
io.err('bash: ambiguous redirect\n')
|
|
return 1
|
|
}
|
|
try {
|
|
switch (redirection.subtype) {
|
|
case '<':
|
|
stdin = await this.fs.readText(resolveIn(state.cwd, target))
|
|
break
|
|
case '<<<':
|
|
stdin = `${target}\n`
|
|
break
|
|
case '>':
|
|
case '>>': {
|
|
const path = resolveIn(state.cwd, target)
|
|
// Truncation happens at redirect time, so `> file` empties it even
|
|
// when the command writes nothing.
|
|
if (redirection.subtype === '>') await this.fs.writeText(path, '')
|
|
// Appends are ordered by the queue below: a sink is synchronous to
|
|
// its caller, so writes are chained rather than raced.
|
|
let pending: Promise<void> = Promise.resolve()
|
|
const sink = (text: string): void => {
|
|
pending = pending.then(async () => { await this.fs.writeText(path, text, true) })
|
|
writes.push(pending)
|
|
}
|
|
if (redirection.fd === 2) err = sink
|
|
else out = sink
|
|
break
|
|
}
|
|
case '>&': {
|
|
// Only descriptor duplication between stdout and stderr is
|
|
// meaningful here: those are the only two the shell owns.
|
|
if (redirection.fd === 2 && target === '1') err = out
|
|
else if ((redirection.fd === null || redirection.fd === 1) && target === '2') out = err
|
|
else {
|
|
io.err(`bash: ${String(redirection.fd ?? 1)}>&${target}: unsupported descriptor redirection\n`)
|
|
return 1
|
|
}
|
|
break
|
|
}
|
|
case '<&':
|
|
io.err(`bash: <&${target}: unsupported descriptor redirection\n`)
|
|
return 1
|
|
}
|
|
} catch (error) {
|
|
io.err(`${describeFailure('bash', resolveIn(state.cwd, target), error)}\n`)
|
|
return 1
|
|
}
|
|
}
|
|
const status = await body({ stdin, out, err })
|
|
await Promise.all(writes)
|
|
return status
|
|
}
|
|
|
|
/** The expansion hook: `$( … )` runs on a nested interpreter of the same table. */
|
|
private context(state: ShellState): ExpansionContext {
|
|
return {
|
|
state,
|
|
fs: this.fs,
|
|
substitute: async (shell: ShellLine): Promise<string> => {
|
|
if (this.depth >= MAX_SUBSTITUTION_DEPTH) {
|
|
throw new Error(`command substitution nested deeper than ${String(MAX_SUBSTITUTION_DEPTH)} levels`)
|
|
}
|
|
const captured = buffer()
|
|
const nested = { ...state, environment: { ...state.environment }, variables: { ...state.variables } }
|
|
const inner = new Interpreter(this.programs, this.fs, this.signal, this.depth + 1)
|
|
await inner.line(shell, nested, { stdin: '', out: captured.write, err: () => {} })
|
|
return captured.text().replace(/\n+$/, '')
|
|
},
|
|
}
|
|
}
|
|
|
|
/** Expand the right-hand side of one `NAME=value` assignment. */
|
|
private async assignedValue(argument: ValueArgument | undefined, state: ShellState): Promise<string> {
|
|
if (argument === undefined) return ''
|
|
return (await expandArgument(argument, this.context(state))).join(' ')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Record one assignment. An exported name keeps its export (the environment
|
|
* copy is what programs read); anything else stays a shell variable.
|
|
*/
|
|
function assign(state: ShellState, name: string, value: string): void {
|
|
if (name in state.environment) state.environment[name] = value
|
|
else state.variables[name] = value
|
|
}
|