Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
3f59a59
Unify command execution under shell
fazxes Aug 29, 2026
57ee8c7
Compile managed execution for single-threaded targets
fazxes Aug 29, 2026
5f397d4
Retire background CLI coverage
fazxes Aug 29, 2026
88cad50
Update workspace SDK shell coverage
fazxes Aug 29, 2026
83ee766
Update command catalog count coverage
fazxes Aug 29, 2026
47d6db7
Update browser workspace shell docs
fazxes Aug 29, 2026
3bf4862
Update malformed terminal history coverage
fazxes Aug 29, 2026
017076f
Update Auto Mode shell coverage
fazxes Aug 29, 2026
438ae94
Preserve managed shell cancellation semantics
fazxes Aug 29, 2026
0907944
Update shell path and permission coverage
fazxes Aug 29, 2026
694cbc8
Remove retired background startup benchmark
fazxes Aug 29, 2026
c92c7ae
Preserve cancelled shell replay
fazxes Aug 29, 2026
f6fcade
Restore managed shell presentation contracts
fazxes Aug 29, 2026
3ae13d0
Harden managed shell execution
fazxes Aug 31, 2026
b32e1b4
Harden shell recovery and retry boundaries
fazxes Aug 31, 2026
e7830b2
Refresh shell tool contract digest
fazxes Aug 31, 2026
7c166b9
Close shell runtime recovery edge cases
fazxes Aug 31, 2026
ef586e6
Wait for terminal recovery before assertions
fazxes Aug 31, 2026
0a3807f
Preserve shell metadata across recovery
fazxes Aug 31, 2026
40dface
Align shell transcript regression checks
fazxes Aug 31, 2026
7616bcc
Bind TTY execution to shell authority
fazxes Aug 31, 2026
b0424dd
Preserve memory removal across shell rebase
fazxes Aug 31, 2026
8daafbc
Refresh shell registry contract digest
fazxes Aug 31, 2026
0eb2f91
Use compact terminal session IDs
fazxes Sep 1, 2026
27ab4a3
Add explicit shell turn handoff
fazxes Sep 1, 2026
d24c47a
Fix trace projection after history cleanup
fazxes Sep 1, 2026
96247a4
Simplify managed shell interaction
fazxes Sep 1, 2026
91e9c26
Preserve ACP shell fixture after rebase
fazxes Sep 1, 2026
f4a3113
Stabilize managed TTY completion
fazxes Sep 1, 2026
eeb470b
Release managed TTY replay authority
fazxes Sep 1, 2026
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
15 changes: 0 additions & 15 deletions benchmarks/startup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -153,21 +153,6 @@ HOME="$SESSION_FIXTURE_HOME" hyperfine \

echo ""

# Benchmark 5: fx background --json (file I/O path)
echo "--- fx background --json ---"
(
cd "$SESSION_FIXTURE_WORKSPACE"
HOME="$SESSION_FIXTURE_HOME" hyperfine \
"${SHELL_OPTS[@]}" \
--runs "$RUNS" \
--warmup "$WARMUP" \
--export-json "${RESULTS_DIR}/background.json" \
--command-name "fx background --json" \
"$FX_BIN background --json"
)

echo ""

