Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changeset/worker-shells-think.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/think": minor
---

Add `@cloudflare/think/workspace-bash`, an opt-in Computer workspace with Worker Shell execution exposed as the regular turn-level `bash` tool.
4 changes: 4 additions & 0 deletions packages/think/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,10 @@
"types": "./dist/workspace.d.ts",
"import": "./dist/workspace.js"
},
"./workspace-bash": {
"types": "./dist/workspace-bash.d.ts",
"import": "./dist/workspace-bash.js"
},
"./workspace-legacy": {
"types": "./dist/workspace-legacy.d.ts",
"import": "./dist/workspace-legacy.js"
Expand Down
1 change: 1 addition & 0 deletions packages/think/scripts/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ async function main() {
"src/messengers/index.ts",
"src/messengers/telegram.ts",
"src/workspace.ts",
"src/workspace-bash.ts",
"src/workspace-legacy.ts",
"src/tools/workspace.ts",
"src/tools/fetch.ts",
Expand Down
51 changes: 51 additions & 0 deletions packages/think/src/tests/agents/execute-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import type { LanguageModel } from "ai";
import { z } from "zod";
import type { DurableObjectStorageLike } from "@cloudflare/computer";
import { Think } from "../../think";
import { Workspace as BashWorkspace } from "../../workspace/workspace-bash";
import { workspaceToolProvider } from "../../workspace/types";
import { Workspace } from "../../workspace/workspace";
import {
createExecuteRuntime,
Expand All @@ -25,6 +27,12 @@ type ExecuteOutput = {
pending?: Array<{ connector: string; method: string }>;
};

function isAsyncIterable(value: unknown): value is AsyncIterable<unknown> {
return (
typeof value === "object" && value !== null && Symbol.asyncIterator in value
);
}

async function invoke(
executeTool: { execute?: unknown },
code: string
Expand Down Expand Up @@ -141,3 +149,46 @@ export class ThinkComputerWorkspaceExecuteAgent extends Think {
await this.workspace.fs.writeFile(path, content);
}
}

export class ThinkBashWorkspaceAgent extends Think {
override workspace = new BashWorkspace({
storage: this.ctx.storage as unknown as DurableObjectStorageLike,
binding: "ThinkBashWorkspaceAgent",
id: this.ctx.id.toString(),
backend: {
loader: this.env.LOADER,
ctx: this.ctx
}
});

getModel(): LanguageModel {
throw new Error("Model is not used in bash workspace tests");
}

async runBash(command: string): Promise<unknown> {
const bash = this.workspace[workspaceToolProvider]().bash.execute;
if (!bash) throw new Error("Bash workspace tool is missing");
const execution = bash(
{ command },
{
toolCallId: "test",
messages: [],
abortSignal: new AbortController().signal,
context: {}
}
);
if (!isAsyncIterable(execution)) return execution;

let terminal: unknown;
for await (const output of execution) terminal = output;
return terminal;
}

async runCodemodeState(code: string): Promise<ExecuteOutput> {
return invoke(createExecuteTool(this), code);
}

async readFile(path: string): Promise<string> {
return this.workspace.fs.readFile(path, "utf8");
}
}
1 change: 1 addition & 0 deletions packages/think/src/tests/agents/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export {
} from "./think-session";
export { ThinkFetchToolsTestAgent } from "./fetch-tools";
export {
ThinkBashWorkspaceAgent,
ThinkComputerWorkspaceExecuteAgent,
ThinkExecuteToolAgent
} from "./execute-tool";
Expand Down
31 changes: 31 additions & 0 deletions packages/think/src/tests/execute-tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,37 @@ describe("Computer workspace state connector", () => {
});
});

describe("Computer Bash workspace", () => {
it("runs turn-level bash against the durable Computer filesystem", async () => {
const agent = await getAgentByName(
env.ThinkBashWorkspaceAgent,
crypto.randomUUID()
);

await expect(
agent.runBash("printf 'from shell' > /workspace/result.txt")
).resolves.toMatchObject({ exitCode: 0 });
await expect(agent.readFile("/workspace/result.txt")).resolves.toBe(
"from shell"
);
});

it("keeps codemode on state.* without adding workspace.bash", async () => {
const agent = await getAgentByName(
env.ThinkBashWorkspaceAgent,
crypto.randomUUID()
);

await agent.runBash("printf codemode > /workspace/codemode.txt");
await expect(
agent.runCodemodeState(`async () => {
const file = await state.readFile({ path: "/workspace/codemode.txt" });
return file === "codemode" && typeof workspace === "undefined";
}`)
).resolves.toMatchObject({ status: "completed", result: true });
});
});

describe("execute tool on the codemode runtime", () => {
it("runs sandbox code against tools.* (ToolSetConnector)", async () => {
const agent = await freshAgent();
Expand Down
4 changes: 4 additions & 0 deletions packages/think/src/tests/worker.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { WorkspaceServiceProxy } from "../workspace/workspace-bash";
import { DurableObject, WorkerEntrypoint } from "cloudflare:workers";
import { routeAgentRequest } from "agents";
import { createBrowserRuntime, createBrowserTools } from "../tools/browser";
Expand Down Expand Up @@ -34,6 +35,7 @@ export {
ThinkNestedMiddleAgent,
StuckThinkAgentToolChild,
ThinkExtensionHookAgent,
ThinkBashWorkspaceAgent,
ThinkComputerWorkspaceExecuteAgent,
ThinkExecuteToolAgent,
ThinkExecuteHitlAgent,
Expand Down Expand Up @@ -74,6 +76,7 @@ import type {
ThinkNestedMiddleAgent,
StuckThinkAgentToolChild,
ThinkExtensionHookAgent,
ThinkBashWorkspaceAgent,
ThinkComputerWorkspaceExecuteAgent,
ThinkExecuteToolAgent,
ThinkExecuteHitlAgent,
Expand Down Expand Up @@ -220,6 +223,7 @@ export type Env = {
ThinkExtensionHookAgent: DurableObjectNamespace<ThinkExtensionHookAgent>;
ThinkMessengerRouteTestAgent: DurableObjectNamespace<ThinkMessengerRouteTestAgent>;
ThinkMcpToolMaterializationAgent: DurableObjectNamespace<ThinkMcpToolMaterializationAgent>;
ThinkBashWorkspaceAgent: DurableObjectNamespace<ThinkBashWorkspaceAgent>;
ThinkComputerWorkspaceExecuteAgent: DurableObjectNamespace<ThinkComputerWorkspaceExecuteAgent>;
ThinkExecuteToolAgent: DurableObjectNamespace<ThinkExecuteToolAgent>;
ThinkExecuteHitlAgent: DurableObjectNamespace<ThinkExecuteHitlAgent>;
Expand Down
89 changes: 89 additions & 0 deletions packages/think/src/tests/workspace-execution-providers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { describe, expect, it } from "vitest";
import type {
ThinkWorkspace,
ThinkWorkspaceRuntimeEvent
} from "../workspace/types";
import { createBashWorkspaceTools } from "../workspace/workspace-bash";

const toolContext = {
toolCallId: "test",
messages: [],
abortSignal: new AbortController().signal,
context: {}
};

function executableWorkspace(
onExec: (source: string, options: Record<string, unknown>) => void
): ThinkWorkspace {
return {
fs: {
async mkdir() {}
} as unknown as ThinkWorkspace["fs"],
runtime: {
async exec(source, options) {
onExec(source, options ?? {});
return {
async *[Symbol.asyncIterator]() {
yield {
name: "stdout",
value: "ok"
} satisfies ThinkWorkspaceRuntimeEvent;
yield {
name: "exit",
code: 0
} satisfies ThinkWorkspaceRuntimeEvent;
},
async result() {
return { exitCode: 0, stdout: "ok", stderr: "" };
}
};
}
}
};
}

function isAsyncIterable(value: unknown): value is AsyncIterable<unknown> {
return (
typeof value === "object" && value !== null && Symbol.asyncIterator in value
);
}

describe("Bash workspace tools", () => {
it("preserves streamed Worker Shell tool output", async () => {
const calls: Array<{ source: string; options: Record<string, unknown> }> =
[];
const tools = createBashWorkspaceTools(
executableWorkspace((source, options) => calls.push({ source, options })),
{ backendId: "shell" }
);

expect(Object.keys(tools)).toEqual(["bash"]);
const execution = tools.bash.execute?.({ command: "pwd" }, toolContext);
expect(isAsyncIterable(execution)).toBe(true);

const output: unknown[] = [];
if (isAsyncIterable(execution)) {
for await (const event of execution) output.push(event);
}

expect(output.at(-1)).toEqual({
command: "pwd",
cwd: null,
backend: "shell",
exitCode: 0,
stdout: "ok",
stderr: ""
});
expect(calls).toEqual([
{
source: "pwd",
options: {
backend: "shell",
encoding: "utf8",
env: undefined,
input: undefined
}
}
]);
});
});
6 changes: 6 additions & 0 deletions packages/think/src/tests/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"compatibility_date": "2026-06-11",
"compatibility_flags": [
"nodejs_compat",
"experimental",
"enable_nodejs_tty_module",
"enable_nodejs_fs_module",
"enable_nodejs_http_modules",
Expand Down Expand Up @@ -123,6 +124,10 @@
"class_name": "ThinkMcpToolMaterializationAgent",
"name": "ThinkMcpToolMaterializationAgent"
},
{
"class_name": "ThinkBashWorkspaceAgent",
"name": "ThinkBashWorkspaceAgent"
},
{
"class_name": "ThinkComputerWorkspaceExecuteAgent",
"name": "ThinkComputerWorkspaceExecuteAgent"
Expand Down Expand Up @@ -205,6 +210,7 @@
"ThinkExtensionHookAgent",
"ThinkMessengerRouteTestAgent",
"ThinkMcpToolMaterializationAgent",
"ThinkBashWorkspaceAgent",
"ThinkComputerWorkspaceExecuteAgent",
"ThinkExecuteToolAgent",
"ThinkExecuteHitlAgent",
Expand Down
17 changes: 17 additions & 0 deletions packages/think/src/think.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,15 +277,18 @@ const ACTION_PENDING_LAST_SWEPT_KEY =
"cf_think_action_pending_approvals:last_swept_at";
/** Prefix for durable-pause action execution ids (vs codemode execution ids). */
const ACTION_PAUSE_ID_PREFIX = "actpause_";
import type { WorkspaceStub } from "@cloudflare/computer";
import { LegacyWorkspace as Workspace } from "./workspace/workspace-legacy";
import { createWorkspaceTools } from "./tools/workspace";
import {
hasWorkspaceLegacyBashProvider,
hasWorkspaceStubProvider,
hasWorkspaceToolProvider,
normalizeWorkspacePath,
readWorkspaceText,
workspaceFilesystem,
writeWorkspaceFile,
workspaceStubProvider,
workspaceToolProvider,
type LegacyWorkspaceBashOptions,
type WorkspaceLike
Expand Down Expand Up @@ -328,11 +331,13 @@ export { LegacyWorkspace as Workspace } from "./workspace/workspace-legacy";
export type { FiberContext, FiberRecoveryContext } from "agents";
export {
hasWorkspaceLegacyBashProvider,
hasWorkspaceStubProvider,
hasWorkspaceToolProvider,
normalizeWorkspacePath,
readWorkspaceText,
workspaceFilesystem,
workspaceLegacyBashProvider,
workspaceStubProvider,
workspaceToolProvider,
writeWorkspaceFile
} from "./workspace/types";
Expand All @@ -345,6 +350,7 @@ export type {
ThinkWorkspaceRuntimeValue,
WorkspaceLegacyBashProvider,
WorkspaceLike,
WorkspaceStubProvider,
WorkspaceToolProvider,
WorkspaceToolProviderOptions
} from "./workspace/types";
Expand Down Expand Up @@ -7064,6 +7070,17 @@ export class Think<

// ── Host bridge methods (called by HostBridgeLoopback via DO RPC) ──

/** @internal Used by Computer backends that call into this workspace. */
async __getWorkspaceStub(): Promise<WorkspaceStub> {
if (!hasWorkspaceStubProvider(this.workspace)) {
throw new Error(
"This workspace does not expose a service stub. Configure " +
"@cloudflare/think/workspace-bash before exporting WorkspaceServiceProxy."
);
}
return this.workspace[workspaceStubProvider]();
}

async _hostReadFile(path: string): Promise<string | null> {
return readWorkspaceText(this.workspace, path);
}
Expand Down
1 change: 1 addition & 0 deletions packages/think/src/workspace-bash.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./workspace/workspace-bash";
16 changes: 16 additions & 0 deletions packages/think/src/workspace/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { WorkspaceStub } from "@cloudflare/computer";
import type {
FileInfo as LegacyFileInfo,
WorkspaceFsLike
Expand Down Expand Up @@ -124,6 +125,10 @@ export const workspaceToolProvider: unique symbol = Symbol.for(
"@cloudflare/think/workspace-tool-provider"
) as unknown as typeof workspaceToolProvider;

export const workspaceStubProvider: unique symbol = Symbol.for(
"@cloudflare/think/workspace-stub-provider"
) as unknown as typeof workspaceStubProvider;

export const workspaceLegacyBashProvider: unique symbol = Symbol.for(
"@cloudflare/think/workspace-legacy-bash-provider"
) as unknown as typeof workspaceLegacyBashProvider;
Expand All @@ -146,6 +151,10 @@ export interface WorkspaceToolProvider {
[workspaceToolProvider](options?: WorkspaceToolProviderOptions): ToolSet;
}

export interface WorkspaceStubProvider {
[workspaceStubProvider](): Promise<WorkspaceStub>;
}

export interface WorkspaceLegacyBashProvider {
readonly [workspaceLegacyBashProvider]: true;
}
Expand All @@ -157,6 +166,13 @@ export function hasWorkspaceToolProvider(
return typeof candidate[workspaceToolProvider] === "function";
}

export function hasWorkspaceStubProvider(
workspace: WorkspaceLike
): workspace is WorkspaceLike & WorkspaceStubProvider {
const candidate = workspace as WorkspaceLike & Partial<WorkspaceStubProvider>;
return typeof candidate[workspaceStubProvider] === "function";
}

export function hasWorkspaceLegacyBashProvider(
workspace: WorkspaceLike
): workspace is WorkspaceLike & WorkspaceLegacyBashProvider {
Expand Down
Loading
Loading