Skip to content

Commit b829ea3

Browse files
cramforceclaude
andcommitted
feat: executor tool invocation for js-exec
Add `executor` option to `BashOptions` that makes a `tools` proxy available to JavaScript code running in js-exec. Tool calls are synchronous from the QuickJS sandbox's perspective — they block via SharedArrayBuffer/Atomics while the host resolves them asynchronously. Natively integrates with `@executor/sdk` — the `setup` callback receives the SDK instance for adding OpenAPI, GraphQL, and MCP sources that auto-discover tools. ## Native SDK integration (OpenAPI, GraphQL, MCP) ```ts import { Bash } from "just-bash"; const bash = new Bash({ executor: { setup: async (sdk) => { // SDK auto-discovers tools from the OpenAPI spec await sdk.sources.add({ kind: "openapi", endpoint: "https://petstore3.swagger.io/api/v3", specUrl: "https://petstore3.swagger.io/api/v3/openapi.json", name: "petstore", }); // GraphQL: introspects the schema await sdk.sources.add({ kind: "graphql", endpoint: "https://countries.trevorblades.com/graphql", name: "countries", }); // MCP: connects to a Model Context Protocol server await sdk.sources.add({ kind: "mcp", endpoint: "https://mcp.example.com/sse", name: "internal", transport: "sse", }); }, }, }); // Code runs in QuickJS sandbox — SDK handles HTTP, auth, validation await bash.exec(`js-exec -c ' const pets = await tools.petstore.findPetsByStatus({ status: "available" }); const country = await tools.countries.country({ code: "US" }); const docs = await tools.internal.searchDocs({ query: "deploy" }); console.log(pets.length, "pets,", country.name, ",", docs.hits.length, "docs"); '`); ``` ## With tool approval ```ts const bash = new Bash({ executor: { tools: { "db.deleteUser": { description: "Delete a user from the database", execute: async (args) => { await db.query("DELETE FROM users WHERE id = $1", [args.id]); return { deleted: true }; }, }, }, setup: async (sdk) => { await sdk.sources.add({ kind: "openapi", endpoint: "https://api.stripe.com/v1", specUrl: "https://raw.githubusercontent.com/.../stripe-openapi.json", name: "stripe", auth: { kind: "bearer", token: process.env.STRIPE_SECRET_KEY }, }); }, // SDK passes approval requests through this callback onToolApproval: async (request) => { // Auto-approve reads, require confirmation for writes/deletes if (request.operationKind === "read") return { approved: true }; const ok = await promptUser( `Allow ${request.toolPath} (${request.operationKind})?` ); return ok ? { approved: true } : { approved: false, reason: "denied" }; }, }, }); ``` ## Inline tools (no SDK needed) ```ts const bash = new Bash({ executor: { tools: { "math.add": { description: "Add two numbers", execute: (args) => ({ sum: args.a + args.b }), }, "db.query": { execute: async (args) => { const rows = await pg.query(args.sql); return { rows }; }, }, }, }, }); await bash.exec(`js-exec -c ' const sum = await tools.math.add({ a: 3, b: 4 }); console.log(sum.sum); const data = await tools.db.query({ sql: "SELECT * FROM users" }); for (const row of data.rows) console.log(row.name); '`); ``` ## Implementation - New INVOKE_TOOL (400) opcode in the SharedArrayBuffer bridge protocol - SyncBackend.invokeTool() — worker-side sync call via Atomics.wait - BridgeHandler accepts optional invokeTool callback, handles new opcode - Worker registers __invokeTool native function + tools Proxy when hasExecutorTools is set - Tool invoker threads from BashOptions → Bash → InterpreterOptions → InterpreterContext → CommandContext → js-exec → BridgeHandler → worker - executor.setup lazily initializes @executor/sdk on first exec (dynamic import — SDK is only loaded when setup is provided) - SDK's CodeExecutor runtime delegates to js-exec's executeForExecutor - Full executor mode (log capture + result capture) available via executorMode flag for direct SDK integration Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d6a5ff0 commit b829ea3