# Combine results into a single summary for CI
echo "--- summary ---"
python3 "${REPO_ROOT}/benchmarks/summarize.py"
Expand Down
4 changes: 2 additions & 2 deletions sdk/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ The SDK has two WebAssembly surfaces and one shared JavaScript host layer:
| Interactive terminal entry point for `fx-term.wasm` | `src/wasm_term_main.zig` and `runWasmTerminal` in `src/main.zig` |
| Native and WebAssembly capability policy | `src/core/hosts/runtime_profile.zig` |
| Host-backed terminal session persistence | `src/core/app/app_session_runtime.zig` and `sdk/fx-sdk.js` |
| Browser workspace contract and `terminal.exec` bridge | `src/core/hosts/js_host_workspace.zig` and `src/tools/terminal/browser_terminal.zig` |
| Browser workspace contract and `shell.run` bridge | `src/core/hosts/js_host_workspace.zig` and `src/tools/shell/browser_shell.zig` |
| Browser device login, OAuth session persistence, and URL opening | `src/core/auth/js_host_auth.zig`, `src/core/auth/oauth_session.zig`, and `src/core/hosts/js_host_url_opener.zig` |
| WASI target, optimization mode, threading, and artifact names | `build.zig` |
| Core browser fixture and its automation contract | `sdk/index.html` and `sdk/tests/test-core-browser.mjs` |
Expand All @@ -31,7 +31,7 @@ Do not treat the demos or this file as the implementation contract. When prose a
- Detect JavaScript Promise Integration (JSPI) by capability through `supportsJspi()`. Do not replace feature detection with browser or version sniffing. Keep loader errors, demo fallback states, and the compatibility statement in `sdk/README.md` consistent.
- Treat JavaScript host stores as durable contracts. Session and OAuth snapshots are opaque bytes with optimistic revisions. Preserve `FX_SESSION_REVISION_CONFLICT` and `FX_OAUTH_SESSION_REVISION_CONFLICT`. Persist configuration only after fx accepts it, and do not collapse prompt-history outcomes into generic success.
- Preserve cancellation and lifecycle behavior. Fetch cancellation must reach the host `AbortSignal`; terminal subscriptions must be released exactly once; `abort()` must settle `exited` and must not leave input or resize listeners attached.
- The WebAssembly runtime is not the native runtime. Keep native tools disabled. The optional workspace host may expose only foreground `terminal.exec` through its typed boundary and permission policy. Its schema is exactly `{ action: "exec", command }`; native profiles and durable terminal actions are unavailable. Any additional capability requires its own typed host boundary, permission review where applicable, and coverage on the affected surface.
- The WebAssembly runtime is not the native runtime. Keep native tools disabled. The optional workspace host may expose only completion-only `shell.run` through its typed boundary and permission policy. Its schema is exactly `{ action: "run", command }`; native profiles, TTYs, and managed running handles are unavailable. Any additional capability requires its own typed host boundary, permission review where applicable, and coverage on the affected surface.
- Keep workspace version 1 constrained to an ephemeral, non-git workspace whose normalized `cwd` equals `root`. Preserve command and output limits, the 30-second maximum deadline, and Ctrl+C cancellation through the shared host-effect abort path.
- `window.__fxCoreTest` and `document.body.dataset.state` are test interfaces for the core debugger. If either changes intentionally, update the browser test in the same change.
- The live demos may pass a locally stored credential into the WebAssembly environment. Never print, serialize into artifacts, or add test assertions containing that credential.
Expand Down
6 changes: 3 additions & 3 deletions sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -308,14 +308,14 @@ The WebAssembly runtime intentionally does not provide:
- Public web fetch, web search, and general outbound network access

The embedded runtime tells the model not to retry unavailable network work
through terminal commands. Use locally installed fx when the full native tool
through shell commands. Use locally installed fx when the full native tool
suite is required.

The optional browser workspace exposes foreground terminal execution through
The optional browser workspace exposes completion-only shell execution through
the typed contract:

```js
{ action: "exec", command }
{ action: "run", command }
```

