diff --git a/apps/mobile/src/features/settings/ThreadSettingsSections.tsx b/apps/mobile/src/features/settings/ThreadSettingsSections.tsx
index 2e77d4d3937e7..e9050922bbb9e 100644
--- a/apps/mobile/src/features/settings/ThreadSettingsSections.tsx
+++ b/apps/mobile/src/features/settings/ThreadSettingsSections.tsx
@@ -250,6 +250,27 @@ function BetaSettingsSection() {
Let agents on a machine work on your other machines, and theirs on it. Turn it on for each
machine. While it is off, nothing new starts over a link; tasks already running finish.
+
+ {supported.map((environment) => (
+
+ void updateSettings({
+ environmentId: environment.environmentId,
+ input: { patch: { scriptToolEnabled: value } },
+ })
+ }
+ />
+ ))}
+
+
+ Let agents in full-access threads run one program that makes many tool calls and reads only
+ what it prints. Its file and shell calls skip provider permission prompts.
+
);
}
diff --git a/apps/server/src/binCli.ts b/apps/server/src/binCli.ts
index b26b4fd9f0dbc..be789bacabaf0 100644
--- a/apps/server/src/binCli.ts
+++ b/apps/server/src/binCli.ts
@@ -25,6 +25,7 @@ import { updateCommand } from "./cli/update.ts";
import { uninstallCommand } from "./cli/uninstall.ts";
import { serviceLauncherCommand } from "./cli/serviceLauncher.ts";
import { claudeHistoryCommand } from "./cli/claudeHistory.ts";
+import { scriptSandboxCommand } from "./cli/scriptSandbox.ts";
import { sshHelperCommand } from "./cli/sshHelper.ts";
import { serviceCommand } from "./cli/service.ts";
import { servicePreflightCommand } from "./cli/servicePreflight.ts";
@@ -85,6 +86,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) =>
uninstallCommand,
serviceLauncherCommand,
claudeHistoryCommand,
+ scriptSandboxCommand,
sshHelperCommand,
servicePreflightCommand,
diff --git a/apps/server/src/cli/scriptSandbox.ts b/apps/server/src/cli/scriptSandbox.ts
new file mode 100644
index 0000000000000..a6cab6ab1c9ca
--- /dev/null
+++ b/apps/server/src/cli/scriptSandbox.ts
@@ -0,0 +1,10 @@
+import * as Effect from "effect/Effect";
+import { Command } from "effect/cli";
+
+import { runScriptSandboxWorker } from "../script/scriptSandboxWorker.ts";
+
+/** Single-executable builds lack Node, so they use this subcommand instead of script-sandbox-worker.mjs. */
+export const scriptSandboxCommand = Command.make("__script-sandbox", {}).pipe(
+ Command.unlisted,
+ Command.withHandler(() => Effect.promise(() => runScriptSandboxWorker())),
+);
diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts
index e684316f48d9a..a1c4e558d3934 100644
--- a/apps/server/src/mcp/McpHttpServer.ts
+++ b/apps/server/src/mcp/McpHttpServer.ts
@@ -75,6 +75,9 @@ import {
DeviceScreenshotToolkit,
DeviceStandardToolkit,
} from "./toolkits/device/tools.ts";
+import * as ScriptRunner from "../script/ScriptRunner.ts";
+import * as ScriptHandlers from "./toolkits/script/handlers.ts";
+import { ScriptToolkit } from "./toolkits/script/tools.ts";
import * as HtmlHandlers from "./toolkits/html/handlers.ts";
import { HtmlPreviewTool, HtmlPreviewToolkit, HtmlRenderToolkit } from "./toolkits/html/tools.ts";
@@ -1115,6 +1118,28 @@ const layerEnvironmentToolkit = toolkitRegistration(
export const layerProjectRegistration = toolkitRegistration(ProjectToolkit, ProjectHandlers.layer);
+// Nested T3 calls need the same server instance the tools registered on, which layers memoize.
+const layerScriptT3Tools = Layer.effect(
+ ScriptHandlers.ScriptT3Tools,
+ Effect.gen(function* () {
+ const server = yield* McpServer.McpServer;
+ return ScriptHandlers.ScriptT3Tools.of({
+ call: Effect.fn("ScriptT3Tools.call")(function* (request) {
+ return yield* server.callTool({
+ name: request.name,
+ arguments: request.arguments as Record,
+ });
+ }),
+ });
+ }),
+);
+
+export const layerScriptToolkit = toolkitRegistration(ScriptToolkit, ScriptHandlers.layer).pipe(
+ Layer.provide(ScriptRunner.layer),
+ Layer.provide(layerScriptT3Tools),
+ Layer.provide(McpServer.McpServer.layer),
+);
+
export const layerAttachmentToolkit = toolkitRegistration(
AttachmentToolkit,
AttachmentHandlers.layer,
@@ -1161,6 +1186,7 @@ export const layerTools = Layer.mergeAll(
layerPullRequestsToolkit,
layerDeviceToolkit,
layerHtmlToolkit,
+ layerScriptToolkit,
);
export const layer = layerTools.pipe(Layer.provideMerge(layerMcpTransport));
diff --git a/apps/server/src/mcp/McpInvocationContext.ts b/apps/server/src/mcp/McpInvocationContext.ts
index 5ce9669bc4d83..839e42541eee6 100644
--- a/apps/server/src/mcp/McpInvocationContext.ts
+++ b/apps/server/src/mcp/McpInvocationContext.ts
@@ -20,6 +20,7 @@ const ALL_MCP_CAPABILITIES = [
"worktree",
"device",
"pull-requests",
+ "script",
] as const;
export type McpCapability = (typeof ALL_MCP_CAPABILITIES)[number];
@@ -144,6 +145,13 @@ export const hiddenWhileLinksOff = (): boolean => {
return scope?.linkedEnvironments !== false;
};
+/** `McpSchema.EnabledWhen` for the script tool, which only credentials issued while it is on hold. */
+export const hiddenWhileScriptToolOff = (): boolean => {
+ const fiber = Fiber.getCurrent();
+ const scope = fiber && Context.getOrUndefined(fiber.context, McpInvocationContext);
+ return scope?.capabilities.has("script") === true;
+};
+
export type McpCapabilityError = C extends "preview"
? PreviewAutomationUnavailableError
: McpCapabilityUnavailableError;
diff --git a/apps/server/src/mcp/toolkits/script/handlers.test.ts b/apps/server/src/mcp/toolkits/script/handlers.test.ts
new file mode 100644
index 0000000000000..6295835d55270
--- /dev/null
+++ b/apps/server/src/mcp/toolkits/script/handlers.test.ts
@@ -0,0 +1,175 @@
+import { expect, it } from "@effect/vitest";
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import { EnvironmentId, ProviderInstanceId, type RuntimeMode, ThreadId } from "@t3tools/contracts";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Layer from "effect/Layer";
+import * as Path from "effect/Path";
+import * as Schema from "effect/Schema";
+import { McpSchema, McpServer, Tool, Toolkit } from "effect/ai";
+
+import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts";
+import * as ProjectService from "../../../project/ProjectService.ts";
+import * as ServerSettings from "../../../serverSettings.ts";
+import * as McpHttpServer from "../../McpHttpServer.ts";
+import * as McpInvocationContext from "../../McpInvocationContext.ts";
+import * as McpToolAccess from "../../McpToolAccess.ts";
+import { liveThreadShell } from "../../McpToolAccess.testkit.ts";
+import { ScriptToolkit } from "./tools.ts";
+
+const threadId = ThreadId.make("thread-script-test");
+const invocation = (capabilities: ReadonlyArray) => ({
+ environmentId: EnvironmentId.make("environment-script-test"),
+ requestNamespace: "script-test",
+ thread: {
+ threadId,
+ providerSessionId: "script-test",
+ providerInstanceId: ProviderInstanceId.make("codex"),
+ },
+ client: undefined,
+ capabilities: new Set(capabilities),
+ issuedAt: 1,
+});
+const client = McpSchema.McpServerClient.of({
+ clientId: 1,
+ clientCapabilities: {},
+ clientInfo: { name: "script-test", version: "1.0.0" },
+ protocolVersion: "2025-06-18",
+ initializePayload: {
+ protocolVersion: "2025-06-18",
+ capabilities: {},
+ clientInfo: { name: "script-test", version: "1.0.0" },
+ },
+ getClient: Effect.die("unused"),
+});
+
+const PingToolkit = Toolkit.make(
+ Tool.make("t3_ping", {
+ parameters: Schema.Struct({ value: Schema.String }),
+ success: Schema.Struct({ pong: Schema.String }),
+ }),
+);
+
+const harness = (input: {
+ readonly cwd: string;
+ readonly enabled: boolean;
+ readonly runtimeMode?: RuntimeMode;
+}) =>
+ Layer.mergeAll(
+ McpHttpServer.layerScriptToolkit,
+ McpHttpServer.toolkitRegistration(
+ PingToolkit,
+ McpToolAccess.toLayer(PingToolkit, {
+ t3_ping: McpToolAccess.reads(({ value }) => Effect.succeed({ pong: value })),
+ }),
+ ),
+ ).pipe(
+ Layer.provideMerge(McpServer.McpServer.layer),
+ Layer.provide(
+ Layer.mergeAll(
+ Layer.mock(ThreadManagementService.ThreadManagementService)({
+ getThreadShell: (id) =>
+ Effect.succeed({
+ ...liveThreadShell(id, { runtimeMode: input.runtimeMode ?? "full-access" }),
+ worktreePath: input.cwd,
+ }),
+ }),
+ Layer.mock(ProjectService.ProjectService)({ getById: () => Effect.succeedNone }),
+ ServerSettings.layerTest({ scriptToolEnabled: input.enabled }),
+ ),
+ ),
+ Layer.provideMerge(NodeServices.layer),
+ );
+
+const runScript = (code: string) =>
+ Effect.gen(function* () {
+ const server = yield* McpServer.McpServer;
+ return yield* server
+ .callTool({ name: "script", arguments: { code } })
+ .pipe(
+ Effect.provideService(McpInvocationContext.McpInvocationContext, invocation(["script"])),
+ Effect.provideService(McpSchema.McpServerClient, client),
+ );
+ });
+
+const inTemp = (
+ run: (cwd: string, path: Path.Path, fs: FileSystem.FileSystem) => Effect.Effect,
+) =>
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-script-test-" });
+ return yield* run(cwd, path, fs);
+ }).pipe(Effect.scoped, Effect.provide(NodeServices.layer));
+
+it.live("runs read, write, exec, grep, and T3 tools, and returns only what it prints", () =>
+ inTemp((cwd, path, fs) =>
+ Effect.gen(function* () {
+ yield* fs.writeFileString(path.join(cwd, "a.txt"), "first\nneedle here\n");
+ const result = yield* runScript(`
+ await tools.exec("git init -q");
+ const read = await tools.read("a.txt");
+ await tools.write({ path: "nested/b.txt", content: read.toUpperCase() });
+ const echoed = await tools.exec("echo hello");
+ const found = await tools.grep("needle");
+ const ping = await tools.t3_ping({ value: "x" });
+ const failed = await Promise.allSettled([tools.read("missing.txt")]);
+ let nested = "none";
+ try { await tools.script({ code: "" }); } catch (error) { nested = error.message; }
+ text(echoed.stdout.trim(), echoed.exitCode, found.matches, ping.pong, failed[0].status, nested);
+ `).pipe(Effect.provide(harness({ cwd, enabled: true })));
+
+ expect(result.isError).toBe(false);
+ const body = result.structuredContent as {
+ readonly status: string;
+ readonly output: string;
+ readonly toolCalls: number;
+ readonly failedToolCalls: number;
+ };
+ expect(body.status).toBe("completed");
+ expect(body.output).toContain("hello 0");
+ expect(body.output).toContain('"path": "a.txt"');
+ expect(body.output).toContain('"line": 2');
+ expect(body.output).toContain("x rejected A script cannot start another script.");
+ expect(body.toolCalls).toBe(8);
+ expect(body.failedToolCalls).toBe(2);
+ expect(yield* fs.readFileString(path.join(cwd, "nested/b.txt"))).toBe("FIRST\nNEEDLE HERE\n");
+ }),
+ ),
+);
+
+it.live.each([
+ { name: "the switch is off", enabled: false, runtimeMode: "full-access", message: "is off" },
+ {
+ name: "the thread is not full access",
+ enabled: true,
+ runtimeMode: "approval-required",
+ message: "full-access thread",
+ },
+] as const)("refuses to start while $name", ({ enabled, runtimeMode, message }) =>
+ inTemp((cwd, path, fs) =>
+ Effect.gen(function* () {
+ const result = yield* runScript(`await tools.write({ path: "x.txt", content: "x" });`).pipe(
+ Effect.provide(harness({ cwd, enabled, runtimeMode })),
+ );
+ expect(result.isError).toBe(true);
+ expect(JSON.stringify(result.content)).toContain(message);
+ expect(yield* fs.exists(path.join(cwd, "x.txt"))).toBe(false);
+ }),
+ ),
+);
+
+it.effect("lists the tool only to credentials holding the script capability", () =>
+ Effect.gen(function* () {
+ const script = ScriptToolkit.tools.script;
+ const enabledWhen = yield* Effect.succeed(
+ script.annotations.mapUnsafe.get(McpSchema.EnabledWhen.key) as () => boolean,
+ );
+ const visibleTo = (capabilities: ReadonlyArray) =>
+ Effect.sync(enabledWhen).pipe(
+ Effect.provideService(McpInvocationContext.McpInvocationContext, invocation(capabilities)),
+ );
+ expect(yield* visibleTo(["script"])).toBe(true);
+ expect(yield* visibleTo([])).toBe(false);
+ }),
+);
diff --git a/apps/server/src/mcp/toolkits/script/handlers.ts b/apps/server/src/mcp/toolkits/script/handlers.ts
new file mode 100644
index 0000000000000..c966cd517f598
--- /dev/null
+++ b/apps/server/src/mcp/toolkits/script/handlers.ts
@@ -0,0 +1,266 @@
+import { OrchestratorMcpFailure, type ThreadId } from "@t3tools/contracts";
+import * as Context from "effect/Context";
+import * as Duration from "effect/Duration";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Option from "effect/Option";
+import * as Path from "effect/Path";
+import * as Stream from "effect/Stream";
+import { McpSchema } from "effect/ai";
+import * as ChildProcess from "effect/process/ChildProcess";
+import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
+import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText";
+// @effect-diagnostics-next-line nodeBuiltinImport:off - effect/Path has no glob matching.
+import * as NodePath from "node:path";
+
+import { threadWorkspaceRoot } from "../../../project/threadWorkspaceRoot.ts";
+import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts";
+import * as ProjectService from "../../../project/ProjectService.ts";
+import * as ScriptRunner from "../../../script/ScriptRunner.ts";
+import * as ServerSettings from "../../../serverSettings.ts";
+import * as McpInvocationContext from "../../McpInvocationContext.ts";
+import * as McpToolAccess from "../../McpToolAccess.ts";
+import { assertFullAccess, assertLiveCaller, loadCaller } from "../../threadAccess.ts";
+import { SCRIPT_TOOL_NAME, ScriptToolkit } from "./tools.ts";
+
+const MAX_READ_BYTES = 10 * 1024 * 1024;
+const MAX_EXEC_OUTPUT_BYTES = 2 * 1024 * 1024;
+const DEFAULT_EXEC_TIMEOUT_MS = 120_000;
+const MAX_GREP_MATCHES = 2_000;
+const MAX_GREP_LINE_CHARS = 500;
+
+const FULL_ACCESS_REQUIRED =
+ "script needs a full-access thread, because its calls skip the provider's permission prompts.";
+
+const refuse = (message: string) =>
+ new OrchestratorMcpFailure({ code: "capability_denied", message });
+
+const errorText = (error: unknown) =>
+ error instanceof Error ? error.message : typeof error === "string" ? error : String(error);
+
+const fieldsOf = (input: unknown, primary: string): Readonly> =>
+ typeof input === "string"
+ ? { [primary]: input }
+ : typeof input === "object" && input !== null
+ ? (input as Readonly>)
+ : {};
+
+const stringField = (fields: Readonly>, name: string, tool: string) =>
+ typeof fields[name] === "string"
+ ? Effect.succeed(fields[name])
+ : Effect.fail(`tools.${tool} needs a string \`${name}\`.`);
+
+const childFailure = (tool: string) => (error: unknown) => `tools.${tool}: ${errorText(error)}`;
+
+/** Calls a T3 tool as the script's caller. McpHttpServer owns the MCP server, so it provides this. */
+export class ScriptT3Tools extends Context.Service<
+ ScriptT3Tools,
+ {
+ readonly call: (request: {
+ readonly name: string;
+ readonly arguments: object;
+ }) => Effect.Effect<
+ McpSchema.CallToolResult,
+ McpSchema.InternalError | McpSchema.InvalidParams,
+ McpSchema.McpServerClient
+ >;
+ }
+>()("t3/mcp/toolkits/script/handlers/ScriptT3Tools") {}
+
+export const layer = McpToolAccess.toLayer(
+ ScriptToolkit,
+ Effect.gen(function* () {
+ const t3Tools = yield* ScriptT3Tools;
+ const runner = yield* ScriptRunner.ScriptRunner;
+ const settings = yield* ServerSettings.ServerSettingsService;
+ const projects = yield* ProjectService.ProjectService;
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
+
+ const exec = Effect.fn("ScriptToolkit.exec")(
+ function* (cwd: string, input: unknown) {
+ const fields = fieldsOf(input, "command");
+ const command = yield* stringField(fields, "command", "exec");
+ const timeoutMs =
+ typeof fields.timeoutMs === "number" && fields.timeoutMs > 0
+ ? fields.timeoutMs
+ : DEFAULT_EXEC_TIMEOUT_MS;
+ const handle = yield* spawner.spawn(
+ ChildProcess.make(command, [], {
+ cwd,
+ shell: true,
+ forceKillAfter: Duration.seconds(1),
+ }),
+ );
+ const collect = (stream: Stream.Stream) =>
+ collectUint8StreamText({
+ stream,
+ maxBytes: MAX_EXEC_OUTPUT_BYTES,
+ truncatedMarker: "\n[output truncated]",
+ });
+ const finished = yield* Effect.all(
+ [collect(handle.stdout), collect(handle.stderr), handle.exitCode],
+ { concurrency: "unbounded" },
+ ).pipe(Effect.timeoutOption(Duration.millis(timeoutMs)));
+ if (Option.isNone(finished)) {
+ return { stdout: "", stderr: "", exitCode: null, timedOut: true };
+ }
+ const [stdout, stderr, exitCode] = finished.value;
+ return { stdout: stdout.text, stderr: stderr.text, exitCode, timedOut: false };
+ },
+ Effect.scoped,
+ Effect.mapError(childFailure("exec")),
+ );
+
+ const read = Effect.fn("ScriptToolkit.read")(
+ function* (cwd: string, input: unknown) {
+ const target = path.resolve(
+ cwd,
+ yield* stringField(fieldsOf(input, "path"), "path", "read"),
+ );
+ const info = yield* fs.stat(target);
+ if (info.size > MAX_READ_BYTES) {
+ return yield* Effect.fail(`${target} is larger than ${MAX_READ_BYTES} bytes.`);
+ }
+ return yield* fs.readFileString(target);
+ },
+ Effect.mapError(childFailure("read")),
+ );
+
+ const write = Effect.fn("ScriptToolkit.write")(
+ function* (cwd: string, input: unknown) {
+ const fields = fieldsOf(input, "path");
+ const target = path.resolve(cwd, yield* stringField(fields, "path", "write"));
+ const content = yield* stringField(fields, "content", "write");
+ yield* fs.makeDirectory(path.dirname(target), { recursive: true });
+ yield* fs.writeFileString(target, content);
+ return { path: target, bytes: Buffer.byteLength(content) };
+ },
+ Effect.mapError(childFailure("write")),
+ );
+
+ const grep = Effect.fn("ScriptToolkit.grep")(
+ function* (cwd: string, input: unknown) {
+ const fields = fieldsOf(input, "pattern");
+ const pattern = yield* stringField(fields, "pattern", "grep");
+ const regex = yield* Effect.try({
+ try: () => new RegExp(pattern, fields.ignoreCase === true ? "i" : ""),
+ catch: () => `\`${pattern}\` is not a valid regular expression.`,
+ });
+ const scope = typeof fields.path === "string" ? fields.path : ".";
+ const glob = typeof fields.glob === "string" ? fields.glob : undefined;
+ const listed = yield* exec(
+ cwd,
+ `git ls-files -co --exclude-standard -- "${scope.replaceAll('"', "")}"`,
+ );
+ if (listed.exitCode !== 0) {
+ return yield* Effect.fail(`git could not list files here: ${listed.stderr.trim()}`);
+ }
+ const matches: Array<{ path: string; line: number; text: string }> = [];
+ for (const file of listed.stdout.split(/\r?\n/)) {
+ if (file.length === 0) continue;
+ if (glob !== undefined && !NodePath.matchesGlob(file, glob)) continue;
+ const target = path.resolve(cwd, file);
+ const text = yield* fs.stat(target).pipe(
+ Effect.flatMap((info) =>
+ info.type === "File" && info.size <= MAX_READ_BYTES
+ ? fs.readFileString(target)
+ : Effect.succeed(""),
+ ),
+ // Binary, deleted, or unreadable files just don't match.
+ Effect.orElseSucceed(() => ""),
+ );
+ for (const [index, line] of text.split(/\r?\n/).entries()) {
+ if (!regex.test(line)) continue;
+ if (matches.length === MAX_GREP_MATCHES) return { matches, truncated: true };
+ matches.push({
+ path: file,
+ line: index + 1,
+ text: line.slice(0, MAX_GREP_LINE_CHARS),
+ });
+ }
+ }
+ return { matches, truncated: false };
+ },
+ Effect.mapError(childFailure("grep")),
+ );
+
+ const t3Tool = Effect.fn("ScriptToolkit.t3Tool")(
+ function* (name: string, input: unknown) {
+ const client = yield* Effect.serviceOption(McpSchema.McpServerClient);
+ if (Option.isNone(client)) return yield* Effect.fail("No MCP client for this call.");
+ const result = yield* t3Tools
+ .call({
+ name,
+ arguments: typeof input === "object" && input !== null ? input : {},
+ })
+ .pipe(Effect.provideService(McpSchema.McpServerClient, client.value));
+ const text = result.content
+ .flatMap((part) => (part.type === "text" ? [part.text] : []))
+ .join("\n");
+ if (result.isError === true) return yield* Effect.fail(text || `${name} failed.`);
+ return result.structuredContent ?? text;
+ },
+ (effect, name) => effect.pipe(Effect.mapError(childFailure(name))),
+ );
+
+ return {
+ [SCRIPT_TOOL_NAME]: McpToolAccess.actsAsCaller((input: { readonly code: string }) =>
+ Effect.gen(function* () {
+ const scope = yield* McpInvocationContext.McpInvocationContext;
+ const { thread } = yield* McpInvocationContext.requireThreadScope(
+ scope,
+ SCRIPT_TOOL_NAME,
+ );
+ const stillAllowed = Effect.gen(function* () {
+ const enabled = yield* settings.getSettings.pipe(
+ Effect.map((current) => current.scriptToolEnabled),
+ Effect.orElseSucceed(() => false),
+ );
+ if (!enabled) {
+ return yield* refuse("The script tool is off. Turn it on in Settings → Labs.");
+ }
+ const caller = yield* loadCaller();
+ yield* assertLiveCaller(caller);
+ yield* assertFullAccess(caller, FULL_ACCESS_REQUIRED);
+ });
+ yield* stillAllowed;
+ const cwd = yield* threadWorkspaceRoot(thread.threadId as ThreadId).pipe(
+ Effect.provideService(ProjectService.ProjectService, projects),
+ );
+ if (cwd === undefined) {
+ return yield* new OrchestratorMcpFailure({
+ code: "invalid_request",
+ message: "This thread has no workspace to run a script in.",
+ });
+ }
+ const context = yield* Effect.context<
+ | McpInvocationContext.McpInvocationContext
+ | ThreadManagementService.ThreadManagementService
+ >();
+ const call: ScriptRunner.ScriptToolCall = (tool, toolInput) =>
+ // A script outlives the checks above: the thread can stop or leave full access mid-run.
+ stillAllowed.pipe(
+ Effect.mapError((failure) => failure.message),
+ Effect.andThen(
+ tool === "read"
+ ? read(cwd, toolInput)
+ : tool === "write"
+ ? write(cwd, toolInput)
+ : tool === "exec"
+ ? exec(cwd, toolInput)
+ : tool === "grep"
+ ? grep(cwd, toolInput)
+ : tool === SCRIPT_TOOL_NAME
+ ? Effect.fail("A script cannot start another script.")
+ : t3Tool(tool, toolInput),
+ ),
+ Effect.provideContext(context),
+ );
+ return yield* runner.run({ code: input.code, call });
+ }),
+ ),
+ };
+ }),
+);
diff --git a/apps/server/src/mcp/toolkits/script/tools.ts b/apps/server/src/mcp/toolkits/script/tools.ts
new file mode 100644
index 0000000000000..17b14efb79e42
--- /dev/null
+++ b/apps/server/src/mcp/toolkits/script/tools.ts
@@ -0,0 +1,40 @@
+import { OrchestratorMcpFailure } from "@t3tools/contracts";
+import * as Schema from "effect/Schema";
+import { McpSchema, Tool, Toolkit } from "effect/ai";
+
+import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts";
+import * as McpInvocationContext from "../../McpInvocationContext.ts";
+
+export const SCRIPT_TOOL_NAME = "script";
+
+const ScriptTool = Tool.make(SCRIPT_TOOL_NAME, {
+ description: `Run one JavaScript program that makes many tool calls. Only what it passes to \`text(...)\` returns to you, so filter and count in the program. Requires a full-access thread. There is no Node, console, timer, file, or network access besides \`tools\`: \`await tools.read(path)\` returns text; \`tools.write({path, content})\`; \`tools.exec(command)\` or \`tools.exec({command, timeoutMs})\` returns \`{stdout, stderr, exitCode, timedOut}\` from cmd.exe on Windows and sh elsewhere; \`tools.grep(pattern)\` or \`tools.grep({pattern, path, glob, ignoreCase})\` searches git-tracked and new files and returns \`{matches: [{path, line, text}], truncated}\`; and any T3 tool by name, such as \`tools.t3_thread_list({})\`. Other MCP servers are not reachable. Relative paths start at the thread's workspace. A failed call rejects, so use \`Promise.allSettled\` to keep a batch going. At most 16 calls run at once, 1,000 per script, and the script stops after 5 minutes.`,
+ parameters: Schema.Struct({
+ code: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(200_000)).annotate({
+ description: "Raw JavaScript, run as the body of an async function. Top-level await works.",
+ }),
+ }),
+ success: Schema.Struct({
+ status: Schema.Literals(["completed", "failed", "timed_out"]),
+ output: Schema.String,
+ outputTruncated: Schema.Boolean,
+ error: Schema.optional(Schema.String),
+ toolCalls: Schema.Int,
+ failedToolCalls: Schema.Int,
+ elapsedMs: Schema.Int,
+ }),
+ failure: OrchestratorMcpFailure,
+ failureMode: "return",
+ dependencies: [
+ McpInvocationContext.McpInvocationContext,
+ ThreadManagementService.ThreadManagementService,
+ ],
+})
+ .annotate(Tool.Title, "Run script")
+ .annotate(Tool.Readonly, false)
+ .annotate(Tool.Destructive, true)
+ .annotate(Tool.Idempotent, false)
+ .annotate(Tool.OpenWorld, true)
+ .annotate(McpSchema.EnabledWhen, McpInvocationContext.hiddenWhileScriptToolOff);
+
+export const ScriptToolkit = Toolkit.make(ScriptTool);
diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
index d2d41831f8c13..c1c35e6330e31 100644
--- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
+++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
@@ -663,7 +663,12 @@ describe("ClaudeAdapterV2 native protocol logging", () => {
});
describe("ClaudeAdapterV2 device instructions", () => {
- const options = (input: { readonly mcp: boolean; readonly deviceToolsAvailable: boolean }) =>
+ interface OptionsInput {
+ readonly mcp: boolean;
+ readonly deviceToolsAvailable: boolean;
+ readonly scriptToolAvailable?: boolean;
+ }
+ const options = (input: OptionsInput) =>
ClaudeAdapterV2.makeClaudeQueryOptions({
modelSelection: {
instanceId: ProviderInstanceId.make("claudeAgent"),
@@ -676,9 +681,10 @@ describe("ClaudeAdapterV2 device instructions", () => {
? { mcpServers: { "t3-code": { type: "http" as const, url: "http://127.0.0.1/mcp" } } }
: {}),
deviceToolsAvailable: input.deviceToolsAvailable,
+ scriptToolAvailable: input.scriptToolAvailable ?? false,
environment: {},
});
- const append = (input: { readonly mcp: boolean; readonly deviceToolsAvailable: boolean }) =>
+ const append = (input: OptionsInput) =>
(options(input).systemPrompt as { readonly append?: string }).append ?? "";
it("gives a session with device access the device block and the ToolSearch line to load it", () => {
@@ -690,6 +696,11 @@ describe("ClaudeAdapterV2 device instructions", () => {
it("leaves the device block out without device access or without T3 tools", () => {
assert.notInclude(append({ mcp: true, deviceToolsAvailable: false }), "## T3 Code devices");
assert.notInclude(append({ mcp: false, deviceToolsAvailable: true }), "## T3 Code devices");
+ assert.notInclude(append({ mcp: true, deviceToolsAvailable: true }), "## T3 Code script tool");
+ assert.include(
+ append({ mcp: true, deviceToolsAvailable: false, scriptToolAvailable: true }),
+ "select:mcp__t3-code__script",
+ );
});
it("gives native subagents the device block only in a session with device access", async () => {
diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
index 08a103b386847..ecf291755c874 100644
--- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
+++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
@@ -178,6 +178,7 @@ import type { ManagedServerProvider } from "@t3tools/provider-core/server/snapsh
import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment";
import {
T3_CODE_DEVICE_TOOL_INSTRUCTIONS_WITH_TOOL_SEARCH,
+ T3_CODE_SCRIPT_TOOL_INSTRUCTIONS_WITH_TOOL_SEARCH,
T3_CODE_ORCHESTRATION_INSTRUCTIONS,
} from "@t3tools/provider-core/server/orchestrationInstructions";
import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions";
@@ -1471,6 +1472,7 @@ export function makeClaudeQueryOptions(input: {
readonly mcpServers?: ClaudeQueryOptions["mcpServers"];
/** The thread's T3 MCP session holds the `device` capability. */
readonly deviceToolsAvailable?: boolean;
+ readonly scriptToolAvailable?: boolean;
readonly tools?: ClaudeAgentSdkQueryTools;
readonly allowedTools?: ReadonlyArray;
readonly disallowedTools?: ReadonlyArray;
@@ -1596,6 +1598,9 @@ export function makeClaudeQueryOptions(input: {
CLAUDE_BACKGROUND_WAIT_INSTRUCTIONS +
(deviceInstructions
? `\n${T3_CODE_DEVICE_TOOL_INSTRUCTIONS_WITH_TOOL_SEARCH}\n`
+ : "") +
+ (input.scriptToolAvailable === true
+ ? `\n${T3_CODE_SCRIPT_TOOL_INSTRUCTIONS_WITH_TOOL_SEARCH}\n`
: "")),
...launchAppendSystemPrompt,
].join("\n\n"),
@@ -10230,6 +10235,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
? {}
: { mcpServers: mcpOverrides.mcpServers }),
deviceToolsAvailable: mcpSession?.capabilities?.has("device") === true,
+ scriptToolAvailable: mcpSession?.capabilities?.has("script") === true,
permissionMode: queryPolicy.permissionMode,
...(queryPolicy.allowDangerouslySkipPermissions === undefined
? {}
diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts
index 2e2fdd4331c1f..a81e60bf17f70 100644
--- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts
+++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts
@@ -732,6 +732,7 @@ export const buildCodexTurnStartParams = Effect.fn("CodexAdapterV2.buildCodexTur
readonly hasT3Mcp?: boolean;
readonly browserToolsAvailable?: boolean;
readonly deviceToolsAvailable?: boolean;
+ readonly scriptToolAvailable?: boolean;
/** ChatGPT token sharing does not accept service tiers. */
readonly omitServiceTier?: boolean;
readonly appContext?: ProviderAdapter.ProviderAdapterV2TurnInput["appContext"];
@@ -768,6 +769,7 @@ export const buildCodexTurnStartParams = Effect.fn("CodexAdapterV2.buildCodexTur
{
browser: input.browserToolsAvailable ?? true,
device: input.deviceToolsAvailable ?? false,
+ script: input.scriptToolAvailable ?? false,
},
)
: undefined;
@@ -1502,6 +1504,7 @@ export const layerAppServerClientFactory: Layer.Layer<
command: input.settings.binaryPath || "codex",
args: codexAppServerArgs(
resolveCodexLaunchArgs(input.settings.launchArgs, input.environment),
+ { codeMode: input.settings.codeMode },
),
env: environment,
threadId: input.threadId,
@@ -6637,6 +6640,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
hasT3Mcp: mcpSession !== undefined,
browserToolsAvailable: mcpSession?.browserToolsAvailable ?? true,
deviceToolsAvailable: mcpSession?.capabilities?.has("device") ?? false,
+ scriptToolAvailable: mcpSession?.capabilities?.has("script") ?? false,
omitServiceTier: adapterOptions.resolveRuntime !== undefined,
...(turnInput.appContext === undefined ? {} : { appContext: turnInput.appContext }),
});
diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts
index 03a7278774a64..7aa2cd5adcdb2 100644
--- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts
+++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts
@@ -457,6 +457,13 @@ export const layerWithOptions = (
>(["orchestration", "worktree", "pull-requests"]);
if (browserToolsAvailable) capabilities.add("preview");
if (deviceToolsAvailable) capabilities.add("device");
+ const scriptToolAvailable = Option.isSome(serverSettings)
+ ? yield* serverSettings.value.getSettings.pipe(
+ Effect.map((settings) => settings.scriptToolEnabled),
+ Effect.orElseSucceed(() => false),
+ )
+ : false;
+ if (scriptToolAvailable) capabilities.add("script");
const subagent = yield* projectionStore.getThread(threadId).pipe(
Effect.map((thread) => thread.lineage.relationshipToParent === "subagent"),
Effect.orElseSucceed(() => false),
@@ -482,6 +489,7 @@ export const layerWithOptions = (
resolved.thread.providerInstanceId === providerInstanceId &&
resolved.capabilities.has("preview") === browserToolsAvailable &&
resolved.capabilities.has("device") === deviceToolsAvailable &&
+ resolved.capabilities.has("script") === scriptToolAvailable &&
// A credential issued while the lineage read failed is replaced once it works.
(resolved.thread.subagent === true) === subagent
) {
diff --git a/apps/server/src/provider/CodexDeveloperInstructions.ts b/apps/server/src/provider/CodexDeveloperInstructions.ts
index cbfd514eb11a6..22736de38771c 100644
--- a/apps/server/src/provider/CodexDeveloperInstructions.ts
+++ b/apps/server/src/provider/CodexDeveloperInstructions.ts
@@ -5,6 +5,7 @@ import {
T3_CODE_BROWSER_TOOL_INSTRUCTIONS,
T3_CODE_DEVICE_TOOL_INSTRUCTIONS,
T3_CODE_ORCHESTRATION_SECTIONS,
+ T3_CODE_SCRIPT_TOOL_INSTRUCTIONS,
} from "@t3tools/provider-core/server/orchestrationInstructions";
/** Codex silently truncates additionalContext values over 1,000 tokens, counted as UTF-8 bytes / 4. */
@@ -13,6 +14,7 @@ export const CODEX_ADDITIONAL_CONTEXT_VALUE_MAX_BYTES = 4_000;
export interface T3CodeToolAvailability {
readonly browser: boolean;
readonly device: boolean;
+ readonly script?: boolean;
}
const normalizeAvailability = (
@@ -26,6 +28,7 @@ const toolInstructions = (availability: boolean | T3CodeToolAvailability): strin
return [
tools.browser ? T3_CODE_BROWSER_TOOL_INSTRUCTIONS : "",
tools.device ? T3_CODE_DEVICE_TOOL_INSTRUCTIONS : "",
+ tools.script === true ? T3_CODE_SCRIPT_TOOL_INSTRUCTIONS : "",
]
.filter(Boolean)
.join("\n\n");
diff --git a/apps/server/src/provider/ProviderInstanceRegistry.test.ts b/apps/server/src/provider/ProviderInstanceRegistry.test.ts
index af3359ea7f27d..39317adcb06f3 100644
--- a/apps/server/src/provider/ProviderInstanceRegistry.test.ts
+++ b/apps/server/src/provider/ProviderInstanceRegistry.test.ts
@@ -97,6 +97,7 @@ const makeCodexConfig = (overrides: Partial): CodexSettings => ({
homePath: "",
shadowHomePath: "",
launchArgs: "",
+ codeMode: false,
customModels: [],
...overrides,
});
diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json
index c8085be773a63..704e6b6d452bd 100644
--- a/apps/server/src/provider/agentContextBudget.json
+++ b/apps/server/src/provider/agentContextBudget.json
@@ -1,5 +1,5 @@
{
"claudeSystemAppendBytes": 5129,
"codexAdditionalContextBytes": 6859,
- "mcpToolListJsonBytes": 46007
+ "mcpToolListJsonBytes": 47199
}
diff --git a/apps/server/src/provider/codexLaunchArgs.test.ts b/apps/server/src/provider/codexLaunchArgs.test.ts
index ce18c8f3f1bd5..b5331cf7c96a3 100644
--- a/apps/server/src/provider/codexLaunchArgs.test.ts
+++ b/apps/server/src/provider/codexLaunchArgs.test.ts
@@ -13,6 +13,21 @@ describe("codexAppServerArgs", () => {
});
});
+describe("codexAppServerArgs code mode", () => {
+ it("enables code mode before launch args, so a user's --disable still wins", () => {
+ NodeAssert.deepStrictEqual(codexAppServerArgs("--disable code_mode", { codeMode: true }), [
+ "app-server",
+ "--enable",
+ "code_mode",
+ "-c",
+ "suppress_unstable_features_warning=true",
+ "--disable",
+ "code_mode",
+ ]);
+ NodeAssert.deepStrictEqual(codexAppServerArgs("", { codeMode: false }), ["app-server"]);
+ });
+});
+
describe("codexExecLaunchArgs", () => {
it("keeps shared codex flags and omits app-server-only flags", () => {
NodeAssert.deepStrictEqual(
diff --git a/apps/server/src/provider/codexLaunchArgs.ts b/apps/server/src/provider/codexLaunchArgs.ts
index 33def5ce9a61a..97abafb5409f6 100644
--- a/apps/server/src/provider/codexLaunchArgs.ts
+++ b/apps/server/src/provider/codexLaunchArgs.ts
@@ -9,8 +9,15 @@ export const resolveCodexLaunchArgs = (
const codexLaunchArgv = (launchArgs?: string): ReadonlyArray => tokenizeCliArgs(launchArgs);
-export const codexAppServerArgs = (launchArgs?: string) => [
+export const codexAppServerArgs = (
+ launchArgs?: string,
+ options?: { readonly codeMode?: boolean },
+) => [
"app-server",
+ // Otherwise every thread opens with Codex's warning about unfinished features.
+ ...(options?.codeMode === true
+ ? ["--enable", "code_mode", "-c", "suppress_unstable_features_warning=true"]
+ : []),
...codexLaunchArgv(launchArgs),
];
diff --git a/apps/server/src/script-sandbox-worker.ts b/apps/server/src/script-sandbox-worker.ts
new file mode 100644
index 0000000000000..a1c16f338c54a
--- /dev/null
+++ b/apps/server/src/script-sandbox-worker.ts
@@ -0,0 +1,3 @@
+import { runScriptSandboxWorker } from "./script/scriptSandboxWorker.ts";
+
+await runScriptSandboxWorker();
diff --git a/apps/server/src/script/ScriptRunner.test.ts b/apps/server/src/script/ScriptRunner.test.ts
new file mode 100644
index 0000000000000..4d1d359c5e5d3
--- /dev/null
+++ b/apps/server/src/script/ScriptRunner.test.ts
@@ -0,0 +1,108 @@
+import { expect, it } from "@effect/vitest";
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
+
+import * as ScriptRunner from "./ScriptRunner.ts";
+
+const layer = ScriptRunner.layer.pipe(Layer.provide(NodeServices.layer));
+
+const run = (
+ code: string,
+ options: {
+ readonly call?: ScriptRunner.ScriptToolCall;
+ readonly deadline?: string;
+ } = {},
+) =>
+ Effect.gen(function* () {
+ const runner = yield* ScriptRunner.ScriptRunner;
+ return yield* runner.run({
+ code,
+ call: options.call ?? (() => Effect.succeed(null)),
+ ...(options.deadline === undefined
+ ? {}
+ : { deadline: options.deadline as `${number} seconds` }),
+ });
+ }).pipe(Effect.provide(layer));
+
+it.live("gives programs no Node, timers, console, network, or eval", () =>
+ Effect.gen(function* () {
+ const result = yield* run(`
+ let evalRefused = false;
+ try { new Function("return 1")(); } catch { evalRefused = true; }
+ text([typeof process, typeof require, typeof console, typeof fetch, typeof setTimeout, evalRefused].join());
+ `);
+ expect(result).toMatchObject({
+ status: "completed",
+ output: "undefined,undefined,undefined,undefined,undefined,true",
+ });
+ }),
+);
+
+it.live("caps concurrent tool calls and passes results and failures back", () =>
+ Effect.gen(function* () {
+ let running = 0;
+ let peak = 0;
+ const result = yield* run(
+ `
+ const settled = await Promise.allSettled(
+ Array.from({ length: 40 }, (_, value) => tools.double({ value })),
+ );
+ text(settled.filter((entry) => entry.status === "fulfilled").map((entry) => entry.value).join());
+ text(settled.find((entry) => entry.status === "rejected").reason.message);
+ `,
+ {
+ call: (_tool, input) => {
+ const value = (input as { readonly value: number }).value;
+ return Effect.gen(function* () {
+ running += 1;
+ peak = Math.max(peak, running);
+ yield* Effect.yieldNow;
+ running -= 1;
+ return value === 39 ? yield* Effect.fail("boom") : value * 2;
+ });
+ },
+ },
+ );
+ expect(result.status).toBe("completed");
+ expect(result.output.startsWith("0,2,4,")).toBe(true);
+ expect(result.output.endsWith("boom")).toBe(true);
+ expect(result.toolCalls).toBe(40);
+ expect(result.failedToolCalls).toBe(1);
+ expect(peak).toBeLessThanOrEqual(ScriptRunner.SCRIPT_MAX_CONCURRENT_TOOL_CALLS);
+ }),
+);
+
+it.live.each([
+ {
+ name: "a throw",
+ code: `text("before"); throw new Error("nope");`,
+ status: "failed",
+ error: "nope",
+ },
+ { name: "a syntax error", code: `this is not javascript`, status: "failed", error: "Unexpected" },
+] as const)("reports $name with the output so far", ({ code, status, error }) =>
+ Effect.gen(function* () {
+ const result = yield* run(code);
+ expect(result.status).toBe(status);
+ expect(result.error).toContain(error);
+ }),
+);
+
+it.live("kills a program stuck in a loop at the deadline and keeps its output", () =>
+ Effect.gen(function* () {
+ const result = yield* run(`text("started"); await tools.yield(); while (true) {}`, {
+ deadline: "2 seconds",
+ });
+ expect(result.status).toBe("timed_out");
+ expect(result.output).toBe("started");
+ }),
+);
+
+it.live("truncates printed output at the cap", () =>
+ Effect.gen(function* () {
+ const result = yield* run(`for (let i = 0; i < 40; i++) text("x".repeat(1000));`);
+ expect(result.outputTruncated).toBe(true);
+ expect(result.output.length).toBe(ScriptRunner.SCRIPT_MAX_OUTPUT_CHARS);
+ }),
+);
diff --git a/apps/server/src/script/ScriptRunner.ts b/apps/server/src/script/ScriptRunner.ts
new file mode 100644
index 0000000000000..8939614f1d92d
--- /dev/null
+++ b/apps/server/src/script/ScriptRunner.ts
@@ -0,0 +1,227 @@
+import * as HostProcess from "@t3tools/shared/HostProcess";
+import * as Clock from "effect/Clock";
+import * as Context from "effect/Context";
+import * as Deferred from "effect/Deferred";
+import * as Duration from "effect/Duration";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
+import * as Option from "effect/Option";
+import * as Path from "effect/Path";
+import * as Queue from "effect/Queue";
+import * as Schema from "effect/Schema";
+import * as Semaphore from "effect/Semaphore";
+import * as Stream from "effect/Stream";
+import * as ChildProcess from "effect/process/ChildProcess";
+import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
+
+const SCRIPT_MAX_TOOL_CALLS = 1_000;
+export const SCRIPT_MAX_CONCURRENT_TOOL_CALLS = 16;
+export const SCRIPT_MAX_OUTPUT_CHARS = 30_000;
+const SCRIPT_DEADLINE = Duration.minutes(5);
+const SANDBOX_HEAP_MB = 512;
+
+const WorkerMessage = Schema.Union([
+ Schema.Struct({ type: Schema.Literal("text"), value: Schema.String }),
+ Schema.Struct({
+ type: Schema.Literal("call"),
+ id: Schema.Number,
+ tool: Schema.String,
+ input: Schema.String,
+ }),
+ Schema.Struct({ type: Schema.Literal("done"), error: Schema.optional(Schema.String) }),
+]);
+const decodeWorkerMessage = Schema.decodeUnknownOption(Schema.fromJsonString(WorkerMessage));
+const decodeToolInput = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown));
+
+export interface ScriptRunResult {
+ readonly status: "completed" | "failed" | "timed_out";
+ readonly output: string;
+ readonly outputTruncated: boolean;
+ readonly error?: string;
+ readonly toolCalls: number;
+ readonly failedToolCalls: number;
+ readonly elapsedMs: number;
+}
+
+/** A nested call fails with the message the program's `await` rejects with. */
+export type ScriptToolCall = (tool: string, input: unknown) => Effect.Effect;
+
+export class ScriptRunner extends Context.Service<
+ ScriptRunner,
+ {
+ readonly run: (input: {
+ readonly code: string;
+ readonly call: ScriptToolCall;
+ readonly deadline?: Duration.Input;
+ }) => Effect.Effect;
+ }
+>()("t3/script/ScriptRunner") {}
+
+const sandboxEnvironment = () => ({
+ ELECTRON_RUN_AS_NODE: "1",
+ NODE_OPTIONS: `--max-old-space-size=${SANDBOX_HEAP_MB}`,
+ // Windows cannot initialize Node's crypto and networking without it.
+ ...(process.env.SystemRoot === undefined ? {} : { SystemRoot: process.env.SystemRoot }),
+});
+
+const make = Effect.gen(function* () {
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
+ const path = yield* Path.Path;
+ const isExecutable = yield* HostProcess.IsExecutable;
+ const executablePath = yield* HostProcess.ExecutablePath;
+
+ const workerArguments = isExecutable
+ ? ["__script-sandbox"]
+ : yield* Effect.gen(function* () {
+ const fromSource = import.meta.url.endsWith(".ts");
+ const worker = yield* path.fromFileUrl(
+ new URL(
+ fromSource ? "../script-sandbox-worker.ts" : "./script-sandbox-worker.mjs",
+ import.meta.url,
+ ),
+ );
+ return [...(fromSource ? ["--experimental-strip-types"] : []), worker];
+ }).pipe(Effect.orDie);
+
+ const run = Effect.fn("ScriptRunner.run")(
+ function* (input: {
+ readonly code: string;
+ readonly call: ScriptToolCall;
+ readonly deadline?: Duration.Input;
+ }) {
+ const startedAt = yield* Clock.currentTimeMillis;
+ let output = "";
+ let outputTruncated = false;
+ let toolCalls = 0;
+ let failedToolCalls = 0;
+ const appendOutput = (value: string) => {
+ const next = output.length === 0 ? value : `${output}\n${value}`;
+ outputTruncated ||= next.length > SCRIPT_MAX_OUTPUT_CHARS;
+ output = next.slice(0, SCRIPT_MAX_OUTPUT_CHARS);
+ };
+ const result = (status: ScriptRunResult["status"], error?: string) =>
+ Clock.currentTimeMillis.pipe(
+ Effect.map((now): ScriptRunResult => ({
+ status,
+ output,
+ outputTruncated,
+ ...(error === undefined ? {} : { error }),
+ toolCalls,
+ failedToolCalls,
+ elapsedMs: now - startedAt,
+ })),
+ );
+
+ const handle = yield* spawner.spawn(
+ ChildProcess.make(executablePath, workerArguments, {
+ env: sandboxEnvironment(),
+ extendEnv: false,
+ forceKillAfter: Duration.seconds(1),
+ }),
+ );
+ const toWorker = yield* Queue.unbounded();
+ const send = (message: unknown) => Queue.offer(toWorker, `${JSON.stringify(message)}\n`);
+ yield* Stream.fromQueue(toWorker).pipe(
+ Stream.encodeText,
+ Stream.run(handle.stdin),
+ Effect.ignore,
+ Effect.forkScoped,
+ );
+ yield* send({ type: "start", code: input.code });
+
+ const slots = yield* Semaphore.make(SCRIPT_MAX_CONCURRENT_TOOL_CALLS);
+ const done = yield* Deferred.make();
+ const runCall = (call: {
+ readonly id: number;
+ readonly tool: string;
+ readonly input: string;
+ }) =>
+ decodeToolInput(call.input).pipe(
+ Effect.mapError(() => "The tool input was not valid JSON."),
+ Effect.flatMap((parsed) => input.call(call.tool, parsed)),
+ Effect.matchEffect({
+ onFailure: (error) => {
+ failedToolCalls += 1;
+ return send({ type: "result", id: call.id, error });
+ },
+ onSuccess: (value) =>
+ send({ type: "result", id: call.id, value: JSON.stringify(value ?? null) }),
+ }),
+ slots.withPermit,
+ );
+
+ const reader = handle.stdout.pipe(
+ Stream.decodeText,
+ Stream.splitLines,
+ Stream.runForEach((line) => {
+ const decoded = decodeWorkerMessage(line);
+ if (Option.isNone(decoded)) return Effect.void;
+ const message = decoded.value;
+ switch (message.type) {
+ case "text":
+ return Effect.sync(() => appendOutput(message.value));
+ case "done":
+ return Deferred.succeed(done, message.error).pipe(Effect.asVoid);
+ case "call": {
+ toolCalls += 1;
+ const call = message;
+ if (toolCalls > SCRIPT_MAX_TOOL_CALLS) {
+ failedToolCalls += 1;
+ return send({
+ type: "result",
+ id: call.id,
+ error: `A script can make at most ${SCRIPT_MAX_TOOL_CALLS} tool calls.`,
+ });
+ }
+ return runCall(call).pipe(Effect.forkScoped, Effect.asVoid);
+ }
+ }
+ }),
+ Effect.ignore,
+ );
+ yield* reader.pipe(
+ // Output ended without "done": the sandbox died, usually out of memory.
+ Effect.andThen(
+ handle.exitCode.pipe(
+ Effect.map((code) => `The script process exited with code ${code} before finishing.`),
+ Effect.orElseSucceed(() => "The script process stopped before finishing."),
+ ),
+ ),
+ Effect.flatMap((error) => Deferred.succeed(done, error)),
+ Effect.forkScoped,
+ );
+
+ const outcome = yield* Deferred.await(done).pipe(
+ Effect.timeoutOption(input.deadline ?? SCRIPT_DEADLINE),
+ );
+ if (Option.isNone(outcome)) {
+ return yield* result(
+ "timed_out",
+ `The script ran past its deadline of ${Duration.format(Duration.fromInputUnsafe(input.deadline ?? SCRIPT_DEADLINE))} and was stopped. Output so far is included.`,
+ );
+ }
+ return yield* outcome.value === undefined
+ ? result("completed")
+ : result("failed", outcome.value);
+ },
+ Effect.scoped,
+ (effect) =>
+ effect.pipe(
+ Effect.catch((cause: unknown) =>
+ Effect.succeed({
+ status: "failed",
+ output: "",
+ outputTruncated: false,
+ error: `The script process could not start: ${String(cause)}`,
+ toolCalls: 0,
+ failedToolCalls: 0,
+ elapsedMs: 0,
+ }),
+ ),
+ ),
+ );
+
+ return ScriptRunner.of({ run });
+});
+
+export const layer = Layer.effect(ScriptRunner, make);
diff --git a/apps/server/src/script/scriptSandboxWorker.ts b/apps/server/src/script/scriptSandboxWorker.ts
new file mode 100644
index 0000000000000..971228afd9191
--- /dev/null
+++ b/apps/server/src/script/scriptSandboxWorker.ts
@@ -0,0 +1,107 @@
+import * as NodeReadline from "node:readline";
+import * as NodeVM from "node:vm";
+
+type HostMessage =
+ | { readonly type: "start"; readonly code: string }
+ | {
+ readonly type: "result";
+ readonly id: number;
+ readonly value?: string;
+ readonly error?: string;
+ };
+
+interface PendingCall {
+ readonly resolve: (json: string) => void;
+ readonly reject: (message: string) => void;
+}
+
+// Runs inside the context so user code only ever holds this context's objects, never a host function.
+const BOOTSTRAP = `(bridge) => {
+ // V8's built-in console prints nowhere; a ReferenceError at least tells the program to use text().
+ delete globalThis.console;
+ const format = (value) => {
+ if (typeof value === "string") return value;
+ const json = JSON.stringify(value, null, 2);
+ return json === undefined ? String(value) : json;
+ };
+ globalThis.text = (...values) => bridge.text(values.map(format).join(" "));
+ globalThis.tools = new Proxy({}, {
+ get: (_target, name) =>
+ typeof name !== "string" || name === "then"
+ ? undefined
+ : (input) =>
+ new Promise((resolve, reject) =>
+ bridge.call(
+ name,
+ input === undefined ? "{}" : JSON.stringify(input),
+ (json) => resolve(json === undefined ? undefined : JSON.parse(json)),
+ (message) => reject(new Error(message)),
+ ),
+ ),
+ });
+}`;
+
+const describe = (error: unknown) =>
+ error instanceof Object && "stack" in error && typeof error.stack === "string"
+ ? error.stack
+ : String(error);
+
+/**
+ * `node:vm` keeps honest programs away from Node, but it is not a security boundary. That is why
+ * the tool is limited to full-access threads, whose agent already has a shell, and why this
+ * process gets no environment and is killed at the deadline.
+ */
+export async function runScriptSandboxWorker(): Promise {
+ const send = (message: unknown) => process.stdout.write(`${JSON.stringify(message)}\n`);
+ const pending = new Map();
+ let nextId = 0;
+ const code = await new Promise((resolve) => {
+ const lines = NodeReadline.createInterface({ input: process.stdin });
+ lines.on("line", (line) => {
+ const message = JSON.parse(line) as HostMessage;
+ if (message.type === "start") {
+ resolve(message.code);
+ return;
+ }
+ const call = pending.get(message.id);
+ pending.delete(message.id);
+ if (message.error !== undefined) call?.reject(message.error);
+ else call?.resolve(message.value ?? "null");
+ });
+ // The host is gone; nothing else would end this process.
+ lines.on("close", () => process.exit(0));
+ });
+
+ const finish = (error?: unknown) =>
+ send(error === undefined ? { type: "done" } : { type: "done", error: describe(error) });
+ process.on("unhandledRejection", finish);
+ process.on("uncaughtException", finish);
+
+ const context = NodeVM.createContext(Object.create(null) as object, {
+ codeGeneration: { strings: false, wasm: false },
+ });
+ const install = NodeVM.runInContext(BOOTSTRAP, context) as (bridge: unknown) => void;
+ install({
+ text: (value: string) => send({ type: "text", value }),
+ call: (
+ tool: string,
+ input: string,
+ resolve: PendingCall["resolve"],
+ reject: PendingCall["reject"],
+ ) => {
+ const id = nextId++;
+ pending.set(id, { resolve, reject });
+ send({ type: "call", id, tool, input });
+ },
+ });
+ try {
+ const program = new NodeVM.Script(`(async () => {\n${code}\n})()`, {
+ filename: "script.js",
+ lineOffset: -1,
+ });
+ await program.runInContext(context);
+ finish();
+ } catch (error) {
+ finish(error);
+ }
+}
diff --git a/apps/server/vite.config.ts b/apps/server/vite.config.ts
index cb77ab545c6ba..1632ba086f9c9 100644
--- a/apps/server/vite.config.ts
+++ b/apps/server/vite.config.ts
@@ -83,7 +83,9 @@ export default mergeConfig(
},
pack: {
// SEA embeds one entry; expose the history worker as a hidden subcommand.
- entry: packExecutable ? ["src/bin.ts"] : ["src/bin.ts", "src/claude-history-worker.ts"],
+ entry: packExecutable
+ ? ["src/bin.ts"]
+ : ["src/bin.ts", "src/claude-history-worker.ts", "src/script-sandbox-worker.ts"],
outDir: packExecutable ? "dist-exe" : "dist",
sourcemap: !packExecutable,
clean: true,
diff --git a/apps/web/src/components/settings/LabsSettingsPanel.tsx b/apps/web/src/components/settings/LabsSettingsPanel.tsx
index ce3f68eac49d0..81f903386de73 100644
--- a/apps/web/src/components/settings/LabsSettingsPanel.tsx
+++ b/apps/web/src/components/settings/LabsSettingsPanel.tsx
@@ -22,6 +22,7 @@ export function LabsSettingsPanel() {
{hasCloudPublicConfig() ? : }
+
@@ -150,6 +151,27 @@ function LinkedEnvironmentsRow() {
);
}
+function ScriptToolRow() {
+ const settings = useScopedSettings();
+ const updateSettings = useUpdateScopedSettings();
+ return (
+ updateSettings({ scriptToolEnabled: Boolean(checked) })}
+ aria-label="Script tool"
+ />
+ }
+ />
+ );
+}
+
function SimlockHostsRow() {
const settings = useScopedSettings();
const updateSettings = useUpdateScopedSettings();
diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts
index aabfd0300eda5..7383800626e7f 100644
--- a/apps/web/src/components/settings/settingsSearch.ts
+++ b/apps/web/src/components/settings/settingsSearch.ts
@@ -476,6 +476,13 @@ export const SETTINGS_SEARCH_ITEMS = [
scope: "environment",
searchTerms: ["beta alpha simlock callstack simulator emulator ios android pool lease gateway"],
},
+ {
+ id: "script-tool",
+ title: "Script tool",
+ to: "/settings/labs",
+ scope: "environment",
+ searchTerms: ["beta programmatic tool calling javascript program sandbox code mode"],
+ },
{
id: "auto-settle-inactive-threads",
title: "Auto-settle inactive threads",
diff --git a/docs/user/permission-modes.md b/docs/user/permission-modes.md
index d7391de5b2249..e6a55450f04b0 100644
--- a/docs/user/permission-modes.md
+++ b/docs/user/permission-modes.md
@@ -49,3 +49,9 @@ Antigravity can still send native approval requests in **Full access**. It only
approvals for actions that support them.
See the [provider guides](./install.md#providers) for setup and provider-specific limits.
+
+## Script tool
+
+The script tool lets an agent in a full-access thread run one JavaScript program that makes many file, shell, and T3 tool calls, and reads only what the program prints. Turn it on in **Settings → Labs**. It is off by default.
+
+The program's file and shell calls skip the provider's permission prompts and hooks, which is why other permission modes refuse it. It cannot reach MCP servers you configured in the provider. For Codex, [Code mode](./providers-codex.md#code-mode) is the native version and also reaches those servers.
diff --git a/docs/user/providers-codex.md b/docs/user/providers-codex.md
index bdc87902a63b2..fa1176600adaf 100644
--- a/docs/user/providers-codex.md
+++ b/docs/user/providers-codex.md
@@ -25,6 +25,14 @@ T3 Code can use your installed Codex and its existing login. Run `codex login`
on the environment's machine to sign in. [Provider setup](./install.md#providers)
covers installation and custom configuration.
+## Code mode
+
+Turn on **Code mode** for a Codex account in **Settings → Providers**. The model
+then writes one JavaScript program that makes many tool calls and reads only what
+the program prints, which saves tokens on big, repetitive tasks and can cost more
+on small ones. It is an unfinished Codex feature, so it may change with a Codex
+update. New sessions pick up the change.
+
## Use multiple accounts
Add another ChatGPT account in **Settings → Providers**, then select the account
diff --git a/knip.jsonc b/knip.jsonc
index 3cec1656d0cc3..6f0e81c61e5b6 100644
--- a/knip.jsonc
+++ b/knip.jsonc
@@ -46,6 +46,7 @@
"entry": [
"src/bin.ts!",
"src/claude-history-worker.ts!",
+ "src/script-sandbox-worker.ts!",
"scripts/cli.ts",
"scripts/fetch-expo-schema.ts",
"scripts/probe-claude-fork-local-rollback-replay.ts",
diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts
index fff25149b2e3d..fd453814b14eb 100644
--- a/packages/contracts/src/settings.ts
+++ b/packages/contracts/src/settings.ts
@@ -672,13 +672,22 @@ export const CodexSettings = makeProviderSettingsSchema(
description: "Additional CLI arguments passed to codex app-server on session start.",
}),
),
+ codeMode: Schema.Boolean.pipe(
+ Schema.withDecodingDefault(Effect.succeed(false)),
+ Schema.annotateKey({
+ title: "Code mode",
+ description:
+ "The model writes one JavaScript program that makes many tool calls, and reads only what it prints. Saves tokens on big tasks and can cost more on small ones. An unfinished Codex feature. New sessions pick up a change.",
+ providerSettingsForm: { control: "switch", clearWhenEmpty: "omit" },
+ }),
+ ),
customModels: Schema.Array(CustomModelSetting).pipe(
Schema.withDecodingDefault(Effect.succeed([])),
Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
),
},
{
- order: ["binaryPath", "homePath", "shadowHomePath", "launchArgs"],
+ order: ["binaryPath", "homePath", "shadowHomePath", "codeMode", "launchArgs"],
},
);
export type CodexSettings = typeof CodexSettings.Type;
@@ -1261,6 +1270,8 @@ export const ServerSettings = Schema.Struct({
* and tasks on another machine; tasks already running there finish.
*/
linkedEnvironmentsEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
+ /** Beta: gives agents in full-access threads the `script` MCP tool (programmatic tool calling). */
+ scriptToolEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
sidebarAutoSettleKeepPullRequests: Schema.Boolean.pipe(
Schema.withDecodingDefault(Effect.succeed(false)),
),
@@ -1617,6 +1628,7 @@ export const ServerSettingsPatch = Schema.Struct({
snoozePresets: Schema.optionalKey(SnoozePresetSettings),
guestSharingEnabled: Schema.optionalKey(Schema.Boolean),
linkedEnvironmentsEnabled: Schema.optionalKey(Schema.Boolean),
+ scriptToolEnabled: Schema.optionalKey(Schema.Boolean),
backgroundActivity: Schema.optionalKey(
Schema.Struct({
schemaVersion: Schema.optionalKey(Schema.Literal(1)),
diff --git a/packages/provider-core/src/server/orchestrationInstructions.ts b/packages/provider-core/src/server/orchestrationInstructions.ts
index 70b3fff064f3b..b913c83016bd7 100644
--- a/packages/provider-core/src/server/orchestrationInstructions.ts
+++ b/packages/provider-core/src/server/orchestrationInstructions.ts
@@ -55,6 +55,17 @@ export const T3_CODE_DEVICE_TOOL_INSTRUCTIONS_WITH_TOOL_SEARCH = deviceToolInstr
` Load them with ToolSearch \`select:${T3_CODE_DEVICE_TOOLS.map((tool) => `mcp__t3-code__${tool}`).join(",")}\`.`,
);
+const scriptToolInstructions = (loadTool: string) => `## T3 Code script tool
+
+For work that takes several tool calls and whose raw results you do not all need to read, such as bulk edits or sweeps over many files, commands, or threads, prefer one \`script\` call: one JavaScript program makes the calls, and only what it passes to \`text()\` reaches you.${loadTool} Make single calls and anything needing the user's approval directly.`;
+
+export const T3_CODE_SCRIPT_TOOL_INSTRUCTIONS = scriptToolInstructions("");
+
+/** Claude Code defers MCP tools behind ToolSearch, so Claude's copy says how to load it. */
+export const T3_CODE_SCRIPT_TOOL_INSTRUCTIONS_WITH_TOOL_SEARCH = scriptToolInstructions(
+ " Load it with ToolSearch `select:mcp__t3-code__script`.",
+);
+
// ACP only: some ACP agents accept the injected MCP server but never expose
// its tools, so they need the terminal transport other harnesses never use.
const T3_CODE_ACP_MCP_FALLBACK_INSTRUCTIONS = `ACP fallback: if the T3 tools are absent and \`T3_ACP_MCP_NODE\` is present, call them through the terminal: \`ELECTRON_RUN_AS_NODE=1 "$T3_ACP_MCP_NODE" \${T3_ACP_MCP_ENTRYPOINT:+"$T3_ACP_MCP_ENTRYPOINT"} acp-mcp-call orchestrator_capabilities '{}'\` (\`T3_ACP_MCP_ENTRYPOINT\` is unset for a standalone executable). Delegate with \`acp-mcp-call delegate_task '{"task":"...","target":{"providerInstanceId":"...","model":"..."},"mode":"async","clientRequestId":"..."}'\`.`;