From d8b45070c9e51427a9b0f7d2348c3366fa4f2358 Mon Sep 17 00:00:00 2001 From: Mavdol Date: Sun, 12 Apr 2026 18:17:19 +0200 Subject: [PATCH 1/2] feat: implement shell command parsing --- packages/bash/src/core/parser.ts | 150 +++++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 packages/bash/src/core/parser.ts diff --git a/packages/bash/src/core/parser.ts b/packages/bash/src/core/parser.ts new file mode 100644 index 0000000..6403411 --- /dev/null +++ b/packages/bash/src/core/parser.ts @@ -0,0 +1,150 @@ +import shellQuote from 'shell-quote'; + +export type RedirectOp = '>' | '>>' | '<'; + +export type Redirect = { + op: RedirectOp; + file: string; +}; + +export type CommandNode = { + type: 'command'; + args: string[]; + redirects: Redirect[]; +}; + +export type PipelineNode = { + type: 'pipeline'; + commands: CommandNode[]; +}; + +export type AndNode = { + type: 'and'; + left: ASTNode; + right: ASTNode; +}; + +export type OrNode = { + type: 'or'; + left: ASTNode; + right: ASTNode; +}; + +export type SequenceNode = { + type: 'sequence'; + left: ASTNode; + right: ASTNode; +}; + +export type ASTNode = CommandNode | PipelineNode | AndNode | OrNode | SequenceNode; + +type ShellToken = string | { op: string } | { comment: string } | { pattern: string }; + +function isOp(token: ShellToken, op?: string): token is { op: string } { + return typeof token === 'object' && 'op' in token && (op === undefined || token.op === op); +} + +function tokenToString(token: ShellToken): string | null { + if (typeof token === 'string') return token; + if (typeof token === 'object' && 'pattern' in token) return token.pattern; + return null; +} + +export class Parser { + private tokens: ShellToken[] = []; + private pos = 0; + + parse(input: string): ASTNode { + this.tokens = (shellQuote.parse(input) as ShellToken[]).filter( + (t) => !(typeof t === 'object' && 'comment' in t) + ); + this.pos = 0; + return this.parseSequence(); + } + + private peek(): ShellToken | undefined { + return this.tokens[this.pos]; + } + + private consume(): ShellToken { + return this.tokens[this.pos++]; + } + + private parseSequence(): ASTNode { + let left = this.parseAndOr(); + + while (isOp(this.peek()!, ';')) { + this.consume(); + if (this.pos >= this.tokens.length) break; + const right = this.parseAndOr(); + left = { type: 'sequence', left, right } satisfies SequenceNode; + } + + return left; + } + + private parseAndOr(): ASTNode { + let left = this.parsePipeline(); + + while (this.pos < this.tokens.length) { + const next = this.peek()!; + + if (isOp(next, '&&')) { + this.consume(); + const right = this.parsePipeline(); + left = { type: 'and', left, right } satisfies AndNode; + + } else if (isOp(next, '||')) { + this.consume(); + const right = this.parsePipeline(); + left = { type: 'or', left, right } satisfies OrNode; + + } else { + break; + } + } + + return left; + } + + private parsePipeline(): ASTNode { + const commands: CommandNode[] = [this.parseCommand()]; + + while (isOp(this.peek()!, '|')) { + this.consume(); + commands.push(this.parseCommand()); + } + + if (commands.length === 1) return commands[0]; + return { type: 'pipeline', commands } satisfies PipelineNode; + } + + private parseCommand(): CommandNode { + const args: string[] = []; + const redirects: Redirect[] = []; + + while (this.pos < this.tokens.length) { + const token = this.peek()!; + + if (isOp(token, '>') || isOp(token, '>>') || isOp(token, '<')) { + this.consume(); + + const fileToken = this.consume(); + const file = tokenToString(fileToken); + + if (file === null) throw new SyntaxError(`Expected filename after '${(token as { op: string }).op}'`); + + redirects.push({ op: (token as { op: RedirectOp }).op, file }); + } else if (isOp(token)) { + break; + } else { + const str = tokenToString(this.consume()); + if (str !== null) args.push(str); + } + } + + if (args.length === 0) throw new SyntaxError('Empty command'); + + return { type: 'command', args, redirects }; + } +} From 6097a428cd9b1c47bd257867d610ea258bbc0f4c Mon Sep 17 00:00:00 2001 From: Mavdol Date: Sun, 12 Apr 2026 21:24:25 +0200 Subject: [PATCH 2/2] feat: introduce command execution infrastructure and refactor state management to use command context --- packages/bash-types/src/command.ts | 28 +++++ packages/bash-types/src/index.ts | 1 + packages/bash-types/src/runtime.ts | 8 +- packages/bash-types/src/state.ts | 10 ++ packages/bash-wasm/src/runtime.ts | 4 +- packages/bash/package.json | 4 +- packages/bash/src/core/bash.ts | 13 ++- packages/bash/src/core/executor.ts | 116 +++++++++++++++++++++ packages/bash/src/core/filesystem.ts | 6 +- packages/bash/src/core/stateManager.ts | 23 ++-- pnpm-lock.yaml | 17 +++ wasm-sandboxes/js/__test__/sandbox.test.ts | 61 ++++------- wasm-sandboxes/js/sandbox.ts | 32 ++---- 13 files changed, 228 insertions(+), 95 deletions(-) create mode 100644 packages/bash-types/src/command.ts create mode 100644 packages/bash/src/core/executor.ts diff --git a/packages/bash-types/src/command.ts b/packages/bash-types/src/command.ts new file mode 100644 index 0000000..b50d499 --- /dev/null +++ b/packages/bash-types/src/command.ts @@ -0,0 +1,28 @@ +import { BaseRuntime } from "./runtime"; +import { State } from "./state"; + +/** + * The context of a command execution + */ +export type CommandContext = { + args: string[]; + stdin: string; + state: State; + runtime: BaseRuntime; +}; + +/** + * The handler of a command execution + */ +export type CommandHandler = (ctx: CommandContext) => Promise; + +/** + * The result of a command execution + */ +export type CommandResult = { + stdout: string; + stderr: string; + exitCode: number; +}; + + diff --git a/packages/bash-types/src/index.ts b/packages/bash-types/src/index.ts index cc3a742..7f9d67a 100644 --- a/packages/bash-types/src/index.ts +++ b/packages/bash-types/src/index.ts @@ -1,3 +1,4 @@ export { State } from "./state"; export { BaseRuntime, RuntimeResult } from "./runtime"; export { BashOptions } from "./bash"; +export { CommandResult, CommandHandler, CommandContext } from "./command"; diff --git a/packages/bash-types/src/runtime.ts b/packages/bash-types/src/runtime.ts index 97ad979..63be9ca 100644 --- a/packages/bash-types/src/runtime.ts +++ b/packages/bash-types/src/runtime.ts @@ -10,17 +10,17 @@ export interface BaseRuntime { /** * Execute a code */ - executeCode(state: State, code: string, language: string): Promise; + executeCode(state: State, code: string, language?: string): Promise; /** * Execute a file */ - executeFile(state: State, filePath: string, language: string): Promise; + executeFile(state: State, filePath: string, language?: string): Promise; /** - * Resolve a directory path + * Resolve a path in the sandbox */ - resolveDirectoryPath(state: State, directoryPath: string): Promise; + resolvePath(state: State, path: string): Promise; } export interface RuntimeResult { diff --git a/packages/bash-types/src/state.ts b/packages/bash-types/src/state.ts index 1b60f3a..48aef48 100644 --- a/packages/bash-types/src/state.ts +++ b/packages/bash-types/src/state.ts @@ -16,4 +16,14 @@ export interface State { * The return code of the last executed command (ex: 0 for success, 1 for error). */ lastExitCode: number; + + /** + * Set the exit code of the last executed command + */ + setLastExitCode(code: number): void; + + /** + * Set an environment variable + */ + setEnv(key: string, value: string): void; } diff --git a/packages/bash-wasm/src/runtime.ts b/packages/bash-wasm/src/runtime.ts index d234444..85ff604 100644 --- a/packages/bash-wasm/src/runtime.ts +++ b/packages/bash-wasm/src/runtime.ts @@ -54,10 +54,10 @@ export class WasmRuntime implements BaseRuntime { return result.result as string; } - async resolveDirectoryPath(state: State, directoryPath: string): Promise { + async resolvePath(state: State, path: string): Promise { const result = await run({ file: this.jsSandbox, - args: ["RESOLVE_DIRECTORY_PATH", JSON.stringify(state), directoryPath], + args: ["RESOLVE_PATH", JSON.stringify(state), path], mounts: [`${this.hostWorkspace}::/`], }) diff --git a/packages/bash/package.json b/packages/bash/package.json index ddf59d6..8f4d7c6 100644 --- a/packages/bash/package.json +++ b/packages/bash/package.json @@ -9,9 +9,11 @@ "license": "Apache-2.0", "packageManager": "pnpm@10.21.0", "devDependencies": { + "@types/shell-quote": "^1.7.5", "tsup": "^8.0.0" }, "dependencies": { - "@capsule-run/bash-types": "workspace:*" + "@capsule-run/bash-types": "workspace:*", + "shell-quote": "^1.8.3" } } diff --git a/packages/bash/src/core/bash.ts b/packages/bash/src/core/bash.ts index cc1fc0b..051125c 100644 --- a/packages/bash/src/core/bash.ts +++ b/packages/bash/src/core/bash.ts @@ -1,10 +1,14 @@ -import type { BaseRuntime, BashOptions } from "@capsule-run/bash-types"; +import type { BaseRuntime, BashOptions, CommandResult } from "@capsule-run/bash-types"; import { StateManager } from "./stateManager"; import { Filesystem } from "./filesystem"; +import { Parser } from "./parser"; +import { Executor } from "./executor"; export class Bash { private runtime: BaseRuntime; private filesystem: Filesystem; + private parser: Parser; + private executor: Executor; public readonly stateManager: StateManager; @@ -13,11 +17,16 @@ export class Bash { this.runtime.hostWorkspace = hostWorkspace; this.stateManager = new StateManager(runtime, initialCwd); this.filesystem = new Filesystem(hostWorkspace); + this.parser = new Parser(); + this.executor = new Executor(runtime, this.stateManager.state); this.filesystem.init(); } - run(command: string) {} + async run(command: string): Promise { + const ast = this.parser.parse(command); + return this.executor.execute(ast); + } reset() { this.filesystem.reset(); diff --git a/packages/bash/src/core/executor.ts b/packages/bash/src/core/executor.ts new file mode 100644 index 0000000..9d3308b --- /dev/null +++ b/packages/bash/src/core/executor.ts @@ -0,0 +1,116 @@ +import path from 'path'; +import type { BaseRuntime, CommandHandler, CommandResult, State } from '@capsule-run/bash-types'; +import type { ASTNode, CommandNode } from './parser'; + +export class Executor { + + constructor( + private readonly runtime: BaseRuntime, + private readonly state: State, + ) {} + + async execute(node: ASTNode, stdin = ''): Promise { + switch (node.type) { + case 'command': return this.executeCommand(node, stdin); + case 'pipeline': return this.executePipeline(node); + case 'and': return this.executeAnd(node); + case 'or': return this.executeOr(node); + case 'sequence': return this.executeSequence(node); + } + } + + private async executeCommand(node: CommandNode, stdin: string): Promise { + const [name, ...args] = node.args; + + for (const r of node.redirects) { + if (r.op === '<') { + try { + stdin = await this.runtime.executeCode(this.state, ` + const fs = require('fs'); + const path = require('path'); + return fs.readFileSync(path.resolve(${JSON.stringify(r.file)}), 'utf8'); + `) as string; + } catch { + return { stdout: '', stderr: `bash: ${r.file}: No such file or directory`, exitCode: 1 }; + } + } + } + + const handler = await this.searchCommandHandler(name); + let result: CommandResult; + + if (handler) { + result = await handler({ args, stdin, state: this.state, runtime: this.runtime }); + } else { + result = { stdout: '', stderr: `bash: ${name}: command not found`, exitCode: 127 }; + } + + for (const r of node.redirects) { + if (r.op === '>' || r.op === '>>') { + try { + await this.runtime.executeCode(this.state, ` + const fs = require('fs'); + const path = require('path'); + const filePath = path.resolve(${JSON.stringify(r.file)}); + + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.${r.op === '>>' ? 'appendFileSync' : 'writeFileSync'}(filePath, ${JSON.stringify(result.stdout)}); + `); + + result = { ...result, stdout: '' }; + } catch { + return { stdout: '', stderr: `bash: ${r.file}: No such file or directory`, exitCode: 1 }; + } + } + } + + this.state.setLastExitCode(result.exitCode); + return result; + } + + private async executePipeline(node: { type: 'pipeline'; commands: CommandNode[] }): Promise { + let stdin = ''; + let result: CommandResult = { stdout: '', stderr: '', exitCode: 0 }; + + for (const cmd of node.commands) { + result = await this.executeCommand(cmd, stdin); + stdin = result.stdout; + } + + return result; + } + + + private async executeAnd(node: { type: 'and'; left: ASTNode; right: ASTNode }): Promise { + const left = await this.execute(node.left); + + if (left.exitCode !== 0) return left; + + return this.execute(node.right); + } + + private async executeOr(node: { type: 'or'; left: ASTNode; right: ASTNode }): Promise { + const left = await this.execute(node.left); + + if (left.exitCode === 0) return left; + + return this.execute(node.right); + } + + private async executeSequence(node: { type: 'sequence'; left: ASTNode; right: ASTNode }): Promise { + await this.execute(node.left); + return this.execute(node.right); + } + + private async searchCommandHandler(name: string): Promise { + const commandsDir = path.resolve(__dirname, '../commands'); + const handlerPath = path.join(commandsDir, name, 'handler'); + + try { + const mod = require(handlerPath); + return mod.handle as CommandHandler; + } catch { + return undefined; + } + } +} diff --git a/packages/bash/src/core/filesystem.ts b/packages/bash/src/core/filesystem.ts index d0bce0e..675e0f1 100644 --- a/packages/bash/src/core/filesystem.ts +++ b/packages/bash/src/core/filesystem.ts @@ -2,11 +2,7 @@ import fs from 'fs'; import path from 'path'; export class Filesystem { - private workspace: string; - - constructor(workspace: string) { - this.workspace = workspace; - } + constructor(private readonly workspace: string) {} init() { const directories = ['bin', 'dev', 'etc', 'proc', 'root', 'sys', 'tmp', 'workspace']; diff --git a/packages/bash/src/core/stateManager.ts b/packages/bash/src/core/stateManager.ts index e4587a3..29cb961 100644 --- a/packages/bash/src/core/stateManager.ts +++ b/packages/bash/src/core/stateManager.ts @@ -1,16 +1,19 @@ -import path from 'node:path'; import type { BaseRuntime, State } from '@capsule-run/bash-types'; export class StateManager { public readonly state: State; - private runtime: BaseRuntime; - constructor(runtime: BaseRuntime, initialCwd: string = 'workspace') { - this.runtime = runtime; + constructor(private readonly runtime: BaseRuntime, initialCwd: string = 'workspace') { this.state = { cwd: initialCwd, env: {}, - lastExitCode: 0 + lastExitCode: 0, + setLastExitCode: (code: number) => { + this.state.lastExitCode = code; + }, + setEnv: (key: string, value: string) => { + this.state.env[key] = value; + } }; } @@ -20,7 +23,7 @@ export class StateManager { public async changeDirectory(targetPath: string): Promise { try { - const resolvedPath = await this.runtime.resolveDirectoryPath(this.state, targetPath); + const resolvedPath = await this.runtime.resolvePath(this.state, targetPath); this.state.cwd = resolvedPath; return true; } catch { @@ -28,14 +31,6 @@ export class StateManager { } } - public setEnv(key: string, value: string): void { - this.state.env[key] = value; - } - - public setExitCode(code: number): void { - this.state.lastExitCode = code; - } - public reset() { this.state.cwd = 'workspace'; this.state.env = {}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7db91ea..822b2e9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,7 +33,13 @@ importers: '@capsule-run/bash-types': specifier: workspace:* version: link:../bash-types + shell-quote: + specifier: ^1.8.3 + version: 1.8.3 devDependencies: + '@types/shell-quote': + specifier: ^1.7.5 + version: 1.7.5 tsup: specifier: ^8.0.0 version: 8.5.1(postcss@8.5.9)(typescript@6.0.2) @@ -861,6 +867,9 @@ packages: '@types/node@25.6.0': resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} + '@types/shell-quote@1.7.5': + resolution: {integrity: sha512-+UE8GAGRPbJVQDdxi16dgadcBfQ+KG2vgZhV1+3A1XmHbmwcdwhCUwIdy+d3pAGrbvgRoVSjeI9vOWyq376Yzw==} + '@vitest/expect@4.1.4': resolution: {integrity: sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==} @@ -1231,6 +1240,10 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + shell-quote@1.8.3: + resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -1937,6 +1950,8 @@ snapshots: dependencies: undici-types: 7.19.2 + '@types/shell-quote@1.7.5': {} + '@vitest/expect@4.1.4': dependencies: '@standard-schema/spec': 1.1.0 @@ -2342,6 +2357,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.60.1 fsevents: 2.3.3 + shell-quote@1.8.3: {} + siginfo@2.0.0: {} signal-exit@4.1.0: {} diff --git a/wasm-sandboxes/js/__test__/sandbox.test.ts b/wasm-sandboxes/js/__test__/sandbox.test.ts index c90d055..72af8ae 100644 --- a/wasm-sandboxes/js/__test__/sandbox.test.ts +++ b/wasm-sandboxes/js/__test__/sandbox.test.ts @@ -104,38 +104,6 @@ describe('sandbox.ts – EXECUTE_FILE', () => { }); }); -describe('sandbox.ts – EXECUTE_COMMAND', () => { - it('calls the execute function exported by the script', async () => { - const script = ` - exports.execute = function(args) { - return 'executed with ' + args.join(', '); - }; - `; - - const result = await run({ - file: SANDBOX, - args: ['EXECUTE_COMMAND', baseState, script, 'foo', 'bar'], - mounts: [`${WORKSPACE}::/`], - }); - - const value = assertSuccess(result); - expect(value).toBe('executed with foo, bar'); - }); - - it('fails when execute is not exported', async () => { - const script = `const x = 1;`; - - const result = await run({ - file: SANDBOX, - args: ['EXECUTE_COMMAND', baseState, script], - mounts: [`${WORKSPACE}::/`], - }); - - const error = assertFailure(result); - expect(error.message).toContain("execute"); - }); -}); - describe('sandbox.ts – invalid action', () => { it('throws on unknown action', async () => { const result = await run({ @@ -149,48 +117,59 @@ describe('sandbox.ts – invalid action', () => { }); -describe('sandbox.ts – RESOLVE_DIRECTORY_PATH', () => { +describe('sandbox.ts – RESOLVE_PATH', () => { it('resolves a directory path', async () => { const result = await run({ file: SANDBOX, - args: ['RESOLVE_DIRECTORY_PATH', baseState, 'imports'], + args: ['RESOLVE_PATH', baseState, 'imports'], mounts: [`${WORKSPACE}::/`], }); const value = assertSuccess(result); - expect(value).toBe('/imports'); + expect(value).toBe('imports'); }); it('Should return an error because the directory path does not exist', async () => { const result = await run({ file: SANDBOX, - args: ['RESOLVE_DIRECTORY_PATH', baseState, '../non-existent-directory'], + args: ['RESOLVE_PATH', baseState, '../non-existent-directory'], mounts: [`${WORKSPACE}::/`], }); const value = assertFailure(result); - expect(value.message).toContain("Directory path ../non-existent-directory does not exist"); + expect(value.message).toContain("Path ../non-existent-directory does not exist"); }); it('resolves a directory path', async () => { const result = await run({ file: SANDBOX, - args: ['RESOLVE_DIRECTORY_PATH', baseState, 'imports/../imports/complex-path-testing'], + args: ['RESOLVE_PATH', baseState, 'imports/../imports/complex-path-testing'], mounts: [`${WORKSPACE}::/`], }); const value = assertSuccess(result); - expect(value).toBe('/imports/complex-path-testing'); + expect(value).toBe('imports/complex-path-testing'); }); it('Should works with a different initial cwd', async () => { const result = await run({ file: SANDBOX, - args: ['RESOLVE_DIRECTORY_PATH', JSON.stringify({ cwd: 'imports', env: {}, lastExitCode: 0 }), 'complex-path-testing'], + args: ['RESOLVE_PATH', JSON.stringify({ cwd: 'imports', env: {}, lastExitCode: 0 }), 'complex-path-testing'], + mounts: [`${WORKSPACE}::/`], + }); + + const value = assertSuccess(result); + expect(value).toBe('imports/complex-path-testing'); + }); + + it('Should works with a file path', async () => { + const result = await run({ + file: SANDBOX, + args: ['RESOLVE_PATH', baseState, 'test-file.js'], mounts: [`${WORKSPACE}::/`], }); const value = assertSuccess(result); - expect(value).toBe('/imports/complex-path-testing'); + expect(value).toBe('test-file.js'); }); }); diff --git a/wasm-sandboxes/js/sandbox.ts b/wasm-sandboxes/js/sandbox.ts index 6d7cb54..ed793bb 100644 --- a/wasm-sandboxes/js/sandbox.ts +++ b/wasm-sandboxes/js/sandbox.ts @@ -96,34 +96,16 @@ const executeCode = task( } ); -const executeCommand = task( - { name: "executeCommand", compute: "LOW", ram: "64MB" }, - async (state: State, scriptContent: string, args: string[]) => { - process.chdir(state.cwd); - const exports: { execute?: (args: string[]) => any } = {}; - - const moduleWrapper = new Function('exports', scriptContent); - - moduleWrapper(exports); - - if (!exports.execute) { - throw new Error("Script must export an 'execute' function"); - } - - return await exports.execute(args); - } -) - -export const resolveDirectoryPath = task( - { name: "resolveDirectoryPath", compute: "LOW", ram: "32MB" }, +export const resolvePath = task( + { name: "resolvePath", compute: "LOW", ram: "32MB" }, async (state: State, targetPath: string) => { process.chdir(state.cwd); if (!fs.existsSync(targetPath)) { - throw new Error(`Directory path ${targetPath} does not exist`); + throw new Error(`Path ${targetPath} does not exist`); } - return '/' + path.resolve(targetPath); + return path.resolve(targetPath); } ) @@ -137,14 +119,12 @@ export const main = task( if (action === "LOAD") { response = { success: true, result: "Sandbox loaded successfully", error: null }; - } else if (action === "EXECUTE_COMMAND") { - response = await executeCommand(parsedState, parsedArgs[0], parsedArgs.slice(1)); } else if (action === "EXECUTE_CODE") { response = await executeCode(parsedState, parsedArgs[0]); } else if (action === "EXECUTE_FILE") { response = await executeFile(parsedState, parsedArgs[0], parsedArgs.slice(1)); - } else if (action === "RESOLVE_DIRECTORY_PATH") { - response = await resolveDirectoryPath(parsedState, parsedArgs[0]); + } else if (action === "RESOLVE_PATH") { + response = await resolvePath(parsedState, parsedArgs[0]); } else { throw new Error(`Invalid action: ${action}`); }