The host remains responsible for admitting commands, enforcing limits, and
Expand Down
30 changes: 15 additions & 15 deletions sdk/node/test-term-workspace.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -75,23 +75,23 @@ function sse(events) {

function toolCall(id, command) {
return sse([
{ type: "tool-call", toolCallId: id, toolName: "terminal", input: { action: "exec", command } },
{ type: "tool-call", toolCallId: id, toolName: "shell", input: { action: "run", command } },
{ type: "finish", finishReason: { unified: "tool-calls", raw: "tool-calls" } },
]);
}

function terminalToolCalls(calls) {
function shellToolCalls(calls) {
const events = calls.flatMap(({ id, input }) => {
const serialized = JSON.stringify(input);
const deltas = [];
for (let offset = 0; offset < serialized.length; offset += 4096) {
deltas.push({ type: "tool-input-delta", id, delta: serialized.slice(offset, offset + 4096) });
}
return [
{ type: "tool-input-start", id, toolName: "terminal" },
{ type: "tool-input-start", id, toolName: "shell" },
...deltas,
{ type: "tool-input-end", id },
{ type: "tool-call", toolCallId: id, toolName: "terminal" },
{ type: "tool-call", toolCallId: id, toolName: "shell" },
];
});
const responseEvents = [
Expand Down Expand Up @@ -177,16 +177,16 @@ const fetch = async (_url, init = {}) => {
checkedBrowserCapabilityContext = true;
}
if (!checkedToolProjection) {
if (body.tools?.length !== 1 || body.tools[0]?.name !== "terminal") {
if (body.tools?.length !== 1 || body.tools[0]?.name !== "shell") {
throw new Error(`workspace advertised unexpected tools: ${JSON.stringify(body.tools)}`);
}
const schema = body.tools[0]?.inputSchema;
if (JSON.stringify(schema?.required) !== JSON.stringify(["action", "command"]) ||
schema?.properties?.action?.enum?.[0] !== "exec" ||
schema?.properties?.action?.enum?.[0] !== "run" ||
schema?.properties?.command?.maxLength !== 65_536 ||
Object.keys(schema?.properties || {}).join(",") !== "action,command" ||
schema?.additionalProperties !== false) {
throw new Error(`workspace advertised unexpected terminal schema: ${JSON.stringify(schema)}`);
throw new Error(`workspace advertised unexpected shell schema: ${JSON.stringify(schema)}`);
}
checkedToolProjection = true;
}
Expand All @@ -211,9 +211,9 @@ const fetch = async (_url, init = {}) => {
}
if (toolResult(body, "workspace-oversized")) {
requireResult(body, "workspace-oversized", ["exceeds 65536 bytes"]);
requireResult(body, "workspace-profile", ["accepts only the", "action", "command", "fields"]);
requireResult(body, "workspace-durable", ["action must be", "exec"]);
requireResult(body, "workspace-unknown", ["accepts only the", "action", "command", "fields"]);
requireResult(body, "workspace-profile", ["accepts only action and command"]);
requireResult(body, "workspace-durable", ["action must be run"]);
requireResult(body, "workspace-unknown", ["accepts only action and command"]);
return textResponse("invalid boundaries checked");
}
const prompt = latestUserText(body);
Expand All @@ -222,11 +222,11 @@ const fetch = async (_url, init = {}) => {
if (prompt.includes("workspace timeout")) return toolCall("workspace-timeout", "timeout-command");
if (prompt.includes("workspace abort")) return toolCall("workspace-abort", "hold-command");
if (prompt.includes("workspace invalid boundaries")) {
return terminalToolCalls([
{ id: "workspace-oversized", input: { action: "exec", command: "x".repeat(65_537) } },
{ id: "workspace-profile", input: { action: "exec", command: "must-not-run", profile: "clean" } },
{ id: "workspace-durable", input: { action: "start", command: "must-not-run" } },
{ id: "workspace-unknown", input: { action: "exec", command: "must-not-run", unexpected: true } },
return shellToolCalls([
{ id: "workspace-oversized", input: { action: "run", command: "x".repeat(65_537) } },
{ id: "workspace-profile", input: { action: "run", command: "must-not-run", profile: "clean" } },
{ id: "workspace-durable", input: { action: "wait", session_id: "must-not-run" } },
{ id: "workspace-unknown", input: { action: "run", command: "must-not-run", unexpected: true } },
]);
}
if (prompt.includes("unsupported web request")) {
Expand Down
56 changes: 26 additions & 30 deletions src/acp/prompt.zig
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,6 @@ const AcpContext = struct {
.retain_grant_fn = retainAcpGrant,
} else null,
.cancel_flag = &session.cancel_flag,
.background = &self.state.background,
.session = &session.session_rt,
.session_allocator = self.alloc,
.skills_dir = self.state.skills.dir,
Expand All @@ -314,13 +313,13 @@ const AcpContext = struct {
.on_output_chunk = onCommandOutputChunk,
.mcp_progress_ctx = @ptrCast(self),
.on_mcp_progress = onMcpProgress,
.background_url_ctx = @ptrCast(self),
.on_background_url_ready = onBackgroundUrlReady,
.session_child_capability = if (session.writable) |*writable|
writable.childCapability() catch null
else
null,
.terminal_client = &self.state.terminal_client,
.managed_executions = &self.state.managed_executions,
.ephemeral_command_replay = self.state.managed_executions.replayStore(),
.web_fetch_runtime = &self.state.web_fetch_runtime,
.web_fetch_artifact_store = session.session_rt.webFetchArtifactStore(),
.web_fetch_artifact_error = session.session_rt.webFetchArtifactError(),
Expand Down Expand Up @@ -1205,8 +1204,6 @@ fn appendRuntimeContext(raw_ctx: *anyopaque, arena: Allocator, messages: *std.Ar
.interactive = false,
.permission_mode = ctx.captured_permission_mode orelse session.permission_mode,
.tracker = null,
.background = &ctx.state.background,
.session = &session.session_rt,
}, arena, messages);
}

Expand Down Expand Up @@ -2501,8 +2498,6 @@ fn activeMcp(ctx: *AcpContext) ?*mcp_runtime.McpRuntime {
return session.mcp;
}

fn onBackgroundUrlReady(_: *anyopaque, _: u64, _: []const u8) void {}

pub fn mapToolKind(tool_name: []const u8) acp_types.ToolCallKind {
if (tool_presentation.isProviderSearchAlias(tool_name)) return .search;
if (std.mem.eql(u8, tool_name, "glob_files")) return .read;
Expand All @@ -2512,6 +2507,7 @@ pub fn mapToolKind(tool_name: []const u8) acp_types.ToolCallKind {
if (std.mem.eql(u8, tool_name, "web_search")) return .search;
if (std.mem.eql(u8, tool_name, "write_file")) return .edit;
if (std.mem.eql(u8, tool_name, "edit_file")) return .edit;
if (std.mem.eql(u8, tool_name, "shell")) return .execute;
if (std.mem.eql(u8, tool_name, "terminal")) return .execute;
if (std.mem.eql(u8, tool_name, "run_command")) return .execute;
if (std.mem.eql(u8, tool_name, "skill")) return .other;
Expand Down Expand Up @@ -2544,15 +2540,15 @@ fn describeToolTitle(registry: tool_dispatch.Registry, arena: Allocator, call: T
return std.fmt.allocPrint(arena, "{s}", .{call.name});
}

test "ACP terminal title uses the call-aware action label" {
test "ACP shell title uses the call-aware action label" {
const alloc = std.testing.allocator;
const title = try describeToolTitle(builtin_tools.registry, alloc, .{
.id = "close",
.name = "terminal",
.arguments_json = "{\"action\":\"close\",\"session_id\":\"terminal-a\",\"close_policy\":\"graceful\"}",
.name = "shell",
.arguments_json = "{\"action\":\"stop\",\"session_id\":\"terminal-a\"}",
});
defer alloc.free(title);
try std.testing.expectEqualStrings("Closing", title);
try std.testing.expectEqualStrings("Stopping", title);
}

test "ACP lifecycle action preserves dynamic MCP availability boundaries" {
Expand Down Expand Up @@ -2581,7 +2577,7 @@ test "ACP lifecycle action preserves dynamic MCP availability boundaries" {
defer alloc.free(missing_label);
try std.testing.expectEqualStrings("Working: mcp_lookup", missing_label);

const builtin = dynamicMcpToolAvailable(builtin_tools.registry, "terminal", &.{"terminal"}, @ptrCast(&fixture), Fixture.hasTool, .unrestricted);
const builtin = dynamicMcpToolAvailable(builtin_tools.registry, "terminal", &.{"shell"}, @ptrCast(&fixture), Fixture.hasTool, .unrestricted);
try std.testing.expect(!builtin);
try std.testing.expectEqual(@as(usize, 1), fixture.calls);
}
Expand Down Expand Up @@ -3537,8 +3533,8 @@ test "ACP pending tool_call updates keep provider ids stable and dedupe" {

const call = ToolCall{
.id = "provider_call_7",
.name = "terminal",
.arguments_json = "{\"action\":\"exec\",\"command\":\"ls\",\"api_key\":\"secret-value\"}",
.name = "shell",
.arguments_json = "{\"action\":\"run\",\"command\":\"ls\",\"api_key\":\"secret-value\"}",
};
const first = try ctx.sendToolCallPending(alloc, call);
const second = try ctx.sendToolCallPending(alloc, call);
Expand Down Expand Up @@ -3566,9 +3562,9 @@ test "ACP pending tool_call updates keep provider ids stable and dedupe" {
try std.testing.expectEqualStrings("tool_call", update.get("sessionUpdate").?.string);
const call_id = update.get("toolCallId").?.string;
if (std.mem.eql(u8, call_id, "provider_call_7")) {
try std.testing.expectEqualStrings("terminal", update.get("name").?.string);
try std.testing.expectEqualStrings("shell", update.get("name").?.string);
const raw_input = update.get("rawInput").?.object;
try std.testing.expectEqualStrings("exec", raw_input.get("action").?.string);
try std.testing.expectEqualStrings("run", raw_input.get("action").?.string);
try std.testing.expectEqualStrings("ls", raw_input.get("command").?.string);
try std.testing.expectEqualStrings("[REDACTED]", raw_input.get("api_key").?.string);
pending_count += 1;
Expand Down Expand Up @@ -4083,25 +4079,25 @@ test "ACP default user commands require configured authority or review" {

const direct = (try requestToolPermissionOutcome(&ctx, arena, .{
.id = "direct",
.name = "terminal",
.arguments_json = "{\"action\":\"exec\",\"command\":\"pwd\"}",
.name = "shell",
.arguments_json = "{\"action\":\"run\",\"command\":\"pwd\"}",
}, .ask, &.{}, &.{}));
try std.testing.expectEqual(ToolPermissionDecision.permission_required, direct.decision);
try std.testing.expect(direct.execution_authority == null);

const blocked = (try requestToolPermissionOutcome(&ctx, arena, .{
.id = "blocked",
.name = "terminal",
.arguments_json = "{\"action\":\"exec\",\"command\":\"touch blocked.txt\"}",
.name = "shell",
.arguments_json = "{\"action\":\"run\",\"command\":\"touch blocked.txt\"}",
}, .ask, &.{}, &.{}));
try std.testing.expectEqual(ToolPermissionDecision.permission_required, blocked.decision);
try std.testing.expect(blocked.execution_authority == null);

state.active_session.?.permission_rules = try testPermissionRuleSet(alloc, "bash", "touch *", .allow);
const configured = (try requestToolPermissionOutcome(&ctx, arena, .{
.id = "configured",
.name = "terminal",
.arguments_json = "{\"action\":\"exec\",\"command\":\"touch configured.txt\"}",
.name = "shell",
.arguments_json = "{\"action\":\"run\",\"command\":\"touch configured.txt\"}",
}, .ask, &.{}, &.{}));
switch ((configured.execution_authority orelse return error.TestExpectedEqual).run_command) {
.direct_only => return error.TestExpectedShellAllowed,
Expand All @@ -4112,8 +4108,8 @@ test "ACP default user commands require configured authority or review" {
state.active_session.?.permission_rules = .{};
const automatic = (try requestToolPermissionOutcome(&ctx, arena, .{
.id = "automatic",
.name = "terminal",
.arguments_json = "{\"action\":\"exec\",\"command\":\"touch automatic.txt\"}",
.name = "shell",
.arguments_json = "{\"action\":\"run\",\"command\":\"touch automatic.txt\"}",
}, .auto, &.{}, &.{}));
try std.testing.expectEqual(ToolPermissionDecision.deny, automatic.decision);
try std.testing.expectEqual(types.ToolPermissionDenialReason.review_unavailable, automatic.denial_reason.?);
Expand Down Expand Up @@ -4161,8 +4157,8 @@ test "ACP auto mode uses automatic review clear and caution without prompting" {

const direct_call: ToolCall = .{
.id = "direct",
.name = "terminal",
.arguments_json = "{\"action\":\"exec\",\"command\":\"pwd\"}",
.name = "shell",
.arguments_json = "{\"action\":\"run\",\"command\":\"pwd\"}",
};
var direct_review = TestReviewTurn.init("Inspect the workspace.", direct_call);
const direct = try requestToolPermissionOutcomeWithRequest(
Expand All @@ -4184,8 +4180,8 @@ test "ACP auto mode uses automatic review clear and caution without prompting" {

const accepted_call: ToolCall = .{
.id = "accepted",
.name = "terminal",
.arguments_json = "{\"action\":\"exec\",\"command\":\"touch accepted.txt\"}",
.name = "shell",
.arguments_json = "{\"action\":\"run\",\"command\":\"touch accepted.txt\"}",
};
var accepted_review = TestReviewTurn.init("Create accepted.txt.", accepted_call);
const accepted = try requestToolPermissionOutcomeWithRequest(&ctx, arena, accepted_call, accepted_review.context(), .auto, &.{}, null, null, &.{});
Expand All @@ -4205,8 +4201,8 @@ test "ACP auto mode uses automatic review clear and caution without prompting" {
fake.decision = .caution;
const blocked_call: ToolCall = .{
.id = "check",
.name = "terminal",
.arguments_json = "{\"action\":\"exec\",\"command\":\"touch check.txt\"}",
.name = "shell",
.arguments_json = "{\"action\":\"run\",\"command\":\"touch check.txt\"}",
};
var blocked_review = TestReviewTurn.init("Check whether this is allowed.", blocked_call);
const blocked = try requestToolPermissionOutcomeWithRequest(&ctx, arena, blocked_call, blocked_review.context(), .auto, &.{}, null, null, &.{});
Expand Down
Loading
Loading