Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/publish-js.yml
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,18 @@ jobs:
crates/bashkit-js/index.d.cts
crates/bashkit-js/wrapper.js
crates/bashkit-js/wrapper.d.ts
crates/bashkit-js/scripted.js
crates/bashkit-js/scripted.d.ts
crates/bashkit-js/internal.js
crates/bashkit-js/internal.d.ts
crates/bashkit-js/langchain.js
crates/bashkit-js/langchain.d.ts
crates/bashkit-js/anthropic.js
crates/bashkit-js/anthropic.d.ts
crates/bashkit-js/openai.js
crates/bashkit-js/openai.d.ts
crates/bashkit-js/ai.js
crates/bashkit-js/ai.d.ts
crates/bashkit-js/bashkit.wasi.cjs
crates/bashkit-js/bashkit.wasi-browser.js
crates/bashkit-js/wasi-worker.mjs
Expand Down
10 changes: 9 additions & 1 deletion crates/bashkit-js/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,19 @@ bashkit.wasi-browser.js
wasi-worker.mjs
wasi-worker-browser.mjs

# tsc output (source is wrapper.ts, langchain.ts, ai.ts, anthropic.ts, openai.ts)
# tsc output (source is wrapper.ts, scripted.ts, internal.ts, langchain.ts, ai.ts, anthropic.ts, openai.ts)
wrapper.js
wrapper.d.ts
wrapper.d.ts.map
wrapper.js.map
scripted.js
scripted.d.ts
scripted.d.ts.map
scripted.js.map
internal.js
internal.d.ts
internal.d.ts.map
internal.js.map
langchain.js
langchain.d.ts
langchain.d.ts.map
Expand Down
2 changes: 1 addition & 1 deletion crates/bashkit-js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@ console.log(result.stdout);
Use `ScriptedTool` to register JavaScript callbacks as bash-callable tools:

