/** * Shell implementation (tokenizer slice in {@link ./shell-tokenizer.js}; planner/executor here). */ import { runBinCommand, resolveBinInPath } from '../boot/kernel-runner.js' import { tokenizeBareShellLineForDiagnostics } from './shell-tokenizer.js' import { tokenizeBareShellLineDetailed } from './shell-tokenizer.js' export { tokenizeBareShellLineForDiagnostics } export { tokenizeBareShellLineDetailed } import { bareOsKernelMetricSet } from '../boot/bare-os-kernel-metrics.js' import { BARE_OS_SHELL_NOUNSET_ERROR } from './shell-nounset.js' import { bareOsEvalArithmeticExpr } from './shell-arithmetic.js' import { isExecLineBuiltinDenied, shellCommandDeniedByPolicy, shellUnsafeRedirectPath } from './shell-policy.js' import { collapseShellLineContinuations, splitTopLevelStatements, splitTopLevelByAmpersand, findThenIndex, findElseOrFiAfterThen, findFiAfterElse, findWhileDoSplit, isValidShellIdentifier, parseShellFunctionDeclaration, casePatternMatches } from './shell-syntax.js' import { tokenize } from './shell-token.js' import { defaultShellAliases, expandArgvAliases, applyAliasDefinition, runUnaliasBuiltin, loadBarerc } from './shell-alias.js' import { parsePipeline, splitTokensBySemicolon, splitTokensByAndOr, segmentHasCommand, buildShellExecutionGraph } from './shell-parse.js' import { isShellBuiltin, runShellReadBuiltin } from './shell-builtins.js' import { syncBareOsExitStatusEnv, getBareOsPipelineLimits, waitWhileShellJobStopped, appendShellAuditEvent, mergePipelineChildCtx } from './shell-runtime.js' import { expandWord, expandShellWordTokens } from './shell-expand.js' import { bareOsPipelineChildCtx, bareOsPipelineRawChunkToText, runWithShellPipelineStageTimeout } from './shell-pipeline.js' import { execShellLocalBuiltin, tryExecShellDeclareBuiltin, tryReportMisplacedReservedStatementStart, execDoubleBracketLimited, assignShellFunctionPositionalEnv, casePatternList, consumeInteractiveHeredoc } from './shell-stmt.js' export { BARE_OS_SHELL_NOUNSET_ERROR } export { bareOsShellReadBuiltinEnabled, listBareOsShellBuiltins } from './shell-builtins.js' export { BARE_OS_EXIT_STATUS_ENV, syncBareOsExitStatusEnv, DEFAULT_PIPELINE_MAX_STAGES, DEFAULT_PIPELINE_MAX_CAPTURE_BYTES, DEFAULT_PIPELINE_MAX_CAPTURE_LINES, getBareOsPipelineLimits } from './shell-runtime.js' export { expandWord } from './shell-expand.js' export { tokenize, shellWordText } from './shell-token.js' export { defaultShellAliases, expandArgvAliases, applyAliasDefinition, runUnaliasBuiltin, BARERC_SKELETON, loadBarerc } from './shell-alias.js' export { parsePipeline, bareOsShellError, bareOsShellAstSnapshot, splitTokensBySemicolon, splitTokensByAndOr, planShellRedirections, buildShellExecutionGraph } from './shell-parse.js' export { bareOsResetShellIdentityState } from './shell-stmt.js' /** Shell planner/executor continues below. */ /** * @param {string} signal */ function normalizeShellSignalName(signal) { return String(signal || '') .trim() .replace(/^SIG/i, '') .toUpperCase() } /** * Execute a registered trap handler for a signal, when present. * @param {Record} ctx * @param {string} signal * @returns {Promise} true when a trap ran */ export async function dispatchShellTrapSignal(ctx, signal) { const sig = normalizeShellSignalName(signal) if (!sig) return false const handlers = ctx.shellTrapHandlers && typeof ctx.shellTrapHandlers === 'object' ? /** @type {Record} */ (ctx.shellTrapHandlers) : null if (!handlers || !handlers[sig]) return false const cmd = String(handlers[sig] || '').trim() if (!cmd) return false await execShellLine(ctx, cmd) return true } /** * @param {Record} ctx * @param {SimpleCmd[]} pipeline * @returns {Promise<'exit' | 'ok'>} */ async function execParsedPipeline(ctx, pipeline) { const vfs = ctx.vfs const env = vfs.env const lim = getBareOsPipelineLimits(env) if (pipeline.length > lim.maxStages) { ctx.console.error( `shell: pipeline exceeds BARE_OS_PIPELINE_MAX_STAGES (${lim.maxStages})` ) ctx.exitCode = 1 return 'ok' } try { bareOsKernelMetricSet('shell.pipeline_last_stages', pipeline.length) } catch { /* ignore */ } let stdinText = typeof ctx.shellStdin === 'string' ? ctx.shellStdin : null /** Last completed pipeline stage exit (POSIX default: status of last stage; optional pipefail). */ let pipelineLastExit = 0 /** True when **`BARE_OS_SHELL_PIPEFAIL`** is already set or set by a stage prefix in this pipeline. */ let pipelineWantsPipefail = env.BARE_OS_SHELL_PIPEFAIL === '1' || env.BARE_OS_SHELL_PIPEFAIL === 'true' /** First non-zero stage exit when pipefail is active (handbook: first failing stage wins). */ let pipefailFirstNonZero = 0 /** Stage exit codes for optional `BARE_OS_PIPESTATUS` (space-separated). */ const pipelineStageExits = [] const recordStageExit = (code) => { const c = Number(code) || 0 pipelineStageExits.push(c) pipelineLastExit = c if (pipelineWantsPipefail && pipefailFirstNonZero === 0 && c !== 0) { pipefailFirstNonZero = c } } try { for (let pi = 0; pi < pipeline.length; pi++) { const cmd = pipeline[pi] const isLast = pi === pipeline.length - 1 const origLog = ctx.console.log const origErr = ctx.console.error if (!cmd.argv.length && !Object.keys(cmd.assign).length) continue if (!ctx.shellAliases) ctx.shellAliases = { ...defaultShellAliases() } /** @type {string[]} */ let argv = [] for (let wi = 0; wi < cmd.argv.length; wi++) { const wTok = cmd.argv[wi] const exprMulCompat = wi > 0 && argv[0] === 'expr' && wTok.value === '*' && Array.isArray(wTok.parts) && wTok.parts.length === 1 && wTok.parts[0].q === 'u' && wTok.parts[0].t === '*' const xs = await expandShellWordTokens(ctx, wTok, env, { disablePathnameExpansion: exprMulCompat }) if ( xs.length === 0 && (env.BARE_OS_STRICT_POSIX === '1' || env.BARE_OS_STRICT_POSIX === 'true') ) { origErr.call( ctx.console, 'shell: pathname expansion produced no matches (strict POSIX)' ) ctx.exitCode = 1 argv = [] break } for (const x of xs) argv.push(x) } if (argv.length === 0 && cmd.argv.length) { continue } try { argv = expandArgvAliases(argv, ctx.shellAliases) } catch (e) { ctx.console.error((e && e.message) || String(e)) ctx.exitCode = 1 continue } const name = argv[0] appendShellAuditEvent(ctx, 'shell.command.start', { command: name, argv: argv.slice(0, 16), stage: pi }) if (shellCommandDeniedByPolicy(name, env)) { origErr.call(ctx.console, 'shell: command denied by policy: ' + name) ctx.exitCode = 126 recordStageExit(ctx.exitCode) appendShellAuditEvent(ctx, 'shell.command.error', { command: name, stage: pi, reason: 'policy_deny' }) continue } const sandboxOn = env.BARE_OS_SHELL_SANDBOX === '1' || env.BARE_OS_SHELL_SANDBOX === 'true' if (sandboxOn && !isShellBuiltin(name, env) && name !== 'command' && name !== 'type') { origErr.call(ctx.console, 'shell: sandbox blocks external command: ' + name) ctx.exitCode = 126 recordStageExit(ctx.exitCode) appendShellAuditEvent(ctx, 'shell.command.error', { command: name, stage: pi, reason: 'sandbox_block' }) continue } /** @type {string | null} */ let resolvedRedirOut = null /** @type {string | null} */ let resolvedRedirErr = null if (cmd.redirOut) { const ps = await expandShellWordTokens(ctx, cmd.redirOut, env, { redirect: true }) if (ps.length === 0) { origErr.call(ctx.console, 'shell: stdout redirect: no match') ctx.exitCode = 1 continue } if (ps.length > 1) { origErr.call(ctx.console, 'shell: stdout redirect: ambiguous') ctx.exitCode = 1 continue } resolvedRedirOut = ps[0] if (shellUnsafeRedirectPath(resolvedRedirOut, env)) { origErr.call(ctx.console, 'shell: unsafe stdout redirect path denied') ctx.exitCode = 1 recordStageExit(ctx.exitCode) appendShellAuditEvent(ctx, 'shell.command.error', { command: name, stage: pi, reason: 'unsafe_redirect_stdout', path: resolvedRedirOut }) continue } } if (cmd.redirErr && !cmd.mergeStderrToStdout) { const ps = await expandShellWordTokens(ctx, cmd.redirErr, env, { redirect: true }) if (ps.length === 0) { origErr.call(ctx.console, 'shell: stderr redirect: no match') ctx.exitCode = 1 continue } if (ps.length > 1) { origErr.call(ctx.console, 'shell: stderr redirect: ambiguous') ctx.exitCode = 1 continue } resolvedRedirErr = ps[0] if (shellUnsafeRedirectPath(resolvedRedirErr, env)) { origErr.call(ctx.console, 'shell: unsafe stderr redirect path denied') ctx.exitCode = 1 recordStageExit(ctx.exitCode) appendShellAuditEvent(ctx, 'shell.command.error', { command: name, stage: pi, reason: 'unsafe_redirect_stderr', path: resolvedRedirErr }) continue } } if (cmd.redirHereDoc) { stdinText = expandWord(cmd.redirHereDoc, env) } else if (cmd.redirIn) { const paths = await expandShellWordTokens(ctx, cmd.redirIn, env, { redirect: true }) if (paths.length === 0) { origErr.call(ctx.console, 'shell: stdin redirect: no match') ctx.exitCode = 1 continue } if (paths.length > 1) { origErr.call(ctx.console, 'shell: stdin redirect: ambiguous') ctx.exitCode = 1 continue } if (shellUnsafeRedirectPath(paths[0], env)) { origErr.call(ctx.console, 'shell: unsafe stdin redirect path denied') ctx.exitCode = 1 recordStageExit(ctx.exitCode) appendShellAuditEvent(ctx, 'shell.command.error', { command: name, stage: pi, reason: 'unsafe_redirect_stdin', path: paths[0] }) continue } const buf = await vfs.readFile(paths[0]) stdinText = buf ? ctx.b4a.toString(buf) : '' } else if (pi === 0 && typeof ctx.shellHeredocOnce === 'string') { stdinText = ctx.shellHeredocOnce delete ctx.shellHeredocOnce } const outChunks = [] const errChunks = [] const capOut = !isLast || cmd.redirOut != null const mergeErr = cmd.mergeStderrToStdout const capErrSeparate = cmd.redirErr != null && !mergeErr const checkCaptureSize = (chunks) => { const joined = chunks.join('') if (joined.length > lim.maxBytes) { throw new Error( `shell: pipeline output exceeds BARE_OS_PIPELINE_MAX_BYTES (${lim.maxBytes})` ) } const lineCount = joined.split('\n').length - 1 if (lineCount > lim.maxLines) { throw new Error( `shell: pipeline output exceeds BARE_OS_PIPELINE_MAX_LINES (${lim.maxLines})` ) } } const pushOut = (...args) => { const line = args.map(String).join(' ') + '\n' outChunks.push(line) const st = ctx.bareOsSessionStats if (st && typeof st.pipelineBytesTotal === 'number') { st.pipelineBytesTotal += line.length } checkCaptureSize(outChunks) } /** @param {string | Uint8Array} chunk */ const pushOutRaw = (chunk) => { const text = bareOsPipelineRawChunkToText(chunk) outChunks.push(text) const st = ctx.bareOsSessionStats if (st && typeof st.pipelineBytesTotal === 'number') { st.pipelineBytesTotal += text.length } checkCaptureSize(outChunks) } const pushErr = (...args) => { const line = args.map(String).join(' ') + '\n' errChunks.push(line) const st = ctx.bareOsSessionStats if (st && typeof st.pipelineBytesTotal === 'number') { st.pipelineBytesTotal += line.length } checkCaptureSize(errChunks) } for (const [k, val] of Object.entries(cmd.assign)) { if ( ctx.shellReadonlyVars instanceof Set && ctx.shellReadonlyVars.has(k) ) { origErr.call(ctx.console, k + ': readonly variable') ctx.exitCode = 1 continue } env[k] = expandWord(val, env) } if ( env.BARE_OS_SHELL_PIPEFAIL === '1' || env.BARE_OS_SHELL_PIPEFAIL === 'true' ) { pipelineWantsPipefail = true } if (!cmd.argv.length) { ctx.exitCode = 0 continue } ctx.exitCode = 0 if (capOut) { ctx.console.log = pushOut if (mergeErr) ctx.console.error = pushOut else if (capErrSeparate) ctx.console.error = pushErr else ctx.console.error = origErr } else { ctx.console.log = origLog if (capErrSeparate) ctx.console.error = pushErr else if (mergeErr) ctx.console.error = origLog else ctx.console.error = origErr } let code = 'ok' try { if ( ctx.shellFunctions && typeof ctx.shellFunctions === 'object' && Object.prototype.hasOwnProperty.call(ctx.shellFunctions, name) ) { const fn = ctx.shellFunctions[name] const maxDepth = Number.parseInt( String(env.BARE_OS_SHELL_FUNCTION_MAX_DEPTH || '32'), 10 ) const cap = Number.isFinite(maxDepth) && maxDepth > 0 ? Math.min(maxDepth, 128) : 32 const curDepth = typeof ctx.shellFunctionDepth === 'number' ? ctx.shellFunctionDepth : 0 if (curDepth >= cap) { origErr.call( ctx.console, `shell: function recursion too deep (max ${cap})` ) ctx.exitCode = 1 continue } const prevEnv = vfs.env const fnEnv = env const prevPositional = {} for (const k of ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '#']) { prevPositional[k] = fnEnv[k] } assignShellFunctionPositionalEnv(fnEnv, argv) vfs.env = fnEnv ctx.env = fnEnv ctx.shellFunctionDepth = curDepth + 1 try { const fnResult = await runWithShellPipelineStageTimeout( env, () => execSemicolonLists(ctx, fn.body), `function ${name}` ) if (fnResult === 'exit') code = 'exit' } finally { for (const [k, v] of Object.entries(prevPositional)) { if (v == null) delete fnEnv[k] else fnEnv[k] = v } ctx.shellFunctionDepth = curDepth vfs.env = prevEnv ctx.env = prevEnv } continue } if (isShellBuiltin(name, env) && isExecLineBuiltinDenied(name, env)) { origErr.call( ctx.console, 'shell: builtin denied by boot policy: ' + name ) ctx.exitCode = 126 continue } if (name === 'alias') { if (argv.length === 1) { const al = ctx.shellAliases || {} for (const k of Object.keys(al).sort()) { origLog.call(ctx.console, `${k}='${al[k]}'`) } } else { let okCount = 0 for (const part of argv.slice(1)) { if (applyAliasDefinition(ctx, part)) okCount++ } if (okCount === 0) { origErr.call( ctx.console, 'alias: usage: alias name=value [name=value ...]' ) ctx.exitCode = 1 } } } else if (name === 'unalias') { runUnaliasBuiltin(ctx, argv, (m) => { origErr.call(ctx.console, m) ctx.exitCode = 1 }) } else if (name === 'barerc') { const sub = argv[1] if (sub === 'reload') { await loadBarerc(ctx, { createSkeletonIfMissing: false }) } else { origErr.call(ctx.console, 'barerc: usage: barerc reload') ctx.exitCode = 1 } } else if (name === 'cd') { try { await vfs.chdir(argv[1] || vfs.home) } catch (e) { origErr.call(ctx.console, (e && e.message) || String(e)) ctx.exitCode = 1 } } else if (name === 'export') { const args = argv.slice(1) if (args.length === 1 && args[0] === '-p') { const keys = Object.keys(env).sort((a, b) => a.localeCompare(b)) for (const k of keys) { const v = String(env[k] ?? '') const q = "'" + v.replace(/'/g, "'\\''") + "'" origLog.call(ctx.console, 'export ' + k + '=' + q) } } else { let sawErr = false for (const a of args) { if (a === '-p') { origErr.call( ctx.console, 'export: -p must be the only argument' ) ctx.exitCode = 2 sawErr = true break } if (a.startsWith('-')) { origErr.call(ctx.console, 'export: unsupported option: ' + a) ctx.exitCode = 2 sawErr = true break } const eq = a.indexOf('=') if (eq > 0) { const k = a.slice(0, eq) if (!isValidShellIdentifier(k)) { origErr.call(ctx.console, `export: not an identifier: ${k}`) ctx.exitCode = 1 sawErr = true continue } if ( ctx.shellReadonlyVars instanceof Set && ctx.shellReadonlyVars.has(k) ) { origErr.call(ctx.console, k + ': readonly variable') ctx.exitCode = 1 sawErr = true continue } env[k] = expandWord(a.slice(eq + 1), env) } else { if (!isValidShellIdentifier(a)) { origErr.call(ctx.console, `export: not an identifier: ${a}`) ctx.exitCode = 1 sawErr = true continue } if (!Object.prototype.hasOwnProperty.call(env, a)) env[a] = '' } } if (!sawErr) ctx.exitCode = 0 } } else if (name === 'unset') { if (!ctx.shellReadonlyVars) ctx.shellReadonlyVars = new Set() for (const a of argv.slice(1)) { if (a.startsWith('-')) continue if (ctx.shellReadonlyVars.has(a)) { origErr.call( ctx.console, 'unset: ' + a + ': cannot unset: readonly variable' ) ctx.exitCode = 1 continue } delete env[a] } } else if (name === 'readonly') { if (!ctx.shellReadonlyVars) ctx.shellReadonlyVars = new Set() const args = argv.slice(1) if (args.length === 1 && args[0] === '-p') { const keys = [...ctx.shellReadonlyVars].sort((a, b) => a.localeCompare(b) ) for (const k of keys) { const v = String(env[k] ?? '') const q = "'" + v.replace(/'/g, "'\\''") + "'" origLog.call(ctx.console, 'readonly ' + k + '=' + q) } } else { for (const a of args) { if (a === '-p') { origErr.call( ctx.console, 'readonly: -p must be the only argument' ) ctx.exitCode = 2 break } if (a.startsWith('-')) { origErr.call(ctx.console, 'readonly: unsupported option: ' + a) ctx.exitCode = 2 break } const eq = a.indexOf('=') if (eq > 0) { const k = a.slice(0, eq) env[k] = expandWord(a.slice(eq + 1), env) ctx.shellReadonlyVars.add(k) } else ctx.shellReadonlyVars.add(a) } } } else if (name === 'umask') { if (argv[1] != null) { const oct = argv[1] if (!/^[0-7]{1,4}$/.test(oct)) { origErr.call(ctx.console, 'umask: invalid octal mask') ctx.exitCode = 1 } else { env.UMASK = oct } } else { const u = env.UMASK || '022' origLog.call(ctx.console, String(u).padStart(4, '0')) } } else if (name === 'set') { const args = argv.slice(1) if ( args.length === 1 && (args[0] === '-o' || args[0] === '+o') ) { const on = (v) => v === '1' || v === 'true' ? 'on' : 'off' origLog.call( ctx.console, `errexit ${on(env.BARE_OS_SHELL_ERREXIT)}` ) origLog.call( ctx.console, `nounset ${on(env.BARE_OS_SHELL_NOUNSET)}` ) origLog.call( ctx.console, `pipefail ${on(env.BARE_OS_SHELL_PIPEFAIL)}` ) origLog.call( ctx.console, `noglob ${on(env.BARE_OS_SHELL_NOGLOB)}` ) } else if (args.length === 1 && args[0] === '-f') { env.BARE_OS_SHELL_NOGLOB = '1' } else if (args.length === 1 && args[0] === '+f') { delete env.BARE_OS_SHELL_NOGLOB } else if ( args.length === 2 && args[0] === '-o' && args[1] === 'errexit' ) { env.BARE_OS_SHELL_ERREXIT = '1' } else if ( args.length === 2 && args[0] === '+o' && args[1] === 'errexit' ) { delete env.BARE_OS_SHELL_ERREXIT } else if (args.length === 1 && args[0] === '-e') { env.BARE_OS_SHELL_ERREXIT = '1' } else if (args.length === 1 && args[0] === '+e') { delete env.BARE_OS_SHELL_ERREXIT } else if ( args.length === 2 && args[0] === '-o' && args[1] === 'nounset' ) { env.BARE_OS_SHELL_NOUNSET = '1' } else if ( args.length === 2 && args[0] === '+o' && args[1] === 'nounset' ) { delete env.BARE_OS_SHELL_NOUNSET } else if (args.length === 1 && args[0] === '-u') { env.BARE_OS_SHELL_NOUNSET = '1' } else if (args.length === 1 && args[0] === '+u') { delete env.BARE_OS_SHELL_NOUNSET } else if ( args.length === 2 && args[0] === '-o' && args[1] === 'pipefail' ) { env.BARE_OS_SHELL_PIPEFAIL = '1' } else if ( args.length === 2 && args[0] === '+o' && args[1] === 'pipefail' ) { delete env.BARE_OS_SHELL_PIPEFAIL } else { origErr.call( ctx.console, 'set: unsupported arguments (only -f / +f / -e / +e / -u / +u / -o errexit|nounset|pipefail / +o errexit|nounset|pipefail)' ) ctx.exitCode = 1 } } else if (name === ':') { /* no-op */ } else if (name === 'command') { const cargs = argv.slice(1) if (cargs.length === 0) { origErr.call(ctx.console, 'command: missing operand') ctx.exitCode = 1 } else if (cargs[0] === '-v' || cargs[0] === '-V') { const cmdn = cargs[1] if (!cmdn) { origErr.call(ctx.console, 'command: missing operand') ctx.exitCode = 1 } else if (isShellBuiltin(cmdn, env)) { origLog.call(ctx.console, cmdn) } else { const p = await resolveBinInPath(ctx, cmdn) if (p) origLog.call(ctx.console, p) else { origErr.call(ctx.console, 'command: ' + cmdn + ': not found') ctx.exitCode = 1 } } } else { const childCtx = bareOsPipelineChildCtx(ctx, env, stdinText, capOut) if (capOut) childCtx.bareOsBinWrite = pushOutRaw await runWithShellPipelineStageTimeout( env, () => runBinCommand(childCtx, cargs), cargs[0] || 'command' ) mergePipelineChildCtx(ctx, childCtx) } } else if (name === 'type') { const cmdn = argv[1] if (!cmdn) { origErr.call(ctx.console, 'type: missing operand') ctx.exitCode = 1 } else if (isShellBuiltin(cmdn, env)) { origLog.call(ctx.console, cmdn + ' is a shell builtin') } else { const p = await resolveBinInPath(ctx, cmdn) if (p) origLog.call(ctx.console, cmdn + ' is ' + p) else { origErr.call(ctx.console, 'type: ' + cmdn + ': not found') ctx.exitCode = 1 } } } else if (name === 'logout') { const save = argv.includes('--save') if (typeof ctx.applyLogout === 'function') { try { await ctx.applyLogout({ save }) } catch (e) { origErr.call(ctx.console, (e && e.message) || String(e)) ctx.exitCode = 1 } } else { origErr.call( ctx.console, 'logout: not supported in this environment' ) ctx.exitCode = 1 } } else if (name === 'jobs') { const list = ctx.shellBackgroundJobs?.list || [] const ja = argv.slice(1) let showPgidOnly = false let longFmt = false for (const a of ja) { if (a === '-p') showPgidOnly = true else if (a === '-l') longFmt = true } if (!list.length) { origLog.call(ctx.console, '') } else if (showPgidOnly) { for (const j of list) { if (typeof j.pgid === 'number') origLog.call(ctx.console, String(j.pgid)) } } else { for (const j of list) { let st = 'Running' if (j.done) st = 'Done' else if (j.stopped) st = 'Stopped' const pg = typeof j.pgid === 'number' && typeof j.sid === 'number' ? ` sid=${j.sid} pgid=${j.pgid}` : '' const syn = longFmt && typeof j.id === 'number' ? ` pid=${4100 + j.id}` : '' origLog.call( ctx.console, `[${j.id}]+ ${st}${pg}${syn} ${j.label}` ) } } } else if (name === 'fg') { const list = ctx.shellBackgroundJobs?.list || [] let candidates = list.filter((j) => !j.done) const arg = argv[1] if (arg) { const raw = arg.startsWith('%') ? arg.slice(1) : arg const id = Number.parseInt(raw, 10) if (Number.isFinite(id)) { candidates = list.filter((j) => j.id === id) } } const j = candidates.length ? candidates[candidates.length - 1] : null if (!j) { origErr.call(ctx.console, 'fg: no such job') ctx.exitCode = 1 } else { if (ctx.shellSessionState && typeof j.pgid === 'number') { ctx.shellSessionState.foregroundPgid = j.pgid } if (j.stopped) j.stopped = false try { await j.promise } catch { /* background errors already logged */ } if (ctx.shellSessionState) { ctx.shellSessionState.foregroundPgid = ctx.shellSessionState.sid ?? 1 } ctx.exitCode = 0 } } else if (name === 'bg') { const list = ctx.shellBackgroundJobs?.list || [] const stopped = list.filter((j) => j && !j.done && j.stopped) if (!stopped.length) { origErr.call(ctx.console, 'bg: no stopped jobs') ctx.exitCode = 1 } else { let targets = stopped const arg = argv[1] if (arg) { const raw = arg.startsWith('%') ? arg.slice(1) : arg const jid = Number.parseInt(raw, 10) if (Number.isFinite(jid)) { targets = stopped.filter((j) => j.id === jid) } } if (!targets.length) { origErr.call(ctx.console, 'bg: no stopped jobs') ctx.exitCode = 1 } else { for (const j of targets) j.stopped = false ctx.exitCode = 0 } } } else if (name === 'suspend-job') { const list = ctx.shellBackgroundJobs?.list || [] let candidates = list.filter((j) => j && !j.done && !j.stopped) const arg = argv[1] if (arg) { const raw = arg.startsWith('%') ? arg.slice(1) : arg const jid = Number.parseInt(raw, 10) if (Number.isFinite(jid)) { candidates = list.filter((j) => j && !j.done && j.id === jid) } } const j = candidates.length ? candidates[candidates.length - 1] : null if (!j) { origErr.call(ctx.console, 'suspend-job: no such job') ctx.exitCode = 1 } else { j.stopped = true ctx.exitCode = 0 } } else if (name === 'disown') { if (!ctx.shellBackgroundJobs || !Array.isArray(ctx.shellBackgroundJobs.list)) { origErr.call(ctx.console, 'disown: no job control') ctx.exitCode = 1 } else { const list = ctx.shellBackgroundJobs.list const arg = argv[1] /** @type {typeof list[number][]} */ let targets = [] if (!arg) { const running = list.filter((j) => j && !j.done) const j = running.length ? running[running.length - 1] : null if (j) targets = [j] } else { const raw = arg.startsWith('%') ? arg.slice(1) : arg const jid = Number.parseInt(raw, 10) if (Number.isFinite(jid)) { targets = list.filter((x) => x && x.id === jid) } } if (!targets.length) { origErr.call(ctx.console, 'disown: no such job') ctx.exitCode = 1 } else { for (const t of targets) { const idx = list.indexOf(t) if (idx >= 0) list.splice(idx, 1) } ctx.exitCode = 0 } } } else if (name === 'trap') { if (argv[1] === '-l' || argv[1] === '--list') { ctx.console.log('HUP INT KILL TERM PIPE CHLD USR1 USR2 EXIT') } else if (argv[1] === '-p') { const h = ctx.shellTrapHandlers && typeof ctx.shellTrapHandlers === 'object' ? ctx.shellTrapHandlers : {} for (const k of Object.keys(h)) { ctx.console.log( `trap -- '${String( /** @type {Record} */ (h)[k] ).replace(/'/g, `'\\''`)}' ${k}` ) } } else if (argv.length < 3) { origErr.call(ctx.console, 'trap: usage: trap COMMAND SIGNAL') ctx.exitCode = 1 } else { if (!ctx.shellTrapHandlers || typeof ctx.shellTrapHandlers !== 'object') ctx.shellTrapHandlers = Object.create(null) const cmd = argv[1] const sig = normalizeShellSignalName(argv[2] || '') if (cmd === '-' || cmd === '') { delete /** @type {Record} */ (ctx.shellTrapHandlers)[ sig ] } else { /** @type {Record} */ (ctx.shellTrapHandlers)[sig] = cmd } } } else if (name === 'wait') { const list = ctx.shellBackgroundJobs?.list || [] const shEnv = ctx.env || {} const posixShellMode = shEnv.BARE_OS_SHELL_POSIX_MODE === '1' || shEnv.BARE_OS_SHELL_POSIX_MODE === 'true' let jobArg = argv[1] let waitAny = false if (posixShellMode && jobArg === '-n') { waitAny = true jobArg = argv[2] } const syntheticPidToJobId = (pidText) => { const n = Number.parseInt(String(pidText || ''), 10) if (!Number.isFinite(n)) return null return n >= 4101 ? n - 4100 : null } /** @type {{ id: number, promise: Promise, done?: boolean }[]} */ let target = list.filter((j) => j && !j.done) if (waitAny && !jobArg) { if (!target.length) { ctx.exitCode = 0 } else { try { const doneJob = await Promise.race( target.map((j) => j.promise.then(() => j)) ) const ec = doneJob && typeof doneJob === 'object' && typeof doneJob.lastExitCode === 'number' ? doneJob.lastExitCode : 0 ctx.exitCode = ec } catch { ctx.exitCode = 1 } } } else { if (jobArg) { const argText = String(jobArg) if (argText === 'all') { target = list.filter((j) => j) } else { const raw = argText.startsWith('%') ? argText.slice(1) : argText const id = Number.parseInt(raw, 10) const pidMapped = syntheticPidToJobId(argText) const wantId = pidMapped != null ? pidMapped : Number.isFinite(id) ? id : null if (Number.isFinite(wantId)) { target = list.filter((j) => j && j.id === wantId) } } } if (!target.length) { origErr.call(ctx.console, 'wait: no such job') ctx.exitCode = 1 } else { try { await Promise.all(target.map((j) => j.promise)) let ec = 0 for (const j of target) { const c = j && typeof j.lastExitCode === 'number' ? j.lastExitCode : 0 if (c !== 0) ec = c } ctx.exitCode = ec } catch { ctx.exitCode = 1 } } } } else if (name === 'test' || name === '[') { /** @type {string[]} */ let testArgv if (name === '[') { if (argv.length < 2) { origErr.call(ctx.console, '[: missing ]') ctx.exitCode = 2 } else if (argv[argv.length - 1] !== ']') { origErr.call(ctx.console, '[: expected ]') ctx.exitCode = 2 } else { testArgv = ['test', ...argv.slice(1, -1)] } } else { testArgv = argv } if (testArgv) { const childCtx = bareOsPipelineChildCtx(ctx, env, stdinText, capOut) if (capOut) childCtx.bareOsBinWrite = pushOutRaw try { await runWithShellPipelineStageTimeout( env, () => runBinCommand(childCtx, testArgv), testArgv[0] || 'test' ) mergePipelineChildCtx(ctx, childCtx) if (childCtx.bareOsScriptCompletedWithCatch) { ctx.bareOsPipelineStageError = true delete childCtx.bareOsScriptCompletedWithCatch } } catch (e) { origErr.call(ctx.console, (e && e.message) || String(e)) ctx.exitCode = 1 ctx.bareOsPipelineStageError = true } } } else if (name === 'read') { await runShellReadBuiltin(ctx, argv, env, origErr) } else if (name === 'exit') { code = 'exit' let ec = 0 if (argv[1] !== undefined) { const n = Number.parseInt(argv[1], 10) ec = Number.isFinite(n) ? n : 0 } await dispatchShellTrapSignal(ctx, 'EXIT') ctx.exitCode = ec if (typeof ctx.requestBooterExit === 'function') { ctx.requestBooterExit(ec) } } else { const childCtx = bareOsPipelineChildCtx(ctx, env, stdinText, capOut) if (capOut) childCtx.bareOsBinWrite = pushOutRaw try { await runWithShellPipelineStageTimeout( env, () => runBinCommand(childCtx, argv), argv[0] || 'bin' ) mergePipelineChildCtx(ctx, childCtx) if (childCtx.bareOsScriptCompletedWithCatch) { ctx.bareOsPipelineStageError = true delete childCtx.bareOsScriptCompletedWithCatch } } catch (e) { origErr.call(ctx.console, (e && e.message) || String(e)) ctx.exitCode = 1 ctx.bareOsPipelineStageError = true } if (name === '/bin/exit' || name.endsWith('/exit')) { code = 'exit' } } appendShellAuditEvent(ctx, 'shell.command.finish', { command: name, stage: pi, exitCode: Number(ctx.exitCode) || 0 }) } finally { if (capOut || capErrSeparate || mergeErr) { ctx.console.log = origLog ctx.console.error = origErr } } if (code === 'exit') return 'exit' if (resolvedRedirErr) { const epath = resolvedRedirErr const edata = ctx.b4a.from(errChunks.join('')) if (cmd.redirErrAppend) { const prev = await vfs.readFile(epath) const merged = prev ? ctx.b4a.concat([prev, edata]) : edata await vfs.writeFile(epath, merged) } else { await vfs.writeFile(epath, edata) } } let pipeOut = outChunks.join('') if (resolvedRedirOut) { const path = resolvedRedirOut const data = ctx.b4a.from(pipeOut) if (cmd.redirAppend) { const prev = await vfs.readFile(path) const merged = prev ? ctx.b4a.concat([prev, data]) : data await vfs.writeFile(path, merged) } else { await vfs.writeFile(path, data) } pipeOut = '' } pipelineLastExit = Number(ctx.exitCode) || 0 pipelineStageExits.push(pipelineLastExit) if ( pipelineWantsPipefail && pipefailFirstNonZero === 0 && pipelineLastExit !== 0 ) { pipefailFirstNonZero = pipelineLastExit } if (ctx.bareOsPipelineStageError) { delete ctx.bareOsPipelineStageError return 'ok' } stdinText = isLast ? null : pipeOut ctx.shellStdin = stdinText ?? undefined } ctx.exitCode = pipelineWantsPipefail ? pipefailFirstNonZero !== 0 ? pipefailFirstNonZero : pipelineLastExit : pipelineLastExit if ( env.BARE_OS_SHELL_PIPESTATUS === '1' || env.BARE_OS_SHELL_PIPESTATUS === 'true' ) { env.BARE_OS_PIPESTATUS = pipelineStageExits.join(' ') } return 'ok' } catch (e) { ctx.console.error((e && e.message) || String(e)) ctx.exitCode = 1 return 'ok' } } /** * @param {Record} ctx * @param {Token[]} toks */ function scheduleBackgroundShell(ctx, toks) { if (!ctx.shellBackgroundJobs) { ctx.shellBackgroundJobs = { nextId: 1, list: [] } } if (!ctx.shellSessionState) { ctx.shellSessionState = { sid: 1, nextPgid: 300, foregroundPgid: 1, controllingTty: '/dev/console' } } else if (ctx.shellSessionState.foregroundPgid == null) { ctx.shellSessionState.foregroundPgid = ctx.shellSessionState.sid } if (!ctx.shellSessionState.controllingTty) { const fifo = (ctx.env && ctx.env.BARE_OS_SESSION_FIFO) || (ctx.env && ctx.env.BARE_OS_IPC_SESSION_FIFO) || '' ctx.shellSessionState.controllingTty = fifo ? `ipc:${String(fifo).trim()}` : '/dev/console' } const id = ctx.shellBackgroundJobs.nextId++ const pgid = ctx.shellSessionState.nextPgid++ const sid = ctx.shellSessionState.sid const controlIpc = `job-${sid}-${pgid}-ctl` const dataIpc = `job-${sid}-${pgid}-data` const label = toks .filter((t) => t.type === 'word') .map((t) => t.value) .slice(0, 6) .join(' ') const childCtx = Object.assign({}, ctx) if (ctx.env && typeof ctx.env === 'object') { childCtx.env = { ...ctx.env } } if (ctx.vfs && typeof ctx.vfs === 'object' && ctx.vfs.env && typeof ctx.vfs.env === 'object') { childCtx.vfs = Object.assign( Object.create(Object.getPrototypeOf(ctx.vfs)), ctx.vfs, { env: childCtx.env && typeof childCtx.env === 'object' ? childCtx.env : { ...ctx.vfs.env } } ) } childCtx.shellBackgroundJobs = { nextId: 1, list: [] } if (ctx.shellSessionState && typeof ctx.shellSessionState === 'object') { childCtx.shellSessionState = { ...ctx.shellSessionState } } /** @type {{ id: number, label: string, promise: Promise, done: boolean, stopped: boolean, pgid: number, sid: number, jobControlModel: string, controlIpc: string, dataIpc: string, terminate?: () => Promise, _terminatePromise?: Promise, lastExitCode?: number }} */ const entry = { id, label, promise: /** @type {Promise} */ (Promise.resolve('pending')), done: false, stopped: false, pgid, sid, jobControlModel: 'logical_no_fork', controlIpc, dataIpc, lastExitCode: 0 } entry.promise = (async () => { const statements = splitTopLevelStatements(toks) for (const stmt of statements) { await waitWhileShellJobStopped(entry) if (!stmt.length) continue const r = await dispatchShellStatement(childCtx, stmt) if (r === 'exit') { entry.lastExitCode = typeof childCtx.exitCode === 'number' ? childCtx.exitCode : 0 return r } } syncBareOsExitStatusEnv(childCtx) entry.lastExitCode = typeof childCtx.exitCode === 'number' ? childCtx.exitCode : 0 return 'ok' })() ctx.shellBackgroundJobs.list.push(entry) entry.terminate = async () => { if (entry._terminatePromise) return entry._terminatePromise entry._terminatePromise = (async () => { entry.stopped = true entry.done = true })() return entry._terminatePromise } entry.promise.finally(() => { entry.done = true }) ctx.console.error(`[${id}] (sid=${sid} pgid=${pgid}) ${label || '(job)'} &`) } /** * `if` COMPOUND `then` COMPOUND [ `else` COMPOUND ] `fi` * Condition / branch bodies use the same `&&` / `||` / `|` rules as a normal line. * @param {Record} ctx * @param {Token[]} tokens * @returns {Promise<'exit' | 'ok'>} */ /** * Run `;`-separated lists (same as outside `if`); last command sets exit status. * @param {Record} ctx * @param {Token[]} toks */ /** * @param {Record} ctx * @param {Token[]} toks * @param {{ suppressErrexit?: boolean }} [opts] */ async function execSemicolonLists(ctx, toks, opts) { const lists = splitTokensBySemicolon(toks) const shE = ctx.vfs?.env const suppressErrexit = opts && opts.suppressErrexit === true const errexitOn = !suppressErrexit && shE && (shE.BARE_OS_SHELL_ERREXIT === '1' || shE.BARE_OS_SHELL_ERREXIT === 'true') for (const list of lists) { if (!list.length) continue const r = await execAndOrList(ctx, list) if (r === 'exit') return 'exit' if (errexitOn && (Number(ctx.exitCode) || 0) !== 0) return 'ok' } return 'ok' } /** * @param {Record} ctx * @param {Token[]} bodyToks * @returns {Promise<'ok' | 'exit' | 'break' | 'continue'>} */ async function execLoopBody(ctx, bodyToks) { const lists = splitTokensBySemicolon(bodyToks) for (const list of lists) { if (!list.length) continue const first = list[0] if (first?.type === 'word' && first.value === 'break') { ctx.exitCode = 0 return 'break' } if (first?.type === 'word' && first.value === 'continue') { ctx.exitCode = 0 return 'continue' } const r = await dispatchShellStatement(ctx, list) if (r === 'exit') return 'exit' } return 'ok' } /** @param {string} name */ /** * @param {Record} ctx * @param {Token[]} tokens * @returns {Promise<'exit' | 'ok'>} */ async function execWhileConstruct(ctx, tokens) { const split = findWhileDoSplit(tokens) if (split < 0) { ctx.console.error('shell: while: expected "; do …; done"') ctx.exitCode = 2 return 'ok' } const last = tokens[tokens.length - 1] if (last.type !== 'word' || last.value !== 'done') { ctx.console.error('shell: while: missing done') ctx.exitCode = 2 return 'ok' } const condToks = tokens.slice(1, split) const bodyToks = tokens.slice(split + 2, tokens.length - 1) const maxIter = Number.parseInt( ctx.vfs?.env?.BARE_OS_SHELL_LOOP_MAX || '10000', 10 ) const cap = Number.isFinite(maxIter) && maxIter > 0 ? maxIter : 10000 for (let i = 0; i < cap; i++) { const r0 = await execSemicolonLists(ctx, condToks, { suppressErrexit: true }) if (r0 === 'exit') return 'exit' if ((Number(ctx.exitCode) || 0) !== 0) break const r1 = await execLoopBody(ctx, bodyToks) if (r1 === 'exit') return 'exit' if (r1 === 'break') break if (r1 === 'continue') continue } return 'ok' } /** * `until test-commands; do consequent-commands; done` — opposite exit test vs while (Issue 7–style). * Gated by **`BARE_OS_SHELL_UNTIL=1`** or **`true`**. * @param {Record} ctx * @param {Token[]} tokens * @returns {Promise<'exit' | 'ok'>} */ async function execUntilConstruct(ctx, tokens) { const split = findWhileDoSplit(tokens) if (split < 0) { ctx.console.error('shell: until: expected "; do …; done"') ctx.exitCode = 2 return 'ok' } const last = tokens[tokens.length - 1] if (last.type !== 'word' || last.value !== 'done') { ctx.console.error('shell: until: missing done') ctx.exitCode = 2 return 'ok' } const condToks = tokens.slice(1, split) const bodyToks = tokens.slice(split + 2, tokens.length - 1) const maxIter = Number.parseInt( ctx.vfs?.env?.BARE_OS_SHELL_LOOP_MAX || '10000', 10 ) const cap = Number.isFinite(maxIter) && maxIter > 0 ? maxIter : 10000 for (let i = 0; i < cap; i++) { const r0 = await execSemicolonLists(ctx, condToks, { suppressErrexit: true }) if (r0 === 'exit') return 'exit' if ((Number(ctx.exitCode) || 0) === 0) break const r1 = await execLoopBody(ctx, bodyToks) if (r1 === 'exit') return 'exit' if (r1 === 'break') break if (r1 === 'continue') continue } return 'ok' } /** * @param {Record} ctx * @param {Token[]} tokens * @returns {Promise<'exit' | 'ok'>} */ async function execForConstruct(ctx, tokens) { const last = tokens[tokens.length - 1] if (last.type !== 'word' || last.value !== 'done') { ctx.console.error('shell: for: missing done') ctx.exitCode = 2 return 'ok' } if (tokens.length < 7 || tokens[1].type !== 'word') { ctx.console.error('shell: for: invalid syntax') ctx.exitCode = 2 return 'ok' } if (tokens[2].type !== 'word' || tokens[2].value !== 'in') { ctx.console.error('shell: for: expected `in`') ctx.exitCode = 2 return 'ok' } /** @type {Token[]} */ const inToks = [] let semi = -1 for (let j = 3; j < tokens.length; j++) { const t = tokens[j] if (t.type === 'op' && t.value === ';') { semi = j break } inToks.push(t) } if (semi < 0) { ctx.console.error('shell: for: expected `;` before do') ctx.exitCode = 2 return 'ok' } if (tokens[semi + 1]?.type !== 'word' || tokens[semi + 1].value !== 'do') { ctx.console.error('shell: for: expected `do` after `;`') ctx.exitCode = 2 return 'ok' } const varName = tokens[1].value const bodyToks = tokens.slice(semi + 2, tokens.length - 1) const env = ctx.vfs.env /** @type {string[]} */ const words = [] for (const t of inToks) { if (t.type !== 'word') continue const xs = await expandShellWordTokens( ctx, /** @type {Extract} */ (t), env, {} ) if ( xs.length === 0 && (env.BARE_OS_STRICT_POSIX === '1' || env.BARE_OS_STRICT_POSIX === 'true') ) { ctx.console.error('shell: for `in`: pathname expansion produced no matches') ctx.exitCode = 1 return 'ok' } for (const x of xs) words.push(x) } const maxIter = Number.parseInt( ctx.vfs?.env?.BARE_OS_SHELL_LOOP_MAX || '10000', 10 ) const cap = Number.isFinite(maxIter) && maxIter > 0 ? maxIter : 10000 let total = 0 for (const w of words) { env[varName] = w if (++total > cap) { ctx.console.error('shell: for: exceeded BARE_OS_SHELL_LOOP_MAX') ctx.exitCode = 1 return 'ok' } const r = await execLoopBody(ctx, bodyToks) if (r === 'exit') return 'exit' if (r === 'break') break if (r === 'continue') continue } return 'ok' } /** * `case WORD in pattern) list ;; … esac` — bounded branches; patterns support `|` alternation and `*`. * @param {Record} ctx * @param {Token[]} tokens * @returns {Promise<'exit' | 'ok'>} */ async function execCaseConstruct(ctx, tokens) { const last = tokens[tokens.length - 1] if (last.type !== 'word' || last.value !== 'esac') { ctx.console.error('shell: case: missing esac') ctx.exitCode = 2 return 'ok' } if ( tokens.length < 5 || tokens[1].type !== 'word' || tokens[2].type !== 'word' || tokens[2].value !== 'in' ) { ctx.console.error('shell: case: expected `case WORD in`') ctx.exitCode = 2 return 'ok' } const env = ctx.vfs.env const subj = expandWord(tokens[1].value, env) const maxBranches = Number.parseInt( ctx.vfs?.env?.BARE_OS_SHELL_CASE_MAX_BRANCHES || '32', 10 ) const cap = Number.isFinite(maxBranches) && maxBranches > 0 ? maxBranches : 32 let i = 3 let branches = 0 while (i < tokens.length - 1) { if (++branches > cap) { ctx.console.error( 'shell: case: too many branches (see BARE_OS_SHELL_CASE_MAX_BRANCHES)' ) ctx.exitCode = 2 return 'ok' } let paren = -1 for (let k = i; k < tokens.length - 1; k++) { const t = tokens[k] if (t.type === 'op' && t.value === ')') { paren = k break } } if (paren < 0) { ctx.console.error('shell: case: expected )') ctx.exitCode = 2 return 'ok' } const patToks = tokens.slice(i, paren) let dsemi = -1 let doubleSemiLen = 2 for (let k = paren + 1; k < tokens.length - 1; k++) { const t = tokens[k] const n = tokens[k + 1] if (t.type === 'op' && t.value === ';;') { dsemi = k doubleSemiLen = 1 break } if ( t.type === 'op' && t.value === ';' && n && n.type === 'op' && n.value === ';' ) { dsemi = k doubleSemiLen = 2 break } } if (dsemi < 0) { ctx.console.error('shell: case: expected ;;') ctx.exitCode = 2 return 'ok' } const bodyToks = tokens.slice(paren + 1, dsemi) const pats = casePatternList(patToks, env) const matched = pats.some((p) => casePatternMatches(subj, p)) if (matched) { const r = await execSemicolonLists(ctx, bodyToks) if (r === 'exit') return 'exit' return 'ok' } i = dsemi + doubleSemiLen } ctx.exitCode = 0 return 'ok' } async function dispatchShellStatement(ctx, stmt) { /** Normalize inline brace-expression tokens like `{1..5}` into a word token so * they use normal shell word expansion instead of statement/group operators. */ const normalized = [] for (let i = 0; i < stmt.length; i++) { const a = stmt[i] const b = stmt[i + 1] const c = stmt[i + 2] if ( a?.type === 'op' && a.value === '{' && b?.type === 'word' && c?.type === 'op' && c.value === '}' && (/^-?\d+\.\.-?\d+$/.test(b.value) || b.value.includes(',')) ) { normalized.push({ type: 'word', value: '{' + b.value + '}', parts: [{ q: /** @type {'u'} */ ('u'), t: '{' + b.value + '}' }] }) i += 2 continue } normalized.push(a) } stmt = normalized const head = stmt[0] const shEnv = ctx.vfs?.env const posixMode = shEnv && (shEnv.BARE_OS_SHELL_POSIX_MODE === '1' || shEnv.BARE_OS_SHELL_POSIX_MODE === 'true') const groupingMode = shEnv && (shEnv.BARE_OS_SHELL_GROUPING === '1' || shEnv.BARE_OS_SHELL_GROUPING === 'true') const arithmeticCommandMode = shEnv && (shEnv.BARE_OS_SH_EXTENDED_PROFILE === '1' || shEnv.BARE_OS_SH_EXTENDED_PROFILE === 'true' || shEnv.BARE_OS_SHELL_POSIX_MODE === '1' || shEnv.BARE_OS_SHELL_POSIX_MODE === 'true') if ( arithmeticCommandMode && ((stmt.length >= 4 && stmt[0]?.type === 'op' && stmt[0].value === '(' && stmt[1]?.type === 'op' && stmt[1].value === '(' && stmt[stmt.length - 2]?.type === 'op' && stmt[stmt.length - 2].value === ')' && stmt[stmt.length - 1]?.type === 'op' && stmt[stmt.length - 1].value === ')') || (stmt.length >= 3 && stmt[0]?.type === 'word' && stmt[0].value === '((' && stmt[stmt.length - 1]?.type === 'word' && stmt[stmt.length - 1].value === '))')) ) { const body = stmt[0]?.type === 'word' && stmt[0].value === '((' ? stmt.slice(1, -1) : stmt.slice(2, -2) const expr = body .filter((t) => t.type !== 'op' || (t.value !== ';' && t.value !== '|')) .map((t) => t.value) .join(' ') .trim() if (!expr) { ctx.console.error('shell: arithmetic: empty expression') ctx.exitCode = 2 return 'ok' } try { const n = bareOsEvalArithmeticExpr(expr, shEnv || {}) ctx.exitCode = n === 0 ? 1 : 0 } catch (e) { ctx.console.error((e && e.message) || String(e)) ctx.exitCode = 2 } return 'ok' } if ((posixMode || groupingMode) && head?.type === 'op' && head.value === '(') { let depth = 0 let close = -1 for (let j = 0; j < stmt.length; j++) { const t = stmt[j] if (t.type === 'op' && t.value === '(') depth++ else if (t.type === 'op' && t.value === ')') { depth-- if (depth === 0) { close = j break } } } if (close < 0) { ctx.console.error('shell: grouped list: unmatched (') ctx.exitCode = 2 return 'ok' } if (close !== stmt.length - 1) { ctx.console.error( 'shell: grouped list must span the full statement (no trailing tokens after closing )' ) ctx.exitCode = 2 return 'ok' } return execSemicolonLists(ctx, stmt.slice(1, close)) } const localOn = shEnv && (shEnv.BARE_OS_SHELL_LOCAL_DECLARE === '1' || shEnv.BARE_OS_SHELL_LOCAL_DECLARE === 'true') if (localOn && head?.type === 'word' && head.value === 'local') { return execShellLocalBuiltin(ctx, stmt.slice(1)) } if (localOn && head?.type === 'word' && head.value === 'declare') { const dr = tryExecShellDeclareBuiltin(ctx, stmt.slice(1)) if (dr != null) return dr } if (head?.type === 'word' && head.value === 'select') { ctx.console.error('shell: select is unsupported') ctx.exitCode = 2 return 'ok' } const doubleBracketOn = shEnv && (shEnv.BARE_OS_SHELL_DOUBLE_BRACKET === '1' || shEnv.BARE_OS_SHELL_DOUBLE_BRACKET === 'true') const legacyDoubleBracketOpen = stmt[0]?.type === 'word' && stmt[0].value === '[' && stmt[1]?.type === 'word' && stmt[1].value === '[' const modernDoubleBracketOpen = stmt[0]?.type === 'word' && stmt[0].value === '[[' if (legacyDoubleBracketOpen || modernDoubleBracketOpen) { if (!doubleBracketOn) { ctx.console.error( 'shell: [[ … ]] is not supported (use /bin/test or [ … ]; set BARE_OS_SHELL_DOUBLE_BRACKET=1 for limited ==/!=)' ) ctx.exitCode = 2 return 'ok' } return execDoubleBracketLimited(ctx, stmt) } const fnDecl = parseShellFunctionDeclaration(stmt) if (fnDecl) { if (!ctx.shellFunctions || typeof ctx.shellFunctions !== 'object') { ctx.shellFunctions = Object.create(null) } ctx.shellFunctions[fnDecl.name] = { body: fnDecl.body } ctx.exitCode = 0 return 'ok' } if (head?.type === 'word' && head.value === 'if') return execIfConstruct(ctx, stmt) if (head?.type === 'word' && head.value === 'until') return execUntilConstruct(ctx, stmt) if (head?.type === 'word' && head.value === 'while') return execWhileConstruct(ctx, stmt) if (head?.type === 'word' && head.value === 'for') return execForConstruct(ctx, stmt) if (head?.type === 'word' && head.value === 'case') return execCaseConstruct(ctx, stmt) if (tryReportMisplacedReservedStatementStart(ctx, stmt)) return 'ok' return execAndOrList(ctx, stmt) } async function execIfConstruct(ctx, tokens) { const thenIdx = findThenIndex(tokens, 1) if (thenIdx < 0) { ctx.console.error('shell: syntax error: if without matching then') ctx.exitCode = 2 return 'ok' } const cond = tokens.slice(1, thenIdx) const tail = findElseOrFiAfterThen(tokens, thenIdx + 1) if (!tail) { ctx.console.error('shell: syntax error: if without fi') ctx.exitCode = 2 return 'ok' } let closingFiIdx if (tail.kind === 'fi') { closingFiIdx = tail.idx } else { closingFiIdx = findFiAfterElse(tokens, tail.idx + 1) if (closingFiIdx < 0) { ctx.console.error('shell: syntax error: else without fi') ctx.exitCode = 2 return 'ok' } } if (closingFiIdx !== tokens.length - 1) { ctx.console.error('shell: syntax error: unexpected tokens after fi') ctx.exitCode = 2 return 'ok' } const condLists = splitTokensBySemicolon(cond).filter((c) => c.length) if (!condLists.length) { ctx.console.error('shell: invalid null command') ctx.exitCode = 2 return 'ok' } for (const c of condLists) { const r = await execAndOrList(ctx, c) if (r === 'exit') return 'exit' } const condOk = (Number(ctx.exitCode) || 0) === 0 if (tail.kind === 'fi') { const thenToks = tokens.slice(thenIdx + 1, tail.idx) if (condOk) { const r = await execSemicolonLists(ctx, thenToks) if (r === 'exit') return 'exit' } else { ctx.exitCode = 0 } return 'ok' } const elseIdx = tail.idx const thenToks = tokens.slice(thenIdx + 1, elseIdx) const elseToks = tokens.slice(elseIdx + 1, closingFiIdx) if (condOk) { const r = await execSemicolonLists(ctx, thenToks) if (r === 'exit') return 'exit' } else { const r = await execSemicolonLists(ctx, elseToks) if (r === 'exit') return 'exit' } return 'ok' } /** * @param {Record} ctx * @param {Token[]} tokens * @returns {Promise<'exit' | 'ok'>} */ async function execAndOrList(ctx, tokens) { const { segments, ops } = splitTokensByAndOr(tokens) for (let s = 0; s < segments.length; s++) { if (!segmentHasCommand(segments[s])) { ctx.console.error('shell: invalid null command') ctx.exitCode = 2 return 'ok' } } let lastStatus = 0 for (let i = 0; i < segments.length; i++) { if (i > 0) { const op = ops[i - 1] if (op === '&&' && lastStatus !== 0) continue if (op === '||' && lastStatus === 0) continue } let pipeline try { pipeline = parsePipeline(segments[i]) } catch (e) { const code = e && /** @type {{ code?: string }} */ (e).code if (code === 'BARE_OS_SHELL_ERROR') { ctx.console.error((e && /** @type {Error} */ (e).message) || String(e)) ctx.exitCode = 2 syncBareOsExitStatusEnv(ctx) return 'ok' } throw e } const r = await execParsedPipeline(ctx, pipeline) if (r === 'exit') return 'exit' lastStatus = Number(ctx.exitCode) || 0 } return 'ok' } /** * @param {Record} ctx * @param {string} line * @returns {Promise<'exit' | 'ok'>} */ export async function execShellLine(ctx, line) { const raw = line.trim() if (!raw) return 'ok' const vfs = ctx.vfs const shellLocalOn = vfs?.env && (vfs.env.BARE_OS_SHELL_LOCAL_DECLARE === '1' || vfs.env.BARE_OS_SHELL_LOCAL_DECLARE === 'true') /** @type {Record | null} */ let savedEnv = null if (shellLocalOn && vfs?.env && typeof vfs.env === 'object') { savedEnv = vfs.env vfs.env = { ...savedEnv } } try { return await execShellLineInner(ctx, raw) } catch (e) { const code = e && /** @type {{ code?: string }} */ (e).code if (code === BARE_OS_SHELL_NOUNSET_ERROR) { ctx.console.error('shell: ' + ((e && e.message) || 'unbound variable')) ctx.exitCode = 1 syncBareOsExitStatusEnv(ctx) return 'ok' } throw e } finally { if (savedEnv != null && vfs) vfs.env = savedEnv } } /** * @param {Record} ctx * @param {string} rawTrimmed */ async function execShellLineInner(ctx, rawTrimmed) { if (typeof ctx.execLine !== 'function') { ctx.execLine = async (ln) => execShellLine(ctx, ln) } let execLine = collapseShellLineContinuations(rawTrimmed.trim()) const arithCmd = /^\(\((.*)\)\)$/.exec(execLine) if (arithCmd) { const envArith = ctx.vfs?.env && typeof ctx.vfs.env === 'object' ? ctx.vfs.env : {} try { const n = bareOsEvalArithmeticExpr(String(arithCmd[1] || '').trim(), envArith) ctx.exitCode = n === 0 ? 1 : 0 } catch (e) { ctx.console.error((e && e.message) || String(e)) ctx.exitCode = 2 } syncBareOsExitStatusEnv(ctx) return 'ok' } const env0 = ctx.vfs?.env && typeof ctx.vfs.env === 'object' ? ctx.vfs.env : {} if ( env0.BARE_OS_SHELL_EXEC_GRAPH_DUMP === '1' || env0.BARE_OS_SHELL_EXEC_GRAPH_DUMP === 'true' ) { try { ctx.shellLastExecGraph = buildShellExecutionGraph( collapseShellLineContinuations(rawTrimmed.trim()) ) } catch { /* best-effort diagnostic only */ } } if (!ctx.shellSessionState) { ctx.shellSessionState = { sid: 1, nextPgid: 300, foregroundPgid: 1, controllingTty: '/dev/console' } } else { if (ctx.shellSessionState.foregroundPgid == null) { ctx.shellSessionState.foregroundPgid = ctx.shellSessionState.sid } if (!ctx.shellSessionState.controllingTty) { const fifo = (ctx.env && ctx.env.BARE_OS_SESSION_FIFO) || (ctx.env && ctx.env.BARE_OS_IPC_SESSION_FIFO) || '' ctx.shellSessionState.controllingTty = fifo ? `ipc:${String(fifo).trim()}` : '/dev/console' } } ctx.shellSessionState.foregroundPgid = ctx.shellSessionState.sid { const heredoc = await consumeInteractiveHeredoc( ctx, execLine, syncBareOsExitStatusEnv ) if (!heredoc.ok) return 'ok' execLine = heredoc.execLine } const tokens = tokenize(execLine) if (!tokens.length) return 'ok' const statements = splitTopLevelStatements(tokens) for (const stmt of statements) { if (!stmt.length) continue const ampParts = splitTopLevelByAmpersand(stmt) for (let ai = 0; ai < ampParts.length; ai++) { const part = ampParts[ai] if (!part.length) continue const isBg = ai < ampParts.length - 1 if (isBg) { scheduleBackgroundShell(ctx, part) continue } const r = await dispatchShellStatement(ctx, part) if (r === 'exit') { syncBareOsExitStatusEnv(ctx) return 'exit' } syncBareOsExitStatusEnv(ctx) const shE = ctx.vfs?.env if ( shE && (shE.BARE_OS_SHELL_ERREXIT === '1' || shE.BARE_OS_SHELL_ERREXIT === 'true') && (Number(ctx.exitCode) || 0) !== 0 ) { syncBareOsExitStatusEnv(ctx) return 'ok' } } } syncBareOsExitStatusEnv(ctx) return 'ok' }