17 files changed

Lines changed: 1180 additions & 5 deletions

knip.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"$schema": "https://unpkg.com/knip@5/schema.json",
3-
"entry": ["src/cli/just-bash.ts"],
3+
"entry": ["src/cli/just-bash.ts", "src/commands/js-exec/executor-adapter.ts"],
44
"project": ["src/**/*.ts"],
55
"ignore": [
66
"src/**/*.test.ts",
@@ -9,6 +9,7 @@
99
"src/security/fuzzing/**"
1010
],
1111
"ignoreBinaries": ["tsx"],
12+
"ignoreDependencies": ["@executor/sdk"],
1213
"rules": {
1314
"unlisted": "off",
1415
"unresolved": "off",

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,10 @@
105105
"vitest": "^4.0.16"
106106
},
107107
"dependencies": {
108+
"@executor/sdk": "link:/Users/malteubl/ws/vercel/reproductions/executor/packages/platform/sdk-programmatic",
108109
"compressjs": "^1.0.3",
109110
"diff": "^8.0.2",
111+
"effect": "^3.21.0",
110112
"fast-xml-parser": "^5.3.3",
111113
"file-type": "^21.2.0",
112114
"ini": "^6.0.0",

pnpm-lock.yaml

Lines changed: 13 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Bash.ts

Lines changed: 211 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,77 @@ export interface JavaScriptConfig {
9292
bootstrap?: string;
9393
}
9494

95+
/** Tool definition for the executor integration */
96+
export interface ExecutorToolDef {
97+
description?: string;
98+
// biome-ignore lint/suspicious/noExplicitAny: matches @executor/sdk SimpleTool signature
99+
execute: (...args: any[]) => unknown;
100+
}
101+
102+
/**
103+
* Executor SDK instance type (from @executor/sdk).
104+
* Kept as an opaque type to avoid requiring the SDK at import time.
105+
*/
106+
export interface ExecutorSDKHandle {
107+
execute: (
108+
code: string,
109+
) => Promise<{ result: unknown; error?: string; logs?: string[] }>;
110+
sources: {
111+
add: (
112+
input: Record<string, unknown>,
113+
options?: Record<string, unknown>,
114+
) => Promise<unknown>;
115+
list: () => Promise<unknown[]>;
116+
[key: string]: unknown;
117+
};
118+
close: () => Promise<void>;
119+
[key: string]: unknown;
120+
}
121+
122+
/** Executor configuration for js-exec tool invocation */
123+
export interface ExecutorConfig {
124+
/** Tool map: keys are dot-separated paths (e.g. "math.add"), values are tool definitions */
125+
tools?: Record<string, ExecutorToolDef>;
126+
/**
127+
* Async setup function that receives the @executor/sdk instance.
128+
* Use this to add OpenAPI, GraphQL, or MCP sources that auto-discover tools.
129+
* When provided, js-exec delegates execution to the SDK pipeline
130+
* (which handles tool approval, auth flows, and interaction loops).
131+
*
132+
* Requires @executor/sdk as a dependency.
133+
*
134+
* @example
135+
* ```ts
136+
* const bash = new Bash({
137+
* executor: {
138+
* setup: async (sdk) => {
139+
* await sdk.sources.add({ kind: "openapi", endpoint: "https://api.example.com", specUrl: "https://api.example.com/openapi.json", name: "myapi" });
140+
* await sdk.sources.add({ kind: "mcp", endpoint: "https://mcp.example.com/sse", name: "tools" });
141+
* },
142+
* },
143+
* });
144+
* ```
145+
*/
146+
setup?: (sdk: ExecutorSDKHandle) => Promise<void>;
147+
/**
148+
* Tool approval callback for the @executor/sdk pipeline.
149+
* Called when a tool invocation requires approval.
150+
* Defaults to "allow-all" when not provided.
151+
*/
152+
onToolApproval?:
153+
| "allow-all"
154+
| "deny-all"
155+
| ((request: {
156+
toolPath: string;
157+
sourceId: string;
158+
sourceName: string;
159+
operationKind: "read" | "write" | "delete" | "execute" | "unknown";
160+
args: unknown;
161+
reason: string;
162+
approvalLabel: string | null;
163+
}) => Promise<{ approved: true } | { approved: false; reason?: string }>);
164+
}
165+
95166
export interface BashOptions {
96167
files?: InitialFiles;
97168
env?: Record<string, string>;
@@ -137,6 +208,13 @@ export interface BashOptions {
137208
* Disabled by default. Can be a boolean or a config object with bootstrap code.
138209
*/
139210
javascript?: boolean | JavaScriptConfig;
211+
/**
212+
* Executor configuration for tool invocation in js-exec.
213+
* When provided, code running in js-exec has access to a `tools` proxy
214+
* (e.g. `tools.math.add({ a: 1, b: 2 })`).
215+
* Implicitly enables JavaScript if not already enabled.
216+
*/
217+
executor?: ExecutorConfig;
140218
/**
141219
* Optional list of command names to register.
142220
* If not provided, all built-in commands are available.
@@ -277,6 +355,14 @@ export class Bash {
277355
private defenseInDepthConfig?: DefenseInDepthConfig | boolean;
278356
private coverageWriter?: FeatureCoverageWriter;
279357
private jsBootstrapCode?: string;
358+
private executorInvokeTool?: (
359+
path: string,
360+
argsJson: string,
361+
) => Promise<string>;
362+
private executorSetup?: (sdk: ExecutorSDKHandle) => Promise<void>;
363+
private executorApproval?: ExecutorConfig["onToolApproval"];
364+
private executorSDK?: ExecutorSDKHandle;
365+
private executorInitPromise?: Promise<void>;
280366
// biome-ignore lint/suspicious/noExplicitAny: type-erased plugin storage for untyped API
281367
private transformPlugins: TransformPlugin<any>[] = [];
282368

@@ -450,7 +536,8 @@ export class Bash {
450536
}
451537

452538
// Register javascript commands only when explicitly enabled
453-
if (options.javascript) {
539+
// (executor config implicitly enables javascript)
540+
if (options.javascript || options.executor) {
454541
for (const cmd of createJavaScriptCommands()) {
455542
this.registerCommand(cmd);
456543
}
@@ -464,6 +551,36 @@ export class Bash {
464551
}
465552
}
466553

554+
// Set up executor tool invoker when executor config is provided
555+
if (options.executor?.tools) {
556+
const tools = options.executor.tools;
557+
this.executorInvokeTool = async (
558+
path: string,
559+
argsJson: string,
560+
): Promise<string> => {
561+
if (!Object.hasOwn(tools, path)) {
562+
throw new Error(`Unknown tool: ${path}`);
563+
}
564+
const tool = tools[path];
565+
let args: unknown;
566+
try {
567+
args = argsJson ? JSON.parse(argsJson) : undefined;
568+
} catch {
569+
args = undefined;
570+
}
571+
const result = await tool.execute(args);
572+
return result !== undefined ? JSON.stringify(result) : "";
573+
};
574+
}
575+
576+
// Store SDK setup function and approval callback for lazy initialization
577+
if (options.executor?.setup) {
578+
this.executorSetup = options.executor.setup;
579+
}
580+
if (options.executor?.onToolApproval) {
581+
this.executorApproval = options.executor.onToolApproval;
582+
}
583+
467584
// Register custom commands (after built-ins so they can override)
468585
if (options.customCommands) {
469586
for (const cmd of options.customCommands) {
@@ -523,10 +640,102 @@ export class Bash {
523640
return result;
524641
}
525642

643+
/**
644+
* Lazily initialize the @executor/sdk when setup is configured.
645+
* Creates the SDK instance, runs the user's setup function, and wires
646+
* the SDK's tool invocation into the executorInvokeTool callback.
647+
*/
648+
private async ensureExecutorReady(): Promise<void> {
649+
if (!this.executorSetup || this.executorSDK) return;
650+
651+
// Deduplicate concurrent init calls
652+
if (this.executorInitPromise) {
653+
await this.executorInitPromise;
654+
return;
655+
}
656+
657+
this.executorInitPromise = (async () => {
658+
// Dynamic import — @executor/sdk and effect are only needed when setup is used
659+
const { createExecutor } = await import("@executor/sdk");
660+
const { executeForExecutor } = await import(
661+
"./commands/js-exec/js-exec.js"
662+
);
663+
const EffectMod = await import("effect/Effect");
664+
665+
const self = this;
666+
// biome-ignore lint/suspicious/noExplicitAny: CodeExecutor + ToolInvoker types cross package boundaries; validated at runtime by the SDK
667+
const runtime: any = {
668+
// biome-ignore lint/suspicious/noExplicitAny: ToolInvoker type from @executor/sdk
669+
execute(code: string, toolInvoker: any) {
670+
return EffectMod.tryPromise(() => {
671+
const ctx = {
672+
fs: self.fs,
673+
cwd: self.state.cwd,
674+
env: self.state.env,
675+
stdin: "",
676+
limits: self.limits,
677+
};
678+
const invokeTool = async (
679+
path: string,
680+
argsJson: string,
681+
): Promise<string> => {
682+
let args: unknown;
683+
try {
684+
args = argsJson ? JSON.parse(argsJson) : undefined;
685+
} catch {
686+
args = undefined;
687+
}
688+
const result = await EffectMod.runPromise(
689+
toolInvoker.invoke({ path, args }),
690+
);
691+
return result !== undefined ? JSON.stringify(result) : "";
692+
};
693+
return executeForExecutor(code, ctx, invokeTool);
694+
});
695+
},
696+
};
697+
698+
const sdk = await createExecutor({
699+
runtime,
700+
storage: "memory",
701+
onToolApproval: this.executorApproval ?? "allow-all",
702+
});
703+
704+
// Run user setup (add sources, configure policies, etc.)
705+
if (this.executorSetup) {
706+
await this.executorSetup(sdk as unknown as ExecutorSDKHandle);
707+
}
708+
this.executorSDK = sdk as unknown as ExecutorSDKHandle;
709+
710+
// Wire SDK execution as the tool invoker for js-exec
711+
this.executorInvokeTool = async (
712+
path: string,
713+
argsJson: string,
714+
): Promise<string> => {
715+
// Route through the SDK's execute to get the tool invoker pipeline.
716+
// The SDK creates the tool invoker per-execution, so we run a small
717+
// code snippet that invokes the tool and returns the result.
718+
const escapedPath = path.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
719+
const sdkHandle = this.executorSDK;
720+
if (!sdkHandle) throw new Error("Executor SDK not initialized");
721+
const result = await sdkHandle.execute(
722+
`return await tools.${escapedPath}(${argsJson || "{}"})`,
723+
);
724+
if (result.error) throw new Error(result.error);
725+
return result.result !== undefined ? JSON.stringify(result.result) : "";
726+
};
727+
})();
728+
729+
await this.executorInitPromise;
730+
}
731+
526732
async exec(
527733
commandLine: string,
528734
options?: ExecOptions,
529735
): Promise<BashExecResult> {
736+
// Lazily initialize executor SDK if setup was provided
737+
await this.ensureExecutorReady();
738+
530739
if (this.state.callDepth === 0) {
531740
this.state.commandCount = 0;
532741
}
@@ -666,6 +875,7 @@ export class Bash {
666875
coverage: this.coverageWriter,
667876
requireDefenseContext: defenseBox?.isEnabled() === true,
668877
jsBootstrapCode: this.jsBootstrapCode,
878+
executorInvokeTool: this.executorInvokeTool,
669879
};
670880

671881
const interpreter = new Interpreter(interpreterOptions, execState);

0 commit comments

Comments
 (0)