```typescript
import { ScriptedTool } from "@everruns/bashkit";
import { ScriptedTool } from "@everruns/bashkit/scripted";

const tool = new ScriptedTool({ name: "api" });
tool.addTool("get_user", "Fetch user by ID", (params) => {
Expand Down
3 changes: 2 additions & 1 deletion crates/bashkit-js/__test__/integration.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import test from "ava";
import { Bash, BashError, BashTool, ScriptedTool } from "../wrapper.js";
import { Bash, BashError, BashTool } from "../wrapper.js";
import { ScriptedTool } from "../scripted.js";

test("integration: python opt-in executes embedded Python", (t) => {
const bash = new Bash({ python: true });
Expand Down
2 changes: 1 addition & 1 deletion crates/bashkit-js/__test__/runtime-compat/_setup.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ export {
Bash,
BashTool,
BashError,
ScriptedTool,
getVersion,
snapshotAncestry,
snapshotCapabilities,
Expand All @@ -16,3 +15,4 @@ export {
snapshotPlanCheckout,
snapshotReachable,
} from "../../wrapper.js";
export { ScriptedTool } from "../../scripted.js";
23 changes: 23 additions & 0 deletions crates/bashkit-js/__test__/scripted-entry.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// ScriptedTool lives in @everruns/bashkit/scripted; the root keeps a
// deprecated alias of the same class during the transition.
import test from "ava";
import { ScriptedTool } from "../scripted.js";
import * as root from "../wrapper.js";

test("scripted entry: ScriptedTool runs registered tools", async (t) => {
const tool = new ScriptedTool({ name: "api" });
tool.addTool("greet", "Greet user", (params) => `hi ${params.name}\n`);
const result = await tool.execute("greet --name ada");
t.is(result.stdout, "hi ada\n");
});

test("root entry: deprecated alias is the same class", (t) => {
t.is(root.ScriptedTool, ScriptedTool);
t.true(new root.ScriptedTool({ name: "x" }) instanceof ScriptedTool);
});

test("scripted entry: BashError is the shared root class", async (t) => {
const tool = new ScriptedTool({ name: "api" });
const error = await t.throwsAsync(() => tool.executeOrThrow("exit 3"));
t.true(error instanceof root.BashError);
});
3 changes: 2 additions & 1 deletion crates/bashkit-js/__test__/security.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
*/

import test from "ava";
import { Bash, BashTool, BashError, ScriptedTool } from "../wrapper.js";
import { Bash, BashTool, BashError } from "../wrapper.js";
import { ScriptedTool } from "../scripted.js";

function sleepMs(ms: number): void {
const signal = new Int32Array(new SharedArrayBuffer(4));
Expand Down
128 changes: 128 additions & 0 deletions crates/bashkit-js/internal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// Shared runtime helpers for wrapper.ts and scripted.ts.
//
// Decision: helpers used by both the root entry (Bash/BashTool) and the
// `@everruns/bashkit/scripted` entry live here so neither entry imports the
// other (no ESM cycle). Not a public entry point: package.json exports only
// ".", "./scripted" and the framework adapters.

import { createRequire } from "node:module";
import type { ExecResult } from "./index.cjs";

const require = createRequire(import.meta.url);
/** @internal Native addon module. */
export const native = require("./index.cjs");

export const MAX_JSON_NESTING_DEPTH = 64;

export const DEFAULT_MAX_INPUT_BYTES = 10_000_000;
const MAX_PENDING_ASYNC_EXECUTIONS = 8;
const ASYNC_EXECUTE_QUEUE_FULL_ERROR =
"too many pending async execute calls for this instance";
interface AsyncExecuteQueueState {
tail: Promise<void>;
pending: number;
}
const asyncExecuteQueues = new WeakMap<object, AsyncExecuteQueueState>();
export function errorExecResult(error: string): ExecResult {
return {
stdout: "",
stdoutBytes: [],
stderr: error,
stderrBytes: Array.from(Buffer.from(error, "utf8")),
exitCode: 1,
error,
stdoutTruncated: false,
stderrTruncated: false,
finalEnv: undefined,
success: false,
};
}

export function inputTooLargeExecResult(
commands: string,
maxInputBytes: number,
): ExecResult | undefined {
const inputBytes = Buffer.byteLength(commands, "utf8");
if (inputBytes <= maxInputBytes) {
return undefined;
}
return errorExecResult(
`input too large: ${inputBytes} bytes exceeds maxInputBytes ${maxInputBytes}`,
);
}

// Decision: serialize async execute() per instance in JS so queued AbortSignal
// listeners only attach once a call reaches the front of the line. Also bound
// the backlog before retaining large command strings in queued closures.
export function queueAsyncExecute<T>(
owner: object,
run: () => Promise<T>,
): Promise<T> {
let state = asyncExecuteQueues.get(owner);
if (!state) {
state = { tail: Promise.resolve(), pending: 0 };
asyncExecuteQueues.set(owner, state);
}
if (state.pending >= MAX_PENDING_ASYNC_EXECUTIONS) {
return Promise.reject(new Error(ASYNC_EXECUTE_QUEUE_FULL_ERROR));
}
state.pending += 1;
const previous = state.tail;
const completion = previous.then(
() => run(),
() => run(),
);
state.tail = completion.then(
() => undefined,
() => undefined,
);
state.tail.finally(() => {
state.pending -= 1;
if (state.pending === 0 && asyncExecuteQueues.get(owner) === state) {
asyncExecuteQueues.delete(owner);
}
});
return completion;
}

export function validateJsonNestingDepth(value: unknown, depth = 0): void {
if (depth > MAX_JSON_NESTING_DEPTH) {
throw new RangeError(
`JSON nesting depth exceeds maximum of ${MAX_JSON_NESTING_DEPTH}`,
);
}

if (Array.isArray(value)) {
for (const item of value) {
validateJsonNestingDepth(item, depth + 1);
}
return;
}

if (value && typeof value === "object") {
for (const item of Object.values(value as Record<string, unknown>)) {
validateJsonNestingDepth(item, depth + 1);
}
}
}

/**
* Error thrown when a bash command execution fails.
*/
export class BashError extends Error {
readonly exitCode: number;
readonly stderr: string;

constructor(result: ExecResult) {
const message =
result.error ?? result.stderr ?? `Exit code ${result.exitCode}`;
super(message);
this.name = "BashError";
this.exitCode = result.exitCode;
this.stderr = result.stderr;
}

display(): string {
return `BashError(exit_code=${this.exitCode}): ${this.message}`;
}
}
7 changes: 4 additions & 3 deletions crates/bashkit-js/langchain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
* const result = await tool.invoke({ commands: "echo hello" });
*
* // Scripted tool
* import { ScriptedTool } from '@everruns/bashkit';
* import { ScriptedTool } from '@everruns/bashkit/scripted';
* const st = new ScriptedTool({ name: "api" });
* st.addTool("greet", "Greet user", (p) => `hello ${p.name}\n`);
* const langchainTool = createScriptedTool(st);
Expand All @@ -24,7 +24,8 @@

import { DynamicStructuredTool } from "@langchain/core/tools";
import { z } from "zod";
import { BashTool, ScriptedTool } from "./wrapper.js";
import { BashTool } from "./wrapper.js";
import { ScriptedTool } from "./scripted.js";
import type { BashOptions } from "./wrapper.js";

const bashInputSchema = z.object({
Expand Down Expand Up @@ -102,7 +103,7 @@ export function createBashTool(
*
* @example
* ```typescript
* import { ScriptedTool } from '@everruns/bashkit';
* import { ScriptedTool } from '@everruns/bashkit/scripted';
* import { createScriptedTool } from '@everruns/bashkit/langchain';
*
* const st = new ScriptedTool({ name: "api" });
Expand Down
7 changes: 7 additions & 0 deletions crates/bashkit-js/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
"browser": "./bashkit.wasi-browser.js",
"default": "./wrapper.js"
},
"./scripted": {
"default": "./scripted.js"
},
"./langchain": {
"default": "./langchain.js"
},
Expand Down Expand Up @@ -52,6 +55,10 @@
"files": [
"wrapper.js",
"wrapper.d.ts",
"scripted.js",
"scripted.d.ts",
"internal.js",
"internal.d.ts",
"langchain.js",
"langchain.d.ts",
"anthropic.js",
Expand Down
Loading
Loading