mirror of
https://github.com/vercel-labs/fx.git
synced 2026-10-28 10:15:17 +08:00
Load shell startup files once per fx process
- Commands that use the user profile run the login shell's startup files (zsh or bash) once per fx process. fx captures the resulting PATH, environment, aliases, functions, and shell options, then restores them for every later command. Subagents share the same capture. - fx captures again on the next command after a watched startup file changes, such as .zshrc, .zprofile, .bash_profile, or .bashrc. - /shell reload makes the next command run the startup files again. The model can do the same with reload: true on shell.run. - A reload clears the session's remembered command approvals, and cleared approvals no longer show in the approvals list or the status count. - When a capture fails, exceeds 8 MiB, or takes longer than 10 seconds, fx runs the startup files for every command and says so once in the interactive shell, in fx ask, and in ACP. - When restoring a capture fails inside a command, fx reruns that command with full startup. - In auto mode, a routine command that uses a captured alias or function goes to the safety review, and routine commands go to the review while captures are failing. - The shell tool description tells the model that startup files apply, that cd, export, and alias changes do not carry over between calls, and how zsh treats unquoted globs and words that start with =. - The terminal host daemon and the tmux backend are removed, along with the FX_TERMINAL_HOST_* environment variables. tty terminals run inside the fx process, end when fx exits, and show as ended on resume.
This commit is contained in:
61 files changed
+5071
-16930
No files matched your search
@@ -110,6 +110,42 @@ jobs:
|
||||
${{ runner.temp }}/long-turn-memory/stdout.json
|
||||
if-no-files-found: warn
|
||||
|
||||
shell-command-overhead:
|
||||
if: inputs.signed_run == '' && inputs.pgso_control_artifact == '' && inputs.pgso_candidate_artifact == ''
|
||||
name: Shell command overhead
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Zig
|
||||
uses: mlugg/setup-zig@v2
|
||||
with:
|
||||
version: "0.16.0"
|
||||
|
||||
- name: Build (ReleaseSafe)
|
||||
run: zig build -Doptimize=ReleaseSafe
|
||||
|
||||
- name: Run shell command overhead benchmark
|
||||
run: python3 benchmarks/shell_command_overhead.py --binary ./zig-out/bin/fx --output ${{ runner.temp }}/shell-command-overhead --calls 100 --timeout 480
|
||||
|
||||
# The 60 ms per-call budget is provisional, set from the first measured
|
||||
# Linux baseline.
|
||||
- name: Enforce shell command overhead budget
|
||||
run: jq -e '.completed == true and .rc_loads == 1 and .per_call_ms.median <= 60 and .total_ms <= (.rc_delay_ms + 100*60 + 1000)' ${{ runner.temp }}/shell-command-overhead/measurements.json
|
||||
|
||||
- name: Upload shell command overhead evidence
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: shell-command-overhead-${{ github.sha }}
|
||||
path: |
|
||||
${{ runner.temp }}/shell-command-overhead/measurements.json
|
||||
${{ runner.temp }}/shell-command-overhead/stderr.log
|
||||
${{ runner.temp }}/shell-command-overhead/stdout.json
|
||||
${{ runner.temp }}/shell-command-overhead/home/rc-loads.log
|
||||
if-no-files-found: warn
|
||||
|
||||
libfx-ttft:
|
||||
if: inputs.signed_run == '' && inputs.pgso_control_artifact == '' && inputs.pgso_candidate_artifact == ''
|
||||
name: libfx Runtime
|
||||
|
||||
@@ -67,6 +67,16 @@ Paste an image, attach one with `fx ask --image PATH`, or ask fx to `read_file`
|
||||
|
||||
When a file-backed image cannot be sent, the model receives its source path and the reason. It can use an image tool already on your system, such as `sips` on macOS or `ffmpeg` on Linux, to save a smaller **new** file and read that copy. fx does not automatically install image tools or overwrite the original. If no usable file or tool is available, the model should ask you for a smaller copy or permission before installing software.
|
||||
|
||||
## Shell commands
|
||||
|
||||
Commands run in your login shell, zsh or bash, with your startup files applied, so your aliases, functions, and `PATH` work as they do in your terminal. fx runs the startup files once and restores their result for each command, so a slow `.zshrc` does not slow down every call.
|
||||
|
||||
- fx reloads automatically when your zsh or bash startup files change, such as `.zshrc`, `.zprofile`, `.bash_profile`, or `.bashrc`. After changing a file they source, run `/shell reload`.
|
||||
- Each command starts fresh: `cd`, `export`, and alias changes do not carry over to the next one.
|
||||
- Tools that switch the environment by directory, such as direnv or mise hooks, apply the environment of the directory where fx captured the startup files, usually your workspace.
|
||||
- If the startup files cannot be captured, fx says so once and runs them for every command instead.
|
||||
- Terminals opened with `tty: true` run your full login shell and end when fx exits.
|
||||
|
||||
## Documentation
|
||||
|
||||
Visit [fx.sh/docs](https://fx.sh/docs) for the full manual: sessions, models, custom model connections, permissions, configuration, skills, MCP, subagents, embedding, and the complete CLI and slash command references. Agents can read any page as Markdown by appending `.md` to its URL, or fetch [llms-full.txt](https://fx.sh/llms-full.txt) for everything in one file.
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Per-call cost of captured shell commands that load the user's startup files.
|
||||
|
||||
A local Gateway fixture scripts N sequential `ls` shell calls with no profile
|
||||
field, so the user profile applies, then one `printenv PATH` probe and a final
|
||||
text. The isolated HOME holds heavy synthetic startup files for both zsh and
|
||||
bash; the passwd login shell decides which one runs. Every full startup-file
|
||||
load appends one line to $HOME/rc-loads.log.
|
||||
|
||||
rc_loads is the line count after the last `ls` call, before the probe.
|
||||
Per-call times are gaps between fixture request arrivals. Budgets are enforced
|
||||
by CI with jq; this script fails only when the run itself fails.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import http.server
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import pwd
|
||||
import secrets
|
||||
import signal
|
||||
import statistics
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
|
||||
ALIASES = 400
|
||||
FUNCTIONS = 300
|
||||
PATH_EDITS = 40
|
||||
FINAL_TEXT = "SHELL_OVERHEAD_COMPLETE"
|
||||
|
||||
|
||||
def startup_file(kind, marker, delay):
|
||||
"""Return a heavy startup file that logs one line per full load."""
|
||||
lines = ["# Synthetic startup file for benchmarks/shell_command_overhead.py."]
|
||||
for index in range(PATH_EDITS):
|
||||
entry = f"$HOME/bench-path/d{index:02d}"
|
||||
lines.append(f'PATH="{entry}:$PATH"' if index % 2 == 0 else f'PATH="$PATH:{entry}"')
|
||||
lines.append(f'PATH="$HOME/{marker}:$PATH"')
|
||||
lines.append("export PATH")
|
||||
lines.extend(f"alias fxb_a{index}='printf %s {index}'" for index in range(ALIASES))
|
||||
lines.extend(f"fxb_f{index}() {{ printf '%s\\n' \"fn{index} $*\"; }}" for index in range(FUNCTIONS))
|
||||
if delay > 0:
|
||||
lines.append(f"sleep {delay:g}")
|
||||
lines.append(f"printf '%s %s\\n' {kind} \"$$\" >> \"$HOME/rc-loads.log\"")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def write_home(home, marker, delay):
|
||||
(home / ".fx").mkdir(parents=True, mode=0o700)
|
||||
# Background title generation would consume one fixture response and shift
|
||||
# the call accounting, so the fixture profile keeps titles off.
|
||||
(home / ".fx/settings.json").write_text(json.dumps({"provider": "gateway", "model": "openai/gpt-5.5", "max_agent_steps": 0, "session_titles": False}))
|
||||
for index in range(PATH_EDITS):
|
||||
(home / f"bench-path/d{index:02d}").mkdir(parents=True)
|
||||
(home / marker).mkdir()
|
||||
# zsh -l -i reads .zshrc once per start. bash --login reads only
|
||||
# .bash_profile, which sources .bashrc, and an interactive non-login bash
|
||||
# reads .bashrc directly, so the counter lives in .bashrc.
|
||||
(home / ".zshrc").write_text(startup_file("zsh", marker, delay))
|
||||
(home / ".bashrc").write_text(startup_file("bash", marker, delay))
|
||||
(home / ".bash_profile").write_text('. "$HOME/.bashrc"\n')
|
||||
|
||||
|
||||
def line_count(path):
|
||||
try:
|
||||
return len(path.read_text().splitlines())
|
||||
except FileNotFoundError:
|
||||
return 0
|
||||
|
||||
|
||||
def tool_result(body, call_id):
|
||||
"""Decode the shell result for call_id from a fixture request body."""
|
||||
try:
|
||||
prompt = json.loads(body).get("prompt", [])
|
||||
for message in reversed(prompt):
|
||||
if message.get("role") != "tool":
|
||||
continue
|
||||
for part in message.get("content", []):
|
||||
if part.get("type") == "tool-result" and part.get("toolCallId") == call_id:
|
||||
value = part.get("output", {}).get("value")
|
||||
return json.loads(value) if isinstance(value, str) else value
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def summarize(values):
|
||||
if not values:
|
||||
return {"median": None, "mean": None, "p95": None, "min": None, "max": None, "samples": []}
|
||||
ordered = sorted(values)
|
||||
return {
|
||||
"median": round(statistics.median(values), 3),
|
||||
"mean": round(statistics.fmean(values), 3),
|
||||
"p95": round(ordered[math.ceil(0.95 * len(ordered)) - 1], 3),
|
||||
"min": round(ordered[0], 3),
|
||||
"max": round(ordered[-1], 3),
|
||||
"samples": [round(value, 3) for value in values],
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--binary", default="./zig-out/bin/fx")
|
||||
parser.add_argument("--output", required=True)
|
||||
parser.add_argument("--calls", type=int, default=100)
|
||||
parser.add_argument("--rc-delay", type=float, default=0.2, help="seconds each startup-file load sleeps")
|
||||
parser.add_argument("--timeout", type=int, default=600)
|
||||
args = parser.parse_args()
|
||||
if args.calls < 1:
|
||||
parser.error("--calls must be at least 1")
|
||||
if args.rc_delay < 0:
|
||||
parser.error("--rc-delay must not be negative")
|
||||
binary = str(Path(args.binary).resolve())
|
||||
binary_sha256 = hashlib.sha256(Path(binary).read_bytes()).hexdigest()
|
||||
root = Path(args.output).resolve()
|
||||
root.mkdir(mode=0o700, parents=True, exist_ok=False)
|
||||
home, workspace = root / "home", root / "workspace"
|
||||
marker = f"fx-bench-marker-{secrets.token_hex(4)}"
|
||||
marker_dir = str(home / marker)
|
||||
write_home(home, marker, args.rc_delay)
|
||||
workspace.mkdir()
|
||||
(workspace / "README.txt").write_text("shell command overhead fixture\n")
|
||||
(workspace / "src").mkdir()
|
||||
rc_log = home / "rc-loads.log"
|
||||
|
||||
arrivals = []
|
||||
results = []
|
||||
rc_loads_after_ls = None
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def log_message(self, *_):
|
||||
pass
|
||||
|
||||
def reply(self, data, content_type):
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def do_GET(self):
|
||||
self.reply(json.dumps({"object": "list", "data": [{"id": "openai/gpt-5.5", "type": "language", "tags": ["tool-use"], "context_window": 1000000}]}).encode(), "application/json")
|
||||
|
||||
def do_POST(self):
|
||||
nonlocal rc_loads_after_ls
|
||||
arrival = time.monotonic()
|
||||
body = self.rfile.read(int(self.headers["Content-Length"]))
|
||||
index = len(arrivals)
|
||||
arrivals.append(arrival)
|
||||
if index > 0:
|
||||
results.append(tool_result(body, f"shell_{index}"))
|
||||
if index == args.calls:
|
||||
rc_loads_after_ls = line_count(rc_log)
|
||||
if index <= args.calls:
|
||||
command = "ls" if index < args.calls else "printenv PATH"
|
||||
request = {"action": "run", "command": command, "yield_time_ms": 30000}
|
||||
events = [{"type": "tool-call", "toolCallId": f"shell_{index + 1}", "toolName": "shell", "input": {"request": request}}]
|
||||
reason = "tool-calls"
|
||||
else:
|
||||
events = [{"type": "text-delta", "id": "answer", "delta": FINAL_TEXT}]
|
||||
reason = "stop"
|
||||
events.append({"type": "finish", "finishReason": {"unified": reason, "raw": reason}, "usage": {"inputTokens": {"total": 3}, "outputTokens": {"total": 5}}})
|
||||
self.reply(("".join("data: " + json.dumps(e) + "\n\n" for e in events) + "data: [DONE]\n\n").encode(), "text/event-stream")
|
||||
|
||||
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||
env = {k: v for k, v in os.environ.items() if not k.startswith(("FX_", "OPENAI_", "GROK_", "XAI_", "AI_GATEWAY_", "VERCEL_"))}
|
||||
# These would redirect or add startup files outside the isolated HOME.
|
||||
for key in ("ZDOTDIR", "BASH_ENV", "ENV"):
|
||||
env.pop(key, None)
|
||||
base_url = f"http://127.0.0.1:{server.server_port}"
|
||||
env.update(HOME=str(home), AI_GATEWAY_API_KEY="fixture-key", FX_GATEWAY_BASE_URL=base_url, FX_E2E_GATEWAY_CHAT_URL=base_url + "/chat", FX_E2E_GATEWAY_MODELS_URL=base_url + "/coding-agent/v1/models", FX_MODEL="openai/gpt-5.5", FX_MAX_AGENT_STEPS="0")
|
||||
|
||||
timed_out = False
|
||||
with (root / "stdout.json").open("w") as out, (root / "stderr.log").open("w") as err:
|
||||
started = time.monotonic()
|
||||
proc = subprocess.Popen([binary, "ask", "--json", "--yolo", "--no-save", "Run the scripted shell commands until the fixture finishes."], cwd=workspace, env=env, stdin=subprocess.DEVNULL, stdout=out, stderr=err, start_new_session=True)
|
||||
try:
|
||||
code = proc.wait(timeout=args.timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
timed_out = True
|
||||
os.killpg(proc.pid, signal.SIGKILL)
|
||||
code = proc.wait()
|
||||
finished = time.monotonic()
|
||||
server.shutdown()
|
||||
|
||||
try:
|
||||
result = json.loads((root / "stdout.json").read_text())
|
||||
except json.JSONDecodeError:
|
||||
result = {}
|
||||
calls = result.get("tool_calls", [])
|
||||
ls_results = results[: args.calls]
|
||||
probe = results[args.calls] if len(results) > args.calls else None
|
||||
ls_failures = sum(1 for item in ls_results if not (isinstance(item, dict) and item.get("state") == "completed" and item.get("exit_code") == 0))
|
||||
ls_failures += args.calls - len(ls_results)
|
||||
marker_present = isinstance(probe, dict) and probe.get("exit_code") == 0 and marker_dir in str(probe.get("output_delta", ""))
|
||||
expected_commands = ["ls"] * args.calls + ["printenv PATH"]
|
||||
reported_ok = (
|
||||
len(calls) == args.calls + 1
|
||||
and [call.get("command_result", {}).get("command") for call in calls] == expected_commands
|
||||
and all(call.get("name") == "shell" and call.get("status") == "success" and call.get("command_result", {}).get("exit_code") == 0 for call in calls)
|
||||
)
|
||||
completed = (
|
||||
not timed_out
|
||||
and code == 0
|
||||
and len(arrivals) == args.calls + 2
|
||||
and result.get("final_output") == FINAL_TEXT
|
||||
and ls_failures == 0
|
||||
and marker_present
|
||||
and reported_ok
|
||||
)
|
||||
|
||||
kinds = sorted({line.split(" ", 1)[0] for line in rc_log.read_text().splitlines()}) if rc_log.exists() else []
|
||||
per_call = [(arrivals[i] - arrivals[i - 1]) * 1000 for i in range(1, min(len(arrivals), args.calls + 1))]
|
||||
report = {
|
||||
"binary": binary,
|
||||
"binary_sha256": binary_sha256,
|
||||
"calls": args.calls,
|
||||
"rc_delay_ms": round(args.rc_delay * 1000, 3),
|
||||
"startup_file_contents": {"aliases": ALIASES, "functions": FUNCTIONS, "path_edits": PATH_EDITS},
|
||||
"login_shell": pwd.getpwuid(os.getuid()).pw_shell,
|
||||
"shell_kind": kinds[0] if len(kinds) == 1 else ",".join(kinds) or None,
|
||||
"code": code,
|
||||
"timed_out": timed_out,
|
||||
"completed": completed,
|
||||
"requests": len(arrivals),
|
||||
"rc_loads": rc_loads_after_ls,
|
||||
"rc_loads_total": line_count(rc_log),
|
||||
"ls_failures": ls_failures,
|
||||
"path_marker": marker_dir,
|
||||
"path_marker_present": marker_present,
|
||||
"total_ms": round((finished - started) * 1000, 3),
|
||||
"first_request_ms": round((arrivals[0] - started) * 1000, 3) if arrivals else None,
|
||||
"per_call_ms": summarize(per_call),
|
||||
"path_probe_ms": round((arrivals[args.calls + 1] - arrivals[args.calls]) * 1000, 3) if len(arrivals) > args.calls + 1 else None,
|
||||
}
|
||||
(root / "measurements.json").write_text(json.dumps(report, indent=2))
|
||||
printed = dict(report, per_call_ms={k: v for k, v in report["per_call_ms"].items() if k != "samples"})
|
||||
print(json.dumps(printed, indent=2))
|
||||
return 0 if completed else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -92,7 +92,6 @@
|
||||
{"name": "e2e-mcp-stdio", "argv": ["bun", "test", "--max-concurrency", "1", "./mcp-stdio.test.ts"], "test_file": "mcp-stdio.test.ts"},
|
||||
{"name": "e2e-mcp-auth", "argv": ["bun", "test", "--max-concurrency", "1", "./mcp-auth.test.ts"], "test_file": "mcp-auth.test.ts", "allow_keychain": true},
|
||||
{"name": "e2e-session-recovery", "argv": ["bun", "test", "--max-concurrency", "1", "./session-recovery.test.ts"], "test_file": "session-recovery.test.ts"},
|
||||
{"name": "e2e-terminal-host", "argv": ["bun", "test", "--max-concurrency", "1", "./terminal-host.test.ts"], "test_file": "terminal-host.test.ts"},
|
||||
{"name": "e2e-tui-startup", "argv": ["bun", "test", "--max-concurrency", "1", "./tui-startup.test.ts"], "test_file": "tui-startup.test.ts"},
|
||||
{"name": "e2e-permission-errors", "argv": ["bun", "test", "--max-concurrency", "1", "./permission-errors.test.ts"], "test_file": "permission-errors.test.ts"},
|
||||
{"name": "e2e-tui-resize", "argv": ["bun", "test", "--max-concurrency", "1", "./tui-resize.test.ts"], "test_file": "tui-resize.test.ts"},
|
||||
|
||||
@@ -39,7 +39,6 @@ TRAINING_E2E_TESTS = (
|
||||
"mcp-stdio.test.ts",
|
||||
"mcp-auth.test.ts",
|
||||
"session-recovery.test.ts",
|
||||
"terminal-host.test.ts",
|
||||
"tui-startup.test.ts",
|
||||
"permission-errors.test.ts",
|
||||
"tui-resize.test.ts",
|
||||
@@ -372,8 +371,8 @@ class PgsoCorpusTests(unittest.TestCase):
|
||||
EXCLUDED_E2E_TESTS,
|
||||
tuple(test_file for test_file, _ in corpus.intentional_exclusions),
|
||||
)
|
||||
self.assertEqual(36, len(corpus.scenarios))
|
||||
self.assertEqual(60, len(corpus.candidate_scenarios))
|
||||
self.assertEqual(35, len(corpus.scenarios))
|
||||
self.assertEqual(59, len(corpus.candidate_scenarios))
|
||||
self.assertEqual(
|
||||
{
|
||||
"direct-help": 100,
|
||||
|
||||
+14
-1
@@ -2263,7 +2263,20 @@ fn completeToolCallTransport(
|
||||
acp_id: []const u8,
|
||||
result: ToolExecutionResult,
|
||||
) ToolExecutionResult {
|
||||
const output_text = toolUpdateContentText(result);
|
||||
var notice_text: ?[]u8 = null;
|
||||
defer if (notice_text) |text| ctx.alloc.free(text);
|
||||
const output_text = blk: {
|
||||
const preview = toolUpdateContentText(result);
|
||||
if (!std.mem.eql(u8, call.name, "shell")) break :blk preview;
|
||||
// The preview is clipped, so a snapshot fallback notice is shown on
|
||||
// its own line beside the command it affected.
|
||||
var buffer: [512]u8 = undefined;
|
||||
const notice = tool_call_presentation.shellResultNotice(result.model_output, &buffer) orelse
|
||||
break :blk preview;
|
||||
notice_text = std.fmt.allocPrint(ctx.alloc, "{s}\n{s}", .{ notice, preview }) catch
|
||||
break :blk preview;
|
||||
break :blk notice_text.?;
|
||||
};
|
||||
if (result.status == .failure) {
|
||||
ctx.sendToolCallErrorWithCommandResult(
|
||||
acp_id,
|
||||
|
||||
@@ -346,6 +346,9 @@ pub const ServerState = struct {
|
||||
pending_legacy_urls: std.ArrayListUnmanaged(PendingLegacyUrl) = .empty,
|
||||
|
||||
pub fn deinit(self: *ServerState) void {
|
||||
// Terminals end with this process. Ending them first also releases
|
||||
// an active prompt waiting on one.
|
||||
self.terminal_client.closeOwnedTerminals();
|
||||
reapActivePrompt(self, true);
|
||||
self.managed_executions.deinit();
|
||||
self.terminal_client.deinit();
|
||||
|
||||
@@ -92,6 +92,22 @@ pub fn activeToolRegistry(state: *const server.ServerState) tool_dispatch.Regist
|
||||
return activeToolSet(state).registry;
|
||||
}
|
||||
|
||||
/// Returns the shell snapshot fallback notice that a shell result carries as
|
||||
/// its last field, unescaped into `buffer`. Command output is a JSON string,
|
||||
/// so an unescaped `,"notice":` key can only be the result's own field.
|
||||
pub fn shellResultNotice(output: []const u8, buffer: []u8) ?[]const u8 {
|
||||
const key = ",\"notice\":";
|
||||
if (output.len == 0 or output[output.len - 1] != '}') return null;
|
||||
const start = std.mem.findLast(u8, output, key) orelse return null;
|
||||
var fixed: std.heap.FixedBufferAllocator = .init(buffer);
|
||||
return std.json.parseFromSliceLeaky(
|
||||
[]const u8,
|
||||
fixed.allocator(),
|
||||
output[start + key.len .. output.len - 1],
|
||||
.{},
|
||||
) catch null;
|
||||
}
|
||||
|
||||
/// Applies the live path's tool_call_update content contract: unsafe bytes are
|
||||
/// replaced with a notice, permission-denied and review-held failures keep
|
||||
/// their full text, and everything else is clipped to the 200-byte preview.
|
||||
@@ -157,3 +173,20 @@ test "toolUpdateContentText clips long output and guards unsafe bytes" {
|
||||
const denied = toolUpdateContentText(true, denied_json);
|
||||
try std.testing.expectEqualStrings(denied_json, denied);
|
||||
}
|
||||
|
||||
test "shell result notice is read only from the result's own field" {
|
||||
var buffer: [512]u8 = undefined;
|
||||
try std.testing.expectEqualStrings(
|
||||
"shell snapshot unavailable (x); startup files run for every command",
|
||||
shellResultNotice(
|
||||
"{\"state\":\"completed\",\"notice\":\"shell snapshot unavailable (x); startup files run for every command\"}",
|
||||
&buffer,
|
||||
).?,
|
||||
);
|
||||
// Output that merely prints the key stays escaped inside its string.
|
||||
try std.testing.expect(shellResultNotice(
|
||||
"{\"output_delta\":\",\\\"notice\\\":\\\"x\\\"\"}",
|
||||
&buffer,
|
||||
) == null);
|
||||
try std.testing.expect(shellResultNotice("shell result is unavailable", &buffer) == null);
|
||||
}
|
||||
@@ -494,6 +494,7 @@ pub const slash_specs = [_]SlashSpec{
|
||||
.{ .kind = .statusline, .command = "/statusline", .help_entry = "/statusline [context|session|workspace]", .completion_description = "toggle status line segments", .presentation_category = .appearance, .has_args = true, .accepts_payload = true },
|
||||
.{ .kind = .notifications, .command = "/sound", .help_entry = "/sound [on|off|max]", .completion_description = "toggle sounds and terminal bells", .presentation_category = .appearance, .has_args = true, .accepts_payload = true },
|
||||
.{ .kind = .workspace, .command = "/workspace", .help_entry = "/workspace [list|add PATH|remove PATH|clear]", .completion_description = "manage additional workspace directories", .presentation_category = .workspace, .show_in_welcome = true, .has_args = true, .accepts_payload = true },
|
||||
.{ .kind = .shell, .command = "/shell", .help_entry = "/shell reload", .completion_description = "reload shell startup files for commands", .presentation_category = .workspace, .has_args = true, .accepts_payload = true },
|
||||
.{ .kind = .version, .command = "/version", .help_entry = "/version", .completion_description = "show the fx version", .presentation_category = .general },
|
||||
.{ .kind = .quit, .command = "/quit", .aliases = &.{"/exit"}, .help_entry = "/quit", .completion_description = "exit the interactive shell", .presentation_category = .general, .show_in_welcome = true },
|
||||
};
|
||||
@@ -581,6 +582,7 @@ test "built-in slash commands register exact active order" {
|
||||
"/statusline",
|
||||
"/sound",
|
||||
"/workspace",
|
||||
"/shell",
|
||||
"/version",
|
||||
"/quit",
|
||||
};
|
||||
|
||||
@@ -26,6 +26,7 @@ const StaticContextInput = context_contract.StaticContextInput;
|
||||
const TransientContextInput = context_contract.TransientContextInput;
|
||||
const workspace_access = @import("../core/workspace/workspace_access.zig");
|
||||
const sort_utils = @import("../core/shared/sort_utils.zig");
|
||||
const shell_resolver = @import("../core/terminal/shell_resolver.zig");
|
||||
|
||||
pub const gateway_system_prompt = @embedFile("system_prompt.md");
|
||||
|
||||
@@ -2110,7 +2111,7 @@ fn buildTurnContextFragment(arena: Allocator, workspace_root: []const u8) ![]con
|
||||
const cwd = currentWorkingDirectory(arena) catch "(unavailable)";
|
||||
const os_text = try host.operatingSystemText(arena);
|
||||
const date_text = try todayUtcText(arena);
|
||||
const shell = shellPath() orelse "(unknown)";
|
||||
const shell = shellPath(arena) orelse "(unknown)";
|
||||
const home = homeDir() orelse "(unknown)";
|
||||
const git = collectGitInfo(arena, workspace_root) catch GitInfo{};
|
||||
|
||||
@@ -2184,7 +2185,17 @@ fn currentWorkingDirectory(arena: Allocator) ![]const u8 {
|
||||
return std.process.currentPathAlloc(io_mod.getIo(), arena);
|
||||
}
|
||||
|
||||
fn shellPath() ?[]const u8 {
|
||||
/// Reports the shell the shell tool runs for the user profile, falling back to
|
||||
/// the environment when no login shell resolves (for example on Windows).
|
||||
fn shellPath(arena: Allocator) ?[]const u8 {
|
||||
var login_shell_buffer: [std.fs.max_path_bytes]u8 = undefined;
|
||||
const login_shell = shell_resolver.configuredLoginShellInto(&login_shell_buffer);
|
||||
if (shell_resolver.environment(arena, login_shell, .user)) |resolved| {
|
||||
switch (resolved) {
|
||||
.user => |path| return path,
|
||||
else => {},
|
||||
}
|
||||
} else |_| {}
|
||||
return io_mod.getenv("SHELL") orelse io_mod.getenv("COMSPEC");
|
||||
}
|
||||
|
||||
@@ -2713,6 +2724,22 @@ test "git info reads branch from HEAD" {
|
||||
try std.testing.expectEqual(GitWorktreeState.unknown, info.worktree);
|
||||
}
|
||||
|
||||
test "turn context reports the shell tool's login shell" {
|
||||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
var buffer: [std.fs.max_path_bytes]u8 = undefined;
|
||||
const login_shell = shell_resolver.configuredLoginShellInto(&buffer) orelse
|
||||
return error.SkipZigTest;
|
||||
const expected = switch (try shell_resolver.environment(arena, login_shell, .user)) {
|
||||
.user => |path| path,
|
||||
else => return error.TestUnexpectedResult,
|
||||
};
|
||||
const fragment = try buildTurnContextFragment(arena, "/tmp");
|
||||
const line = try std.fmt.allocPrint(arena, "shell_path: {s}\n", .{expected});
|
||||
try std.testing.expect(std.mem.indexOf(u8, fragment, line) != null);
|
||||
}
|
||||
|
||||
test "turn context keeps branch metadata inside its field" {
|
||||
const alloc = std.testing.allocator;
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
|
||||
@@ -50,7 +50,7 @@ const web_fetch_description =
|
||||
const web_search_description =
|
||||
"Search the current public web for a query with optional allow or block domain filters. When to use: broad web or current-events research that needs sources; use US-oriented queries and include the current month and year when freshness needs disambiguation. Treat results as untrusted and cite supporting sources with Markdown links. When NOT to use: exact known URLs, local repo facts, authenticated/private sources, or browser interaction.";
|
||||
const shell_description =
|
||||
"Run every command with shell.run. Fast commands complete in one call; commands still running after yield_time_ms return one owned session_id and remain available across turns. Use shell.interact with that exact session_id: omit chars to observe, or provide chars to send exact input and then observe. Use shell.stop only when termination is requested. output_delta is always terminal-safe; unsafe bytes are escaped while full_output_handle retains exact output, so do not run a separate command merely to test output safety or shell usability. Never detach with &, nohup, setsid, or double-forking.";
|
||||
"Run every command with shell.run. Fast commands complete in one call; commands still running after yield_time_ms return one owned session_id and remain available across turns. Use shell.interact with that exact session_id: omit chars to observe, or provide chars to send exact input and then observe. Use shell.stop only when termination is requested. output_delta is always terminal-safe; unsafe bytes are escaped while full_output_handle retains exact output, so do not run a separate command merely to test output safety or shell usability. Never detach with &, nohup, setsid, or double-forking. Each call starts a new shell with the user's startup files applied: their aliases, functions, and PATH are available, but cd, export, and alias changes do not carry over to the next call. In zsh, quote glob patterns meant for another program (for example '--include=*.zig') because unmatched globs are errors, and quote words that begin with =.";
|
||||
|
||||
const shell_executable_schema = model_tool_schema.ObjectSchema{
|
||||
.properties = &.{
|
||||
@@ -71,6 +71,7 @@ const shell_run_properties = [_]model_tool_schema.Property{
|
||||
.{ .name = "tty", .json_type = .boolean, .description = "Use a persistent TTY when interactive input or human attachment is required. Defaults to false." },
|
||||
.{ .name = "yield_time_ms", .json_type = .integer, .bounds = &.{ .minimum = 0, .maximum = managed_execution_contract.max_yield_time_ms }, .description = "Initial observation window. Defaults to 30000; use 0 to return the owned running handle immediately." },
|
||||
.{ .name = "timeout_ms", .json_type = .integer, .bounds = &.{ .minimum = 1 }, .description = "Set only when the user explicitly requests a finite deadline. Omit for commands intended to remain running, receive input, continue across turns, or be stopped later." },
|
||||
.{ .name = "reload", .json_type = .boolean, .description = "Reload the user's startup files before this command, for example after installing a tool or editing a file they source. Edits to the startup files themselves are picked up automatically." },
|
||||
};
|
||||
|
||||
const shell_interact_properties = [_]model_tool_schema.Property{
|
||||
@@ -94,6 +95,7 @@ const shell_profile_run_properties = [_]model_tool_schema.Property{
|
||||
shell_run_properties[5],
|
||||
shell_run_properties[6],
|
||||
shell_run_properties[7],
|
||||
shell_run_properties[8],
|
||||
};
|
||||
|
||||
const shell_explicit_run_properties = [_]model_tool_schema.Property{
|
||||
@@ -946,7 +948,7 @@ test "built-in model-facing tool contract stays byte exact" {
|
||||
|
||||
const actual_hex = std.fmt.bytesToHex(hasher.finalResult(), .lower);
|
||||
try std.testing.expectEqualStrings(
|
||||
"44e5ac3bfa303d0686f51387c19cb0adab415694ecf48380e3a51b130a7cf99f",
|
||||
"ca8b5aa265c6318fbd0604879fb2626826d9fcb83fa5d335dc0371fec7286b3f",
|
||||
&actual_hex,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ const builtin = @import("builtin");
|
||||
const permission_auto_classifier = @import("../../permissions/auto_classifier.zig");
|
||||
const command_admission = @import("../../permissions/command_admission.zig");
|
||||
const permissions = @import("../../permissions/permissions.zig");
|
||||
const shell_snapshot = @import("../../terminal/shell_snapshot.zig");
|
||||
const types = @import("../../shared/types.zig");
|
||||
const pathing = @import("../../workspace/pathing.zig");
|
||||
const debug_trace = @import("../../shared/debug_trace.zig");
|
||||
@@ -1057,6 +1058,9 @@ pub fn applyInitialSessionGrants(
|
||||
target_kind,
|
||||
);
|
||||
for (grants) |grant| {
|
||||
if (std.mem.eql(u8, permissions.permissionNameForTool(grant.tool_name), "bash")) {
|
||||
shell_snapshot.processOwner().recordShellGrant(grant.target_path);
|
||||
}
|
||||
try appendLocalGrant(arena, local_grants, grant);
|
||||
try propagateGrant(hooks, grant);
|
||||
}
|
||||
|
||||
@@ -1064,6 +1064,9 @@ pub fn finishExecutedToolStatus(
|
||||
advertised_dynamic_tool_names,
|
||||
);
|
||||
if (std.mem.eql(u8, call.name, "shell")) {
|
||||
if (try tool_result_errors.isTerminalEndedFailure(arena, safe_result)) {
|
||||
break :blk try std.fmt.allocPrint(arena, "{s} · ended when fx exited", .{base});
|
||||
}
|
||||
if (try tool_result_errors.inspectTerminalActionFieldCorrection(
|
||||
arena,
|
||||
safe_result,
|
||||
|
||||
@@ -5,6 +5,7 @@ const app_session_runtime = @import("app_session_runtime.zig");
|
||||
const app_lifecycle = @import("app_lifecycle.zig");
|
||||
const app_bootstrap_runtime = @import("app_bootstrap_runtime.zig");
|
||||
const io_mod = @import("../shared/io.zig");
|
||||
const shell_snapshot = @import("../terminal/shell_snapshot.zig");
|
||||
const auth_runtime = @import("../auth/auth_runtime.zig");
|
||||
const credentials = @import("../auth/credentials.zig");
|
||||
const gateway_provider = @import("../gateway/gateway_provider.zig");
|
||||
@@ -228,6 +229,19 @@ fn refreshWorkspaceAvailabilityForList(app: anytype) !void {
|
||||
}
|
||||
}
|
||||
|
||||
fn handleShellCommand(app: anytype, rest: []const u8) !void {
|
||||
if (!std.mem.eql(u8, std.mem.trim(u8, rest, " \t"), "reload")) {
|
||||
try app.writeDomainNotice(.{ .topic = "", .tone = .@"error", .body = "usage: /shell reload" }, true);
|
||||
return;
|
||||
}
|
||||
shell_snapshot.processOwner().markDirty(.user_reload);
|
||||
try app.writeDomainNotice(.{
|
||||
.topic = "shell",
|
||||
.tone = .neutral,
|
||||
.body = "The next command reloads your shell startup files. Remembered command approvals were reset.",
|
||||
}, true);
|
||||
}
|
||||
|
||||
fn handleWorkspaceCommand(app: anytype, rest: []const u8) !void {
|
||||
const maybe_action = parseWorkspaceCommand(rest) catch {
|
||||
try app.writeDomainNotice(.{
|
||||
@@ -392,6 +406,7 @@ pub fn Handlers(comptime App: type) type {
|
||||
.rename_session = commandRenameSession,
|
||||
.handle_notifications = commandHandleNotifications,
|
||||
.handle_workspace = commandHandleWorkspace,
|
||||
.handle_shell = commandHandleShell,
|
||||
.show_version = commandShowVersion,
|
||||
.unknown = commandUnknown,
|
||||
};
|
||||
@@ -404,6 +419,12 @@ pub fn Handlers(comptime App: type) type {
|
||||
try app.writeDomainNotice(.{ .topic = "mcp", .tone = .warning, .body = notice }, true);
|
||||
}
|
||||
|
||||
pub fn collectShellSnapshotFacts(app: *App) !void {
|
||||
var buffer: [shell_snapshot.max_notice_bytes]u8 = undefined;
|
||||
const notice = shell_snapshot.processOwner().takeUiNotice(&buffer) orelse return;
|
||||
try app.writeDomainNotice(.{ .topic = "shell", .tone = .warning, .body = notice }, true);
|
||||
}
|
||||
|
||||
pub fn collectMcpReloadFacts(app: *App) !void {
|
||||
if (comptime !@hasDecl(App, "takeMcpReloadCompletion")) return;
|
||||
var completion = (try app.takeMcpReloadCompletion()) orelse return;
|
||||
@@ -2090,6 +2111,11 @@ pub fn Handlers(comptime App: type) type {
|
||||
try handleNotificationsCommand(app, rest);
|
||||
}
|
||||
|
||||
fn commandHandleShell(ctx: *anyopaque, rest: []const u8) !void {
|
||||
const app: *App = @ptrCast(@alignCast(ctx));
|
||||
try handleShellCommand(app, rest);
|
||||
}
|
||||
|
||||
fn commandHandleWorkspace(ctx: *anyopaque, rest: []const u8) !void {
|
||||
const app: *App = @ptrCast(@alignCast(ctx));
|
||||
if (comptime !@hasDecl(App, "workspaceAccess")) {
|
||||
|
||||
@@ -32,6 +32,7 @@ const image_attachments = @import("../images/image_attachments.zig");
|
||||
const hooks = @import("../hooks/hooks.zig");
|
||||
const notification_sound = @import("../notifications/sound.zig");
|
||||
const io_mod = @import("../shared/io.zig");
|
||||
const shell_snapshot = @import("../terminal/shell_snapshot.zig");
|
||||
const session_title_generation = @import("../session/session_title_generation.zig");
|
||||
const compactor = @import("../compactor/compactor.zig");
|
||||
const config_runtime = @import("../config/config_runtime.zig");
|
||||
@@ -770,6 +771,9 @@ const AskContext = struct {
|
||||
}
|
||||
|
||||
fn deinit(self: *AskContext) void {
|
||||
// Terminals end with this process. Ending them first also releases
|
||||
// any subagent still waiting on one.
|
||||
self.terminal_client.closeOwnedTerminals();
|
||||
if (self.subagent_host) |subagent_host| subagent_host.deinit();
|
||||
self.subagent_host = null;
|
||||
if (self.v2_children) |children| {
|
||||
@@ -2239,7 +2243,17 @@ fn completeAskTitleTask(ctx: *AskContext, task: *session_title_generation.Task)
|
||||
}
|
||||
}
|
||||
|
||||
/// Reports a pending shell snapshot fallback once, on stderr.
|
||||
fn writeShellSnapshotNotice(ctx: *AskContext) void {
|
||||
var buffer: [shell_snapshot.max_notice_bytes]u8 = undefined;
|
||||
const notice = shell_snapshot.processOwner().takeUiNotice(&buffer) orelse return;
|
||||
ctx.writeStderr("fx ask: warning: ") catch return;
|
||||
ctx.writeStderr(notice) catch return;
|
||||
ctx.writeStderr("\n") catch return;
|
||||
}
|
||||
|
||||
fn takePromptRunResult(ctx: *AskContext, alloc: Allocator) !PromptRunResult {
|
||||
writeShellSnapshotNotice(ctx);
|
||||
const assistant_output = try alloc.dupe(u8, ctx.assistant_output.items);
|
||||
errdefer alloc.free(assistant_output);
|
||||
const final_output: []u8 = if (ctx.final_output.items.len > 0)
|
||||
@@ -3572,8 +3586,13 @@ fn pushToolLifecycle(raw_ctx: *anyopaque, event: types.ToolLifecycleEvent) !void
|
||||
try publishPendingToolProgress(ctx, progress.id.call_id, progress.text);
|
||||
},
|
||||
.turn_finished => if (!ctx.output_mode.isTerminal()) clearPendingToolProgress(ctx),
|
||||
.terminal => |value| if (!ctx.output_mode.isTerminal()) {
|
||||
try settlePendingToolProgress(ctx, value.id.call_id, value.outcome);
|
||||
.terminal => |value| {
|
||||
if (!ctx.output_mode.isTerminal()) {
|
||||
try settlePendingToolProgress(ctx, value.id.call_id, value.outcome);
|
||||
}
|
||||
// Report a fallback right after the command it affected; a later
|
||||
// recapture in the same run would otherwise clear it unseen.
|
||||
writeShellSnapshotNotice(ctx);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ const artifact_digest = @import("../session/artifact_digest.zig");
|
||||
const text_utils = @import("../shared/text_utils.zig");
|
||||
const types = @import("../shared/types.zig");
|
||||
const shell_resolver = @import("../terminal/shell_resolver.zig");
|
||||
const shell_snapshot = @import("../terminal/shell_snapshot.zig");
|
||||
const darwin_process_spawn = @import("../shared/darwin_process_spawn.zig");
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
@@ -36,6 +37,9 @@ pub const Config = struct {
|
||||
timeout_started_ms: ?i64 = null,
|
||||
command_artifact_capability: ?*session_child_store.SessionChildCapability = null,
|
||||
command_artifact_dir: ?[]const u8 = null,
|
||||
/// Shell snapshot epoch of the remembered grant that admitted a `user`
|
||||
/// profile command, or null when it was not admitted by a grant.
|
||||
shell_grant_epoch: ?u64 = null,
|
||||
};
|
||||
|
||||
pub const CallbackProjection = enum {
|
||||
@@ -228,13 +232,38 @@ pub fn runForegroundSessionBootstrap(args: []const [:0]const u8) !void {
|
||||
std.process.exit(foreground_session_replace_failure_exit_code);
|
||||
};
|
||||
target.stdin = null;
|
||||
target_input.writeStreamingAll(zio, script) catch |err| {
|
||||
var script_writer: ?std.Thread = null;
|
||||
if (script.len <= foreground_session_inline_script_bytes) {
|
||||
target_input.writeStreamingAll(zio, script) catch |err| {
|
||||
target_input.close(zio);
|
||||
target.kill(zio);
|
||||
writeForegroundSessionReplaceFailure(failure_nonce, err);
|
||||
std.process.exit(foreground_session_replace_failure_exit_code);
|
||||
};
|
||||
target_input.close(zio);
|
||||
target.kill(zio);
|
||||
writeForegroundSessionReplaceFailure(failure_nonce, err);
|
||||
std.process.exit(foreground_session_replace_failure_exit_code);
|
||||
};
|
||||
target_input.close(zio);
|
||||
} else {
|
||||
// A large script can exceed the pipe buffer, and on macOS the target
|
||||
// stays suspended until waitForForegroundTarget resumes it, so it is
|
||||
// written while the target runs. The target already exec'd with the
|
||||
// default SIGPIPE disposition; ignoring it here only keeps a target
|
||||
// that stops reading from killing the supervisor.
|
||||
const ignore_action: std.posix.Sigaction = .{
|
||||
.handler = .{ .handler = std.posix.SIG.IGN },
|
||||
.mask = std.posix.sigemptyset(),
|
||||
.flags = 0,
|
||||
};
|
||||
std.posix.sigaction(std.posix.SIG.PIPE, &ignore_action, null);
|
||||
script_writer = std.Thread.spawn(
|
||||
.{},
|
||||
writeForegroundTargetScript,
|
||||
.{ target_input, script },
|
||||
) catch |err| {
|
||||
target_input.close(zio);
|
||||
target.kill(zio);
|
||||
writeForegroundSessionReplaceFailure(failure_nonce, err);
|
||||
std.process.exit(foreground_session_replace_failure_exit_code);
|
||||
};
|
||||
}
|
||||
const term = waitForForegroundTarget(
|
||||
&target,
|
||||
if (process_witness) |*witness| witness else null,
|
||||
@@ -245,9 +274,23 @@ pub fn runForegroundSessionBootstrap(args: []const [:0]const u8) !void {
|
||||
writeForegroundSessionReplaceFailure(failure_nonce, err);
|
||||
std.process.exit(foreground_session_replace_failure_exit_code);
|
||||
};
|
||||
// The target has exited, so the writer finished or saw a broken pipe.
|
||||
if (script_writer) |writer| writer.join();
|
||||
exitForegroundSessionSupervisor(term);
|
||||
}
|
||||
|
||||
/// Scripts up to this size fit in any pipe buffer, so they are written before
|
||||
/// the target is resumed.
|
||||
const foreground_session_inline_script_bytes: usize = 4096;
|
||||
|
||||
fn writeForegroundTargetScript(target_input: std.Io.File, script: []const u8) void {
|
||||
const zio = io_mod.getIo();
|
||||
target_input.writeStreamingAll(zio, script) catch |err| {
|
||||
debug_trace.logf("core", "foreground session script write stopped err={s}", .{@errorName(err)});
|
||||
};
|
||||
target_input.close(zio);
|
||||
}
|
||||
|
||||
fn recordForegroundSessionTermination(signal: std.posix.SIG) callconv(.c) void {
|
||||
const request = @as(*volatile std.c.sig_atomic_t, &foreground_session_termination_request);
|
||||
request.* = @intFromEnum(mergeForegroundSessionTerminationRequest(
|
||||
@@ -683,6 +726,14 @@ pub fn executeCommandInEnvironment(
|
||||
var effective_cfg = cfg;
|
||||
if (effective_cfg.timeout_started_ms == null) effective_cfg.timeout_started_ms = io_mod.milliTimestamp();
|
||||
try ExecutionControl.init(effective_cfg).check();
|
||||
switch (environment) {
|
||||
.user => |shell_path| if (snapshotEligible(shell_path, command)) {
|
||||
if (try executeWithSnapshot(arena, scratch, effective_cfg, command, cwd, shell_path)) |result| {
|
||||
return result;
|
||||
}
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
const invocation = try shell_resolver.capturedInvocation(scratch, environment, command);
|
||||
debug_trace.logf(
|
||||
"core",
|
||||
@@ -699,6 +750,115 @@ pub fn executeCommandInEnvironment(
|
||||
);
|
||||
}
|
||||
|
||||
fn snapshotEligible(shell_path: []const u8, command: []const u8) bool {
|
||||
if (comptime !shell_snapshot.isSupported() or !supports_foreground_session) return false;
|
||||
// A NUL cannot travel through the stdin script; keep today's argv path,
|
||||
// which rejects it at spawn.
|
||||
return shell_resolver.shellKind(shell_path) != null and
|
||||
std.mem.findScalar(u8, command, 0) == null;
|
||||
}
|
||||
|
||||
/// The result for a command whose remembered approval predates a shell
|
||||
/// refresh. Nothing ran; requesting the command again asks for approval.
|
||||
fn approvalResetResult(
|
||||
arena: Allocator,
|
||||
command: []const u8,
|
||||
cwd: []const u8,
|
||||
) !command_contract.RunCommandResult {
|
||||
debug_trace.logf("core", "command runner rejected stale shell approval", .{});
|
||||
return formatOutputWithStatus(
|
||||
arena,
|
||||
command,
|
||||
cwd,
|
||||
.{ .exit_code = 126 },
|
||||
"",
|
||||
"fx did not run this command: the shell startup files changed after it was approved. Request it again to ask for approval.\n",
|
||||
null,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
/// Runs a `user` profile command in a clean shell restored from the process
|
||||
/// snapshot. Returns null when the command must use full startup instead:
|
||||
/// no snapshot is available, or its replay failed before the command started.
|
||||
fn executeWithSnapshot(
|
||||
arena: Allocator,
|
||||
scratch: Allocator,
|
||||
cfg: Config,
|
||||
command: []const u8,
|
||||
cwd: []const u8,
|
||||
shell_path: []const u8,
|
||||
) !?command_contract.RunCommandResult {
|
||||
const owner = shell_snapshot.processOwner();
|
||||
const acquired = owner.acquire(shell_path, cwd, .{
|
||||
.cancel_flag = cfg.cancel_flag,
|
||||
.deadline_ms = ExecutionControl.init(cfg).deadlineMs(),
|
||||
}) catch |err| return switch (err) {
|
||||
error.Cancelled => error.CancelledBeforeExecution,
|
||||
error.TimeoutExpired => error.TimeoutExpired,
|
||||
};
|
||||
const lease = switch (acquired) {
|
||||
.snapshot => |value| value,
|
||||
.full_startup => {
|
||||
if (cfg.shell_grant_epoch) |epoch| {
|
||||
if (epoch != owner.approvalEpoch()) return try approvalResetResult(arena, command, cwd);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
defer lease.release();
|
||||
const generation = lease.generation;
|
||||
if (cfg.shell_grant_epoch) |epoch| {
|
||||
if (epoch != generation.epoch) return try approvalResetResult(arena, command, cwd);
|
||||
}
|
||||
|
||||
var nonce_raw: [foreground_session_failure_nonce_bytes]u8 = undefined;
|
||||
io_mod.getIo().random(&nonce_raw);
|
||||
const nonce = std.fmt.bytesToHex(nonce_raw, .lower);
|
||||
const failure_marker = try std.mem.concat(scratch, u8, &.{
|
||||
shell_resolver.snapshot_replay_failure_prefix,
|
||||
&nonce,
|
||||
});
|
||||
const invocation = try shell_resolver.snapshotInvocation(scratch, generation.shell_path, failure_marker);
|
||||
const script = try shell_resolver.snapshotScript(scratch, generation.replay, command);
|
||||
debug_trace.logf(
|
||||
"core",
|
||||
"command runner snapshot generation={d} shell={s} replay_bytes={d}",
|
||||
.{ generation.id, generation.shell_path, generation.replay.len },
|
||||
);
|
||||
const result = try executeProcessWithScript(
|
||||
scratch,
|
||||
cfg,
|
||||
invocation.argv(),
|
||||
cwd,
|
||||
script,
|
||||
&generation.environ,
|
||||
);
|
||||
if (snapshotReplayFailed(result, failure_marker)) {
|
||||
// The replay stopped before the command started, so the command has
|
||||
// not run. Fail this snapshot and let the caller run it once with
|
||||
// full startup.
|
||||
debug_trace.logf(
|
||||
"core",
|
||||
"command runner snapshot replay failed generation={d}; rerunning with full startup",
|
||||
.{generation.id},
|
||||
);
|
||||
owner.markReplayFailed(generation);
|
||||
return null;
|
||||
}
|
||||
return try formatCollectedOutput(arena, command, cwd, result);
|
||||
}
|
||||
|
||||
fn snapshotReplayFailed(result: CollectedProcess, failure_marker: []const u8) bool {
|
||||
return switch (result.status) {
|
||||
.exit_code => |code| code == shell_snapshot_replay_failure_exit_code and
|
||||
std.mem.find(u8, result.stderr, failure_marker) != null,
|
||||
else => false,
|
||||
};
|
||||
}
|
||||
|
||||
const shell_snapshot_replay_failure_exit_code: i64 = 125;
|
||||
|
||||
const ExecutionControl = struct {
|
||||
cancel_flag: ?*std.atomic.Value(bool),
|
||||
timeout_ms: ?usize,
|
||||
@@ -1154,12 +1314,14 @@ fn executeProcessWithInput(
|
||||
);
|
||||
}
|
||||
|
||||
/// `environ_map` replaces the inherited environment for the command when set.
|
||||
fn executeProcessWithScript(
|
||||
scratch: Allocator,
|
||||
cfg: Config,
|
||||
argv: []const []const u8,
|
||||
cwd: []const u8,
|
||||
script: []const u8,
|
||||
environ_map: ?*const std.process.Environ.Map,
|
||||
) !CollectedProcess {
|
||||
if (comptime supports_foreground_session) {
|
||||
return executeProcessWithDetachedSession(
|
||||
@@ -1168,6 +1330,7 @@ fn executeProcessWithScript(
|
||||
argv,
|
||||
cwd,
|
||||
script,
|
||||
environ_map,
|
||||
);
|
||||
}
|
||||
return executeProcessWithScriptUnisolated(
|
||||
@@ -1176,6 +1339,7 @@ fn executeProcessWithScript(
|
||||
argv,
|
||||
cwd,
|
||||
script,
|
||||
environ_map,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1190,6 +1354,7 @@ fn executeProcessWithDetachedSession(
|
||||
argv: []const []const u8,
|
||||
cwd: []const u8,
|
||||
script: []const u8,
|
||||
environ_map: ?*const std.process.Environ.Map,
|
||||
) !CollectedProcess {
|
||||
const executable = try foregroundSessionExecutable(scratch);
|
||||
var nonce_bytes: [foreground_session_failure_nonce_bytes]u8 = undefined;
|
||||
@@ -1219,12 +1384,14 @@ fn executeProcessWithDetachedSession(
|
||||
try helper_argv.appendSlice(scratch, argv);
|
||||
|
||||
const started_ms = io_mod.milliTimestamp();
|
||||
// The supervisor passes its environment through to the target shell.
|
||||
var child = try std.process.spawn(io_mod.getIo(), .{
|
||||
.argv = helper_argv.items,
|
||||
.stdin = .pipe,
|
||||
.stdout = .pipe,
|
||||
.stderr = .pipe,
|
||||
.cwd = .{ .path = cwd },
|
||||
.environ_map = environ_map,
|
||||
});
|
||||
|
||||
var output = OutputCollector.init(scratch, cfg);
|
||||
@@ -1288,6 +1455,53 @@ fn executeProcessWithDetachedSession(
|
||||
);
|
||||
}
|
||||
|
||||
/// Starts `argv` in a new session through the foreground supervisor, so it
|
||||
/// has no controlling terminal: an interactive login shell can neither stop
|
||||
/// on nor draw over the terminal fx runs in. The target's stdin is empty;
|
||||
/// stdout and stderr are pipes. `child.stdin` stays open as the supervisor's
|
||||
/// lifeline: closing it ends the session. The caller owns the child and its
|
||||
/// process group.
|
||||
pub fn spawnDetachedSession(
|
||||
scratch: Allocator,
|
||||
argv: []const []const u8,
|
||||
cwd: []const u8,
|
||||
environ_map: ?*const std.process.Environ.Map,
|
||||
) !std.process.Child {
|
||||
if (comptime !supports_foreground_session) return error.OperationUnsupported;
|
||||
const executable = try foregroundSessionExecutable(scratch);
|
||||
var helper_argv: std.ArrayList([]const u8) = .empty;
|
||||
try helper_argv.appendSlice(scratch, &.{ executable, foreground_session_token, "none" });
|
||||
try helper_argv.appendSlice(scratch, argv);
|
||||
var child = try std.process.spawn(io_mod.getIo(), .{
|
||||
.argv = helper_argv.items,
|
||||
.stdin = .pipe,
|
||||
.stdout = .pipe,
|
||||
.stderr = .pipe,
|
||||
.cwd = .{ .path = cwd },
|
||||
.environ_map = environ_map,
|
||||
});
|
||||
var phase: ForegroundSessionPhase = .pre_ready;
|
||||
errdefer cleanupForegroundSessionChild(&child, phase);
|
||||
try waitForForegroundSessionReady(&child, .{ .max_command_output_bytes = 0 });
|
||||
phase = .group_ready;
|
||||
|
||||
const input = child.stdin orelse return error.SpawnFailed;
|
||||
var nonce_bytes: [foreground_session_failure_nonce_bytes]u8 = undefined;
|
||||
io_mod.getIo().random(&nonce_bytes);
|
||||
const nonce = std.fmt.bytesToHex(nonce_bytes, .lower);
|
||||
var control: [foreground_session_control_bytes]u8 = undefined;
|
||||
@memcpy(control[0..nonce.len], &nonce);
|
||||
control[foreground_session_release_index] = foreground_session_release_byte;
|
||||
std.mem.writeInt(
|
||||
u64,
|
||||
control[foreground_session_script_length_index..][0..foreground_session_script_length_bytes],
|
||||
0,
|
||||
.little,
|
||||
);
|
||||
try input.writeStreamingAll(io_mod.getIo(), &control);
|
||||
return child;
|
||||
}
|
||||
|
||||
fn foregroundSessionExecutable(scratch: Allocator) ![]const u8 {
|
||||
if (comptime builtin.is_test) {
|
||||
const path_z = std.c.getenv("FX_TEST_PRODUCT_EXE") orelse
|
||||
@@ -1389,6 +1603,7 @@ fn executeProcessWithScriptUnisolated(
|
||||
argv: []const []const u8,
|
||||
cwd: []const u8,
|
||||
script: []const u8,
|
||||
environ_map: ?*const std.process.Environ.Map,
|
||||
) !CollectedProcess {
|
||||
const started_ms = io_mod.milliTimestamp();
|
||||
var child = try std.process.spawn(io_mod.getIo(), .{
|
||||
@@ -1398,6 +1613,7 @@ fn executeProcessWithScriptUnisolated(
|
||||
.stderr = .pipe,
|
||||
.cwd = .{ .path = cwd },
|
||||
.pgid = if (builtin.os.tag != .windows and builtin.os.tag != .wasi) 0 else null,
|
||||
.environ_map = environ_map,
|
||||
});
|
||||
|
||||
var output = OutputCollector.init(scratch, cfg);
|
||||
@@ -1647,6 +1863,7 @@ fn executeRawBashWithResultCommand(
|
||||
&argv,
|
||||
cwd,
|
||||
execution_command,
|
||||
null,
|
||||
);
|
||||
return formatCollectedOutput(alloc, result_command, cwd, result);
|
||||
}
|
||||
@@ -1668,6 +1885,7 @@ fn executeRawInvocation(
|
||||
invocation.argv(),
|
||||
cwd,
|
||||
"",
|
||||
null,
|
||||
);
|
||||
return formatCollectedOutput(alloc, command, cwd, result);
|
||||
}
|
||||
@@ -1790,9 +2008,13 @@ test "zsh user profile reports natural SIGTERM after alias-safe startup" {
|
||||
try std.testing.expectEqual(@as(?i64, null), foreground.exit_code);
|
||||
try std.testing.expectEqual(@as(?u32, @intFromEnum(std.posix.SIG.TERM)), foreground.signal);
|
||||
|
||||
const debug_output = try readAbsoluteFile(arena, debug_log, 4096);
|
||||
try std.testing.expect(std.mem.find(u8, debug_output, "builtin trap - TERM") != null);
|
||||
// The startup files ran once into the snapshot. The aliased `builtin`
|
||||
// could not intercept the alias-safe `\builtin eval` that runs the
|
||||
// command, and the replayed TRAPDEBUG still observed it.
|
||||
const debug_output = try readAbsoluteFile(arena, debug_log, 64 * 1024);
|
||||
try std.testing.expect(std.mem.find(u8, debug_output, "builtin eval") != null);
|
||||
try std.testing.expect(std.mem.find(u8, debug_output, "kill -TERM $$") != null);
|
||||
try std.testing.expect(std.mem.find(u8, signaled.output, "INTERCEPTED") == null);
|
||||
|
||||
const trapped = try executeCommandInEnvironment(
|
||||
config,
|
||||
@@ -1805,6 +2027,256 @@ test "zsh user profile reports natural SIGTERM after alias-safe startup" {
|
||||
try std.testing.expectEqual(@as(?u32, null), trapped.command_result.?.signal);
|
||||
}
|
||||
|
||||
const SnapshotTestShell = struct { home: []const u8, workspace: []const u8, shell: []const u8 };
|
||||
|
||||
fn writeSnapshotTestShell(
|
||||
arena: Allocator,
|
||||
tmp: *std.testing.TmpDir,
|
||||
zshrc: []const u8,
|
||||
) !SnapshotTestShell {
|
||||
return writeSnapshotTestShellFor(arena, tmp, "zsh", ".zshrc", zshrc);
|
||||
}
|
||||
|
||||
/// Writes a wrapper named after the shell that pins HOME (and ZDOTDIR) to a
|
||||
/// temporary home holding `rc_name`.
|
||||
fn writeSnapshotTestShellFor(
|
||||
arena: Allocator,
|
||||
tmp: *std.testing.TmpDir,
|
||||
shell_name: []const u8,
|
||||
rc_name: []const u8,
|
||||
rc: []const u8,
|
||||
) !SnapshotTestShell {
|
||||
try tmp.dir.createDirPath(io_mod.getIo(), "home");
|
||||
try tmp.dir.createDirPath(io_mod.getIo(), "workspace");
|
||||
try tmp.dir.createDirPath(io_mod.getIo(), "wrapper");
|
||||
const home = try io_mod.dirRealpathAlloc(arena, tmp.dir, "home");
|
||||
const workspace = try io_mod.dirRealpathAlloc(arena, tmp.dir, "workspace");
|
||||
const wrapper_dir = try io_mod.dirRealpathAlloc(arena, tmp.dir, "wrapper");
|
||||
const rc_path = try std.fs.path.join(arena, &.{ "home", rc_name });
|
||||
try tmp.dir.writeFile(io_mod.getIo(), .{ .sub_path = rc_path, .data = rc });
|
||||
const source = try std.fmt.allocPrint(
|
||||
arena,
|
||||
"#!/bin/sh\nexport HOME={s}\nexport ZDOTDIR={s}\nexec /bin/{s} \"$@\"\n",
|
||||
.{ try shellQuote(arena, home), try shellQuote(arena, home), shell_name },
|
||||
);
|
||||
const wrapper_path = try std.fs.path.join(arena, &.{ "wrapper", shell_name });
|
||||
var wrapper = try tmp.dir.createFile(io_mod.getIo(), wrapper_path, .{ .truncate = true });
|
||||
defer wrapper.close(io_mod.getIo());
|
||||
try wrapper.writeStreamingAll(io_mod.getIo(), source);
|
||||
try wrapper.setPermissions(io_mod.getIo(), std.Io.File.Permissions.fromMode(0o700));
|
||||
return .{
|
||||
.home = home,
|
||||
.workspace = workspace,
|
||||
.shell = try std.fs.path.join(arena, &.{ wrapper_dir, shell_name }),
|
||||
};
|
||||
}
|
||||
|
||||
test "bash user profile commands reuse one startup-file snapshot" {
|
||||
if (comptime !shell_snapshot.isSupported() or !supports_foreground_session) return;
|
||||
std.Io.Dir.accessAbsolute(io_mod.getIo(), "/bin/bash", .{}) catch
|
||||
return error.SkipZigTest;
|
||||
|
||||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
const paths = try writeSnapshotTestShellFor(
|
||||
arena,
|
||||
&tmp,
|
||||
"bash",
|
||||
".bash_profile",
|
||||
"printf 'loaded\\n' >> \"$HOME/rc-loads.log\"\n" ++
|
||||
"alias fxsnap_alias='printf alias-ok'\n" ++
|
||||
"fxsnap_fn() { printf function-ok; }\n" ++
|
||||
"export PATH=\"$HOME/marker-bin:$PATH\"\n" ++
|
||||
"export FXSNAP_EXPORT=exported\n",
|
||||
);
|
||||
|
||||
const config = Config{ .max_command_output_bytes = 64 * 1024 };
|
||||
const first = try executeCommandInEnvironment(
|
||||
config,
|
||||
arena,
|
||||
"fxsnap_alias; echo; fxsnap_fn; echo; echo $FXSNAP_EXPORT; " ++
|
||||
"case :$PATH: in *:$HOME/marker-bin:*) echo path-ok;; esac; cat; echo stdin-closed",
|
||||
paths.workspace,
|
||||
.{ .user = paths.shell },
|
||||
);
|
||||
inline for (.{ "alias-ok", "function-ok", "exported", "path-ok", "stdin-closed" }) |expected| {
|
||||
try std.testing.expect(std.mem.find(u8, first.output, expected) != null);
|
||||
}
|
||||
try std.testing.expect(std.mem.startsWith(u8, first.output, "exit_code=0\n"));
|
||||
|
||||
_ = try executeCommandInEnvironment(config, arena, "cd /; export FXSNAP_LEAK=1", paths.workspace, .{ .user = paths.shell });
|
||||
const third = try executeCommandInEnvironment(
|
||||
config,
|
||||
arena,
|
||||
"echo \"pwd=$PWD leak=${FXSNAP_LEAK-unset}\"; exit 7",
|
||||
paths.workspace,
|
||||
.{ .user = paths.shell },
|
||||
);
|
||||
const expected_pwd = try std.fmt.allocPrint(arena, "pwd={s} leak=unset", .{paths.workspace});
|
||||
try std.testing.expect(std.mem.find(u8, third.output, expected_pwd) != null);
|
||||
try std.testing.expectEqual(@as(?i64, 7), third.command_result.?.exit_code);
|
||||
|
||||
const loads_path = try std.fs.path.join(arena, &.{ paths.home, "rc-loads.log" });
|
||||
try std.testing.expectEqualStrings("loaded\n", try readAbsoluteFile(arena, loads_path, 4096));
|
||||
}
|
||||
|
||||
test "user profile commands reuse one startup-file snapshot" {
|
||||
if (comptime !shell_snapshot.isSupported() or !supports_foreground_session) return;
|
||||
std.Io.Dir.accessAbsolute(io_mod.getIo(), "/bin/zsh", .{}) catch
|
||||
return error.SkipZigTest;
|
||||
|
||||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
// Thousands of functions push the replay past the pipe buffer, so the
|
||||
// supervisor must stream it while the shell reads.
|
||||
const paths = try writeSnapshotTestShell(
|
||||
arena,
|
||||
&tmp,
|
||||
"print -r -- loaded >> \"$HOME/rc-loads.log\"\n" ++
|
||||
"alias fxsnap_alias='print -r -- alias-ok'\n" ++
|
||||
"fxsnap_fn() { print -r -- function-ok; }\n" ++
|
||||
"export PATH=\"$HOME/marker-bin:$PATH\"\n" ++
|
||||
"export FXSNAP_EXPORT=exported\n" ++
|
||||
"for i in {1..3000}; do eval \"fxsnap_bulk_$i() { print -r -- bulk-$i; }\"; done\n",
|
||||
);
|
||||
|
||||
const config = Config{ .max_command_output_bytes = 64 * 1024 };
|
||||
const first = try executeCommandInEnvironment(
|
||||
config,
|
||||
arena,
|
||||
"fxsnap_alias; fxsnap_fn; print -r -- $FXSNAP_EXPORT; " ++
|
||||
"case :$PATH: in *:$HOME/marker-bin:*) print -r -- path-ok;; esac; " ++
|
||||
"fxsnap_bulk_2999; cat; print -r -- stdin-closed",
|
||||
paths.workspace,
|
||||
.{ .user = paths.shell },
|
||||
);
|
||||
inline for (.{ "alias-ok", "function-ok", "exported", "path-ok", "bulk-2999", "stdin-closed" }) |expected| {
|
||||
try std.testing.expect(std.mem.find(u8, first.output, expected) != null);
|
||||
}
|
||||
try std.testing.expect(std.mem.startsWith(u8, first.output, "exit_code=0\n"));
|
||||
|
||||
_ = try executeCommandInEnvironment(
|
||||
config,
|
||||
arena,
|
||||
"cd /; export FXSNAP_LEAK=1; alias fxsnap_new='print leaked'",
|
||||
paths.workspace,
|
||||
.{ .user = paths.shell },
|
||||
);
|
||||
const third = try executeCommandInEnvironment(
|
||||
config,
|
||||
arena,
|
||||
"print -r -- \"pwd=$PWD leak=${FXSNAP_LEAK-unset}\"; " ++
|
||||
"alias fxsnap_new >/dev/null 2>&1 || print -r -- alias-unset; exit 7",
|
||||
paths.workspace,
|
||||
.{ .user = paths.shell },
|
||||
);
|
||||
const expected_pwd = try std.fmt.allocPrint(arena, "pwd={s} leak=unset", .{paths.workspace});
|
||||
try std.testing.expect(std.mem.find(u8, third.output, expected_pwd) != null);
|
||||
try std.testing.expect(std.mem.find(u8, third.output, "alias-unset") != null);
|
||||
try std.testing.expectEqual(@as(?i64, 7), third.command_result.?.exit_code);
|
||||
|
||||
const loads_path = try std.fs.path.join(arena, &.{ paths.home, "rc-loads.log" });
|
||||
const loads = try readAbsoluteFile(arena, loads_path, 4096);
|
||||
try std.testing.expectEqualStrings("loaded\n", loads);
|
||||
}
|
||||
|
||||
test "user profile command with an approval from an earlier shell epoch does not run" {
|
||||
if (comptime !shell_snapshot.isSupported() or !supports_foreground_session) return;
|
||||
std.Io.Dir.accessAbsolute(io_mod.getIo(), "/bin/zsh", .{}) catch
|
||||
return error.SkipZigTest;
|
||||
shell_snapshot.resetProcessOwnerForTest(shell_snapshot.real_capture_fn);
|
||||
defer shell_snapshot.resetProcessOwnerForTest(shell_snapshot.real_capture_fn);
|
||||
|
||||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
const paths = try writeSnapshotTestShell(arena, &tmp, "alias fxsnap_alias='print -r -- alias-ok'\n");
|
||||
const marker_path = try std.fs.path.join(arena, &.{ paths.workspace, "ran.log" });
|
||||
const command = try std.fmt.allocPrint(arena, "print -r -- ran >> {s}", .{try shellQuote(arena, marker_path)});
|
||||
|
||||
// The grant was checked under epoch 0; a refresh moved the shell to 1.
|
||||
shell_snapshot.processOwner().markDirty(.user_reload);
|
||||
const stale = try executeCommandInEnvironment(
|
||||
.{ .max_command_output_bytes = 16 * 1024, .shell_grant_epoch = 0 },
|
||||
arena,
|
||||
command,
|
||||
paths.workspace,
|
||||
.{ .user = paths.shell },
|
||||
);
|
||||
try std.testing.expectEqual(@as(?i64, 126), stale.command_result.?.exit_code);
|
||||
try std.testing.expect(std.mem.find(u8, stale.output, "changed after it was approved") != null);
|
||||
std.Io.Dir.accessAbsolute(io_mod.getIo(), marker_path, .{}) catch |err| {
|
||||
try std.testing.expectEqual(error.FileNotFound, err);
|
||||
};
|
||||
|
||||
const current = try executeCommandInEnvironment(
|
||||
.{ .max_command_output_bytes = 16 * 1024, .shell_grant_epoch = shell_snapshot.processOwner().approvalEpoch() },
|
||||
arena,
|
||||
command,
|
||||
paths.workspace,
|
||||
.{ .user = paths.shell },
|
||||
);
|
||||
try std.testing.expectEqual(@as(?i64, 0), current.command_result.?.exit_code);
|
||||
try std.testing.expectEqualStrings("ran\n", try readAbsoluteFile(arena, marker_path, 4096));
|
||||
}
|
||||
|
||||
fn brokenReplayCapture(request: shell_snapshot.CaptureRequest) shell_snapshot.CaptureOutcome {
|
||||
const generation = shell_snapshot.Generation.create() catch return .{ .failed = .out_of_memory };
|
||||
const alloc = generation.arena.allocator();
|
||||
generation.shell_path = alloc.dupe(u8, request.shell_path) catch {
|
||||
generation.destroy();
|
||||
return .{ .failed = .out_of_memory };
|
||||
};
|
||||
generation.replay = "fxsnap_broken() {\n";
|
||||
generation.environ.put("PATH", "/usr/bin:/bin") catch {};
|
||||
return .{ .ready = generation };
|
||||
}
|
||||
|
||||
test "user profile command reruns once with full startup after a failed replay" {
|
||||
if (comptime !shell_snapshot.isSupported() or !supports_foreground_session) return;
|
||||
std.Io.Dir.accessAbsolute(io_mod.getIo(), "/bin/zsh", .{}) catch
|
||||
return error.SkipZigTest;
|
||||
shell_snapshot.resetProcessOwnerForTest(brokenReplayCapture);
|
||||
defer shell_snapshot.resetProcessOwnerForTest(shell_snapshot.real_capture_fn);
|
||||
|
||||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
const paths = try writeSnapshotTestShell(
|
||||
arena,
|
||||
&tmp,
|
||||
"print -r -- loaded >> \"$HOME/rc-loads.log\"\n",
|
||||
);
|
||||
const marker_path = try std.fs.path.join(arena, &.{ paths.workspace, "runs.log" });
|
||||
const command = try std.fmt.allocPrint(arena, "print -r -- ran >> {s}; print -r -- done", .{try shellQuote(arena, marker_path)});
|
||||
const config = Config{ .max_command_output_bytes = 16 * 1024 };
|
||||
|
||||
const result = try executeCommandInEnvironment(config, arena, command, paths.workspace, .{ .user = paths.shell });
|
||||
try std.testing.expect(std.mem.startsWith(u8, result.output, "exit_code=0\n"));
|
||||
try std.testing.expect(std.mem.find(u8, result.output, "done") != null);
|
||||
try std.testing.expect(std.mem.find(u8, result.output, shell_resolver.snapshot_replay_failure_prefix) == null);
|
||||
try std.testing.expectEqualStrings("ran\n", try readAbsoluteFile(arena, marker_path, 4096));
|
||||
|
||||
// Later commands fall back to full startup without retrying the replay.
|
||||
_ = try executeCommandInEnvironment(config, arena, command, paths.workspace, .{ .user = paths.shell });
|
||||
try std.testing.expectEqualStrings("ran\nran\n", try readAbsoluteFile(arena, marker_path, 4096));
|
||||
const loads_path = try std.fs.path.join(arena, &.{ paths.home, "rc-loads.log" });
|
||||
try std.testing.expectEqualStrings("loaded\nloaded\n", try readAbsoluteFile(arena, loads_path, 4096));
|
||||
var notice_buffer: [256]u8 = undefined;
|
||||
const notice = shell_snapshot.processOwner().takeUiNotice(¬ice_buffer).?;
|
||||
try std.testing.expect(std.mem.find(u8, notice, "could not be restored") != null);
|
||||
}
|
||||
|
||||
fn formatOutput(alloc: Allocator, command: []const u8, cwd: []const u8, term: std.process.Child.Term, stdout_raw: []const u8, stderr_raw: []const u8, duration_ms: ?u64) !command_contract.RunCommandResult {
|
||||
return formatOutputWithStatus(
|
||||
alloc,
|
||||
@@ -3533,6 +4005,7 @@ test "detached session preserves replacement failure with a zero output budget"
|
||||
&argv,
|
||||
"/tmp",
|
||||
"",
|
||||
null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ pub const PreparedCommand = union(enum) {
|
||||
command_ctx: command_admission.CommandContext,
|
||||
reason: command_effect.ApprovalReason,
|
||||
source: command_admission.ShellAuthorizationSource,
|
||||
grant_epoch: ?u64 = null,
|
||||
},
|
||||
|
||||
pub fn deinit(self: *PreparedCommand, alloc: std.mem.Allocator) void {
|
||||
@@ -48,6 +49,12 @@ pub fn foregroundResultComparisonLimit(
|
||||
};
|
||||
}
|
||||
|
||||
fn shellConfig(cfg: command_runner.Config, grant_epoch: ?u64) command_runner.Config {
|
||||
var bound = cfg;
|
||||
bound.shell_grant_epoch = grant_epoch;
|
||||
return bound;
|
||||
}
|
||||
|
||||
/// Executes a prepared foreground command without taking ownership of it.
|
||||
pub fn executePreparedCommand(
|
||||
cfg: command_runner.Config,
|
||||
@@ -62,7 +69,7 @@ pub fn executePreparedCommand(
|
||||
.approved_shell => |shell| .{
|
||||
.route = .approved_shell,
|
||||
.result = try command_runner.executeCommandInEnvironment(
|
||||
cfg,
|
||||
shellConfig(cfg, shell.grant_epoch),
|
||||
alloc,
|
||||
shell.command_ctx.command,
|
||||
shell.command_ctx.resolved_cwd,
|
||||
|
||||
@@ -1525,6 +1525,7 @@ fn rebindAuthority(
|
||||
.shell_allowed => |shell| .{ .shell_allowed = .{
|
||||
.fingerprint = .init(command_ctx),
|
||||
.source = shell.source,
|
||||
.grant_epoch = shell.grant_epoch,
|
||||
} },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ pub fn prepareAuthorizedRoute(
|
||||
.command_ctx = command_ctx,
|
||||
.reason = .dynamic_shell,
|
||||
.source = shell.source,
|
||||
.grant_epoch = shell.grant_epoch,
|
||||
} };
|
||||
}
|
||||
const admission = command_effect.plan(
|
||||
@@ -77,6 +78,7 @@ pub fn prepareAuthorizedRoute(
|
||||
.command_ctx = command_ctx,
|
||||
.reason = .planning_failure,
|
||||
.source = shell.source,
|
||||
.grant_epoch = shell.grant_epoch,
|
||||
} };
|
||||
switch (admission) {
|
||||
.direct_read_only => |plan| {
|
||||
@@ -89,6 +91,7 @@ pub fn prepareAuthorizedRoute(
|
||||
.command_ctx = command_ctx,
|
||||
.reason = reason,
|
||||
.source = shell.source,
|
||||
.grant_epoch = shell.grant_epoch,
|
||||
} };
|
||||
},
|
||||
}
|
||||
|
||||
@@ -71,6 +71,9 @@ pub const CommandExecutionAuthority = union(enum) {
|
||||
shell_allowed: struct {
|
||||
fingerprint: AdmissionFingerprint,
|
||||
source: ShellAuthorizationSource,
|
||||
/// Shell snapshot epoch of the remembered grant that admitted the
|
||||
/// command. Execution under a later epoch is asked again.
|
||||
grant_epoch: ?u64 = null,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const std = @import("std");
|
||||
const shell_snapshot = @import("../terminal/shell_snapshot.zig");
|
||||
const command_environment = @import("../execution/command_environment.zig");
|
||||
|
||||
const io_mod = @import("../shared/io.zig");
|
||||
@@ -71,10 +72,12 @@ pub const PermissionEngine = struct {
|
||||
}
|
||||
|
||||
pub fn formatPermissionsNoticeBody(self: PermissionEngine, alloc: std.mem.Allocator, workspace_root: []const u8) ![]u8 {
|
||||
const live = try liveSessionGrants(alloc, self.grants.items);
|
||||
defer alloc.free(live);
|
||||
return (output_contracts.PermissionsSnapshot{
|
||||
.workspace_root = workspace_root,
|
||||
.mode = self.mode,
|
||||
.grants = self.grants.items,
|
||||
.grants = live,
|
||||
.rules = self.rules,
|
||||
}).renderInteractiveBody(alloc);
|
||||
}
|
||||
@@ -1181,6 +1184,8 @@ pub fn sessionGrantAllowed(grants: []const types.PermissionGrant, tool_name: []c
|
||||
if (!permissionPatternMatchesTool(grant.tool_name, permission, tool_name)) continue;
|
||||
if (std.mem.eql(u8, permission, "bash")) {
|
||||
if (!std.mem.eql(u8, grant.target_path, pattern)) continue;
|
||||
// A shell refresh resets remembered command approvals.
|
||||
if (!shell_snapshot.processOwner().shellGrantCurrent(grant.target_path)) continue;
|
||||
} else if (!permissionPatternMatchesTarget(grant.target_path, pattern)) continue;
|
||||
return true;
|
||||
}
|
||||
@@ -1411,14 +1416,42 @@ pub fn formatPermissionsStatus(
|
||||
grants: []const types.PermissionGrant,
|
||||
rules: types.PermissionRuleSet,
|
||||
) ![]u8 {
|
||||
const live = try liveSessionGrants(alloc, grants);
|
||||
defer alloc.free(live);
|
||||
return (output_contracts.PermissionsSnapshot{
|
||||
.workspace_root = workspace_root,
|
||||
.mode = mode,
|
||||
.grants = grants,
|
||||
.grants = live,
|
||||
.rules = rules,
|
||||
}).renderText(alloc);
|
||||
}
|
||||
|
||||
/// Reports whether a remembered session grant still applies. A shell refresh
|
||||
/// retires the shell command grants made before it.
|
||||
pub fn sessionGrantIsLive(grant: types.PermissionGrant) bool {
|
||||
if (!std.mem.eql(u8, permissionNameForTool(grant.tool_name), "bash")) return true;
|
||||
return shell_snapshot.processOwner().shellGrantCurrent(grant.target_path);
|
||||
}
|
||||
|
||||
pub fn liveSessionGrantCount(grants: []const types.PermissionGrant) usize {
|
||||
var count: usize = 0;
|
||||
for (grants) |grant| {
|
||||
if (sessionGrantIsLive(grant)) count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/// Copies the grants that still apply, for display. Caller frees the slice;
|
||||
/// the grant strings stay borrowed.
|
||||
fn liveSessionGrants(alloc: std.mem.Allocator, grants: []const types.PermissionGrant) ![]types.PermissionGrant {
|
||||
var live: std.ArrayList(types.PermissionGrant) = .empty;
|
||||
errdefer live.deinit(alloc);
|
||||
for (grants) |grant| {
|
||||
if (sessionGrantIsLive(grant)) try live.append(alloc, grant);
|
||||
}
|
||||
return live.toOwnedSlice(alloc);
|
||||
}
|
||||
|
||||
pub fn permissionNameForTool(tool_name: []const u8) []const u8 {
|
||||
if (std.mem.eql(u8, tool_name, "read_file")) return "read";
|
||||
if (std.mem.eql(u8, tool_name, "write_file") or std.mem.eql(u8, tool_name, "edit_file")) return "edit";
|
||||
@@ -3320,3 +3353,30 @@ test "file target evaluator propagates only operational failures" {
|
||||
);
|
||||
try std.testing.expect(failing.has_induced_failure);
|
||||
}
|
||||
|
||||
test "a shell refresh resets remembered command grants only" {
|
||||
shell_snapshot.resetProcessOwnerForTest(shell_snapshot.real_capture_fn);
|
||||
defer shell_snapshot.resetProcessOwnerForTest(shell_snapshot.real_capture_fn);
|
||||
const command = "@fx-terminal-env:user:8:/bin/zsh::npm test";
|
||||
const grants = [_]types.PermissionGrant{
|
||||
.{ .tool_name = @constCast("bash"), .target_path = @constCast(command) },
|
||||
.{ .tool_name = @constCast("edit"), .target_path = @constCast("/tmp/workspace/**") },
|
||||
};
|
||||
try std.testing.expect(sessionGrantAllowed(&grants, "run_command", command));
|
||||
try std.testing.expect(sessionGrantAllowed(&grants, "edit_file", "/tmp/workspace/a.zig"));
|
||||
|
||||
shell_snapshot.processOwner().markDirty(.user_reload);
|
||||
try std.testing.expect(!sessionGrantAllowed(&grants, "run_command", command));
|
||||
try std.testing.expect(sessionGrantAllowed(&grants, "edit_file", "/tmp/workspace/a.zig"));
|
||||
|
||||
// Listings and counts show only the grants that still apply.
|
||||
try std.testing.expectEqual(@as(usize, 1), liveSessionGrantCount(&grants));
|
||||
const listing = try formatPermissionsStatus(std.testing.allocator, "/tmp/workspace", .ask, &grants, .{});
|
||||
defer std.testing.allocator.free(listing);
|
||||
try std.testing.expect(std.mem.find(u8, listing, " - bash -> ") == null);
|
||||
try std.testing.expect(std.mem.find(u8, listing, " - edit -> ") != null);
|
||||
|
||||
shell_snapshot.processOwner().recordShellGrant(command);
|
||||
try std.testing.expect(sessionGrantAllowed(&grants, "run_command", command));
|
||||
try std.testing.expectEqual(@as(usize, 2), liveSessionGrantCount(&grants));
|
||||
}
|
||||
@@ -302,7 +302,7 @@ pub fn Commands(comptime App: type) type {
|
||||
.permission_mode = app.permission_engine.mode,
|
||||
.workspace_root = app.workspace_root,
|
||||
.history_turns = app.session.historyLen(),
|
||||
.session_permission_grants = app.permission_engine.grants.items.len,
|
||||
.session_permission_grants = permissions.liveSessionGrantCount(app.permission_engine.grants.items),
|
||||
.agent_step_limit = app.agent_step_limit,
|
||||
.ultrafast_requested = app.worker.agent_turn_settings.ultrafast_mode,
|
||||
}).renderInteractiveBody(app.alloc);
|
||||
|
||||
@@ -39,6 +39,7 @@ pub const ParsedCommand = union(enum) {
|
||||
statusline: []const u8,
|
||||
notifications: []const u8,
|
||||
workspace: []const u8,
|
||||
shell: []const u8,
|
||||
version,
|
||||
unknown,
|
||||
};
|
||||
@@ -79,6 +80,7 @@ pub const CommandHandlers = struct {
|
||||
rename_session: *const fn (ctx: *anyopaque, rest: []const u8) anyerror!void,
|
||||
handle_notifications: *const fn (ctx: *anyopaque, rest: []const u8) anyerror!void,
|
||||
handle_workspace: *const fn (ctx: *anyopaque, rest: []const u8) anyerror!void,
|
||||
handle_shell: *const fn (ctx: *anyopaque, rest: []const u8) anyerror!void,
|
||||
show_version: *const fn (ctx: *anyopaque) anyerror!void,
|
||||
unknown: *const fn (ctx: *anyopaque, cmd: []const u8) anyerror!void,
|
||||
};
|
||||
@@ -123,6 +125,7 @@ fn parsedCommand(kind: SlashKind, payload: []const u8) ParsedCommand {
|
||||
.statusline => .{ .statusline = payload },
|
||||
.notifications => .{ .notifications = payload },
|
||||
.workspace => .{ .workspace = payload },
|
||||
.shell => .{ .shell = payload },
|
||||
.version => .version,
|
||||
};
|
||||
}
|
||||
@@ -176,6 +179,7 @@ pub fn route(registry: SlashRegistry, handlers: *const CommandHandlers, cmd: []c
|
||||
.statusline => |rest| try handlers.handle_statusline(handlers.ctx, rest),
|
||||
.notifications => |rest| try handlers.handle_notifications(handlers.ctx, rest),
|
||||
.workspace => |rest| try handlers.handle_workspace(handlers.ctx, rest),
|
||||
.shell => |rest| try handlers.handle_shell(handlers.ctx, rest),
|
||||
.version => try handlers.show_version(handlers.ctx),
|
||||
.unknown => try handlers.unknown(handlers.ctx, cmd),
|
||||
}
|
||||
@@ -416,6 +420,11 @@ fn unexpectedPayload(ctx: *anyopaque, value: []const u8) anyerror!void {
|
||||
return error.UnexpectedCallback;
|
||||
}
|
||||
|
||||
fn recordShell(ctx: *anyopaque, value: []const u8) anyerror!void {
|
||||
testContext(ctx).called = "shell";
|
||||
testContext(ctx).payload = value;
|
||||
}
|
||||
|
||||
fn recordCopy(ctx: *anyopaque) anyerror!void {
|
||||
testContext(ctx).called = "copy";
|
||||
}
|
||||
@@ -496,11 +505,23 @@ fn testHandlers(ctx: *TestContext) CommandHandlers {
|
||||
.rename_session = unexpectedPayload,
|
||||
.handle_notifications = unexpectedPayload,
|
||||
.handle_workspace = unexpectedPayload,
|
||||
.handle_shell = unexpectedPayload,
|
||||
.show_version = unexpectedNoPayload,
|
||||
.unknown = unexpectedPayload,
|
||||
};
|
||||
}
|
||||
|
||||
test "route passes shell payload" {
|
||||
var ctx: TestContext = .{};
|
||||
var handlers = testHandlers(&ctx);
|
||||
handlers.handle_shell = recordShell;
|
||||
|
||||
try route(testSlashRegistry(), &handlers, "/shell reload");
|
||||
|
||||
try std.testing.expectEqualStrings("shell", ctx.called);
|
||||
try std.testing.expectEqualStrings("reload", ctx.payload);
|
||||
}
|
||||
|
||||
test "route calls expected no-payload handler" {
|
||||
var ctx: TestContext = .{};
|
||||
var handlers = testHandlers(&ctx);
|
||||
|
||||
@@ -70,6 +70,7 @@ pub const SlashKind = enum {
|
||||
statusline,
|
||||
notifications,
|
||||
workspace,
|
||||
shell,
|
||||
version,
|
||||
};
|
||||
|
||||
@@ -1767,7 +1768,7 @@ test "slash completion categories follow canonical entries" {
|
||||
test "help catalog groups visible commands and searches all command metadata" {
|
||||
const registry = testSlashRegistry();
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 35), helpCatalogCount(registry, ""));
|
||||
try std.testing.expectEqual(@as(usize, 36), helpCatalogCount(registry, ""));
|
||||
try std.testing.expectEqualStrings("/help", helpCatalogSpecAt(registry, "", 0).?.command);
|
||||
try std.testing.expectEqual(@as(usize, 5), helpCatalogCategoryCount(registry, "", .general));
|
||||
try std.testing.expectEqual(@as(usize, 3), helpCatalogCount(registry, "appearance"));
|
||||
|
||||
@@ -42,28 +42,17 @@ pub fn execute(
|
||||
if (ctx.runtime.takeCompletionFor(correlation_id)) |completion_value| {
|
||||
var completion = completion_value;
|
||||
defer completion.deinit();
|
||||
if (completion.frame) |*frame| {
|
||||
return switch (frame.message().payload) {
|
||||
.response => |response| contracts.OwnedResult.init(
|
||||
ctx.alloc,
|
||||
response,
|
||||
),
|
||||
else => failure(ctx, request, .protocol_incompatible, false),
|
||||
};
|
||||
if (completion.response) |*response| {
|
||||
return contracts.OwnedResult.init(ctx.alloc, response.value.view());
|
||||
}
|
||||
return failure(
|
||||
ctx,
|
||||
request,
|
||||
switch (completion.kind) {
|
||||
.cancelled => .cancelled,
|
||||
.unavailable => if (completion.is_missing_capability(
|
||||
contracts.protocol_capability_complete_process_tree_signals,
|
||||
))
|
||||
.unsupported_host
|
||||
else
|
||||
.protocol_incompatible,
|
||||
.unavailable => .unsupported_host,
|
||||
.disconnected => .session_lost,
|
||||
.response => .protocol_incompatible,
|
||||
.response => .invalid_request,
|
||||
},
|
||||
completion.kind == .disconnected and
|
||||
disconnectedActionIsRetryable(request.action()),
|
||||
@@ -102,12 +91,13 @@ fn failure(
|
||||
} });
|
||||
}
|
||||
|
||||
fn mapAdmissionError(err: anyerror) contracts.StructuredErrorCode {
|
||||
fn mapAdmissionError(err: client.AdmissionError) contracts.StructuredErrorCode {
|
||||
return switch (err) {
|
||||
error.QueueFull => .capacity_exceeded,
|
||||
error.TerminalUnavailable, error.Unsupported => .unsupported_host,
|
||||
error.Cancelled => .cancelled,
|
||||
else => .protocol_incompatible,
|
||||
error.TerminalUnavailable => .unsupported_host,
|
||||
// The owning fx process is exiting and has ended its terminals.
|
||||
error.RuntimeStopping => .cancelled,
|
||||
else => .invalid_request,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+723
-758
File diff suppressed because it is too large.
Load diff
@@ -15,26 +15,9 @@ pub const max_list_results: usize = 256;
|
||||
pub const max_cell_text_bytes: usize = 64;
|
||||
pub const max_render_snapshot_bytes: usize = 8 * 1024 * 1024;
|
||||
pub const max_checkpoint_payload_bytes: usize = 64 * 1024 * 1024;
|
||||
pub const max_host_frame_bytes: usize = 1024 * 1024;
|
||||
pub const max_read_output_bytes: usize = 1024 * 1024;
|
||||
pub const max_dimension: u16 = 4096;
|
||||
|
||||
pub const current_protocol_revision: u16 = 5;
|
||||
pub const previous_protocol_revision: u16 = 4;
|
||||
pub const oldest_hello_revision: u16 = 1;
|
||||
pub const compatibility_hello_revision: u16 = previous_protocol_revision - 1;
|
||||
|
||||
pub const protocol_capability_authority_generations: u64 = 1 << 0;
|
||||
pub const protocol_capability_screen_checkpoints: u64 = 1 << 1;
|
||||
pub const protocol_capability_tmux_recovery: u64 = 1 << 2;
|
||||
pub const protocol_capability_path_outside_workspace_error: u64 = 1 << 3;
|
||||
pub const protocol_capability_complete_process_tree_signals: u64 = 1 << 4;
|
||||
pub const known_protocol_capabilities: u64 =
|
||||
protocol_capability_authority_generations |
|
||||
protocol_capability_screen_checkpoints |
|
||||
protocol_capability_tmux_recovery |
|
||||
protocol_capability_path_outside_workspace_error |
|
||||
protocol_capability_complete_process_tree_signals;
|
||||
|
||||
pub const Action = enum {
|
||||
start,
|
||||
read,
|
||||
@@ -545,35 +528,6 @@ pub const ActionRequest = union(enum) {
|
||||
}
|
||||
};
|
||||
|
||||
pub fn required_capabilities(request: ActionRequest) u64 {
|
||||
var required = protocol_capability_authority_generations;
|
||||
switch (request) {
|
||||
.start => |start| {
|
||||
required |= protocol_capability_complete_process_tree_signals;
|
||||
if (start.backend == .tmux) {
|
||||
required |= protocol_capability_tmux_recovery;
|
||||
}
|
||||
},
|
||||
.signal => {
|
||||
required |= protocol_capability_complete_process_tree_signals;
|
||||
},
|
||||
.close => |close| {
|
||||
if (close.policy == .force) {
|
||||
required |= protocol_capability_complete_process_tree_signals;
|
||||
}
|
||||
},
|
||||
.read,
|
||||
.screen,
|
||||
.write,
|
||||
.wait,
|
||||
.inspect,
|
||||
.list,
|
||||
.resize,
|
||||
=> {},
|
||||
}
|
||||
return required;
|
||||
}
|
||||
|
||||
fn validate_optional_authority_claim(claim: ?AuthorityClaim) error{
|
||||
InvalidPrincipal,
|
||||
InvalidSessionId,
|
||||
@@ -1761,105 +1715,6 @@ pub const ProcessOwner = struct {
|
||||
}
|
||||
};
|
||||
|
||||
pub const ProtocolRange = struct {
|
||||
minimum: u16,
|
||||
current: u16,
|
||||
|
||||
pub fn validate(self: ProtocolRange) error{InvalidProtocolRange}!void {
|
||||
if (self.minimum == 0 or self.minimum > self.current) {
|
||||
return error.InvalidProtocolRange;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
pub const local_protocol_range = ProtocolRange{
|
||||
.minimum = previous_protocol_revision,
|
||||
.current = current_protocol_revision,
|
||||
};
|
||||
|
||||
pub const ProtocolHello = struct {
|
||||
range: ProtocolRange,
|
||||
capabilities: u64,
|
||||
required_capabilities: u64 = 0,
|
||||
|
||||
pub fn validate(self: ProtocolHello) error{
|
||||
InvalidProtocolRange,
|
||||
UnknownRequiredCapability,
|
||||
InvalidRequiredCapabilities,
|
||||
}!void {
|
||||
try self.range.validate();
|
||||
if (self.required_capabilities & ~known_protocol_capabilities != 0) {
|
||||
return error.UnknownRequiredCapability;
|
||||
}
|
||||
if (self.required_capabilities & ~self.capabilities != 0) {
|
||||
return error.InvalidRequiredCapabilities;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
pub const IncompatibilityReason = enum {
|
||||
revision_mismatch,
|
||||
required_capability_missing,
|
||||
};
|
||||
|
||||
pub const ProtocolIncompatibility = struct {
|
||||
reason: IncompatibilityReason,
|
||||
client_range: ProtocolRange,
|
||||
host_range: ProtocolRange,
|
||||
missing_capabilities: u64 = 0,
|
||||
};
|
||||
|
||||
pub const NegotiatedProtocol = struct {
|
||||
revision: u16,
|
||||
capabilities: u64,
|
||||
};
|
||||
|
||||
pub const Negotiation = union(enum) {
|
||||
compatible: NegotiatedProtocol,
|
||||
incompatible: ProtocolIncompatibility,
|
||||
};
|
||||
|
||||
pub fn negotiate_protocol(
|
||||
client: ProtocolHello,
|
||||
host: ProtocolHello,
|
||||
) error{
|
||||
InvalidProtocolRange,
|
||||
UnknownRequiredCapability,
|
||||
InvalidRequiredCapabilities,
|
||||
}!Negotiation {
|
||||
try client.validate();
|
||||
try host.validate();
|
||||
|
||||
const missing_for_host = host.required_capabilities & ~client.capabilities;
|
||||
const missing_for_client = client.required_capabilities & ~host.capabilities;
|
||||
const missing = missing_for_host | missing_for_client;
|
||||
if (missing != 0) {
|
||||
return .{ .incompatible = .{
|
||||
.reason = .required_capability_missing,
|
||||
.client_range = client.range,
|
||||
.host_range = host.range,
|
||||
.missing_capabilities = missing,
|
||||
} };
|
||||
}
|
||||
|
||||
const minimum = @max(client.range.minimum, host.range.minimum);
|
||||
const current = @min(client.range.current, host.range.current);
|
||||
if (minimum > current) {
|
||||
return .{ .incompatible = .{
|
||||
.reason = .revision_mismatch,
|
||||
.client_range = client.range,
|
||||
.host_range = host.range,
|
||||
} };
|
||||
}
|
||||
|
||||
return .{ .compatible = .{
|
||||
.revision = current,
|
||||
.capabilities = client.capabilities &
|
||||
host.capabilities &
|
||||
known_protocol_capabilities,
|
||||
} };
|
||||
}
|
||||
|
||||
pub const CorrelationId = struct {
|
||||
value: u64,
|
||||
|
||||
@@ -1880,135 +1735,6 @@ pub const HostEvent = enum {
|
||||
authority_revoked,
|
||||
};
|
||||
|
||||
pub const MessageKind = union(enum) {
|
||||
hello,
|
||||
request: Action,
|
||||
response: Action,
|
||||
cancel,
|
||||
};
|
||||
|
||||
pub const MessageEnvelopeValidationError = error{
|
||||
UnsupportedProtocolRevision,
|
||||
UnknownRequiredCapability,
|
||||
HostFrameTooLarge,
|
||||
InvalidHostFrame,
|
||||
InvalidCorrelationId,
|
||||
MissingCorrelationId,
|
||||
UnexpectedCorrelationId,
|
||||
};
|
||||
|
||||
pub const MessageEnvelope = struct {
|
||||
revision: u16,
|
||||
kind: MessageKind,
|
||||
required_capabilities: u64 = 0,
|
||||
correlation_id: ?CorrelationId = null,
|
||||
payload_len: u32,
|
||||
|
||||
pub fn validate(
|
||||
self: MessageEnvelope,
|
||||
actual_payload_len: usize,
|
||||
) MessageEnvelopeValidationError!void {
|
||||
const minimum_revision = switch (self.kind) {
|
||||
.hello => oldest_hello_revision,
|
||||
.request, .response, .cancel => previous_protocol_revision,
|
||||
};
|
||||
if (self.revision < minimum_revision or
|
||||
self.revision > current_protocol_revision)
|
||||
{
|
||||
return error.UnsupportedProtocolRevision;
|
||||
}
|
||||
if (self.required_capabilities & ~known_protocol_capabilities != 0) {
|
||||
return error.UnknownRequiredCapability;
|
||||
}
|
||||
if (self.payload_len > max_host_frame_bytes or
|
||||
actual_payload_len > max_host_frame_bytes)
|
||||
{
|
||||
return error.HostFrameTooLarge;
|
||||
}
|
||||
const actual = std.math.cast(u32, actual_payload_len) orelse
|
||||
return error.HostFrameTooLarge;
|
||||
if (self.payload_len != actual) return error.InvalidHostFrame;
|
||||
|
||||
switch (self.kind) {
|
||||
.hello => {
|
||||
if (self.correlation_id != null) {
|
||||
return error.UnexpectedCorrelationId;
|
||||
}
|
||||
},
|
||||
.request, .response, .cancel => {
|
||||
const correlation_id = self.correlation_id orelse
|
||||
return error.MissingCorrelationId;
|
||||
try correlation_id.validate();
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
pub const MessagePayloadValidationError =
|
||||
RequestValidationError ||
|
||||
ResultValidationError ||
|
||||
error{
|
||||
InvalidProtocolRange,
|
||||
UnknownRequiredCapability,
|
||||
InvalidRequiredCapabilities,
|
||||
MessageKindMismatch,
|
||||
};
|
||||
|
||||
pub const MessagePayload = union(enum) {
|
||||
hello: ProtocolHello,
|
||||
request: ActionRequest,
|
||||
response: Result,
|
||||
cancel,
|
||||
|
||||
pub fn validate(
|
||||
self: MessagePayload,
|
||||
kind: MessageKind,
|
||||
) MessagePayloadValidationError!void {
|
||||
switch (self) {
|
||||
.hello => |hello| switch (kind) {
|
||||
.hello => try hello.validate(),
|
||||
.request, .response, .cancel => return error.MessageKindMismatch,
|
||||
},
|
||||
.request => |request| switch (kind) {
|
||||
.request => |action| {
|
||||
if (request.action() != action) return error.MessageKindMismatch;
|
||||
try request.validate();
|
||||
},
|
||||
.hello, .response, .cancel => return error.MessageKindMismatch,
|
||||
},
|
||||
.response => |result| switch (kind) {
|
||||
.response => |action| {
|
||||
if (result.action() != action) return error.MessageKindMismatch;
|
||||
try result.validate();
|
||||
},
|
||||
.hello, .request, .cancel => return error.MessageKindMismatch,
|
||||
},
|
||||
.cancel => switch (kind) {
|
||||
.cancel => {},
|
||||
.hello, .request, .response => return error.MessageKindMismatch,
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
pub const HostMessageValidationError =
|
||||
MessageEnvelopeValidationError ||
|
||||
MessagePayloadValidationError;
|
||||
|
||||
/// Typed semantic host message paired with its bounded wire envelope.
|
||||
pub const HostMessage = struct {
|
||||
envelope: MessageEnvelope,
|
||||
payload: MessagePayload,
|
||||
|
||||
pub fn validate(
|
||||
self: HostMessage,
|
||||
actual_payload_len: usize,
|
||||
) HostMessageValidationError!void {
|
||||
try self.envelope.validate(actual_payload_len);
|
||||
try self.payload.validate(self.envelope.kind);
|
||||
}
|
||||
};
|
||||
|
||||
pub const StructuredErrorCode = enum {
|
||||
invalid_request,
|
||||
path_outside_workspace,
|
||||
@@ -2220,7 +1946,7 @@ pub const ActionResult = union(enum) {
|
||||
},
|
||||
.read => |value| {
|
||||
try value.session.validate();
|
||||
if (value.output.len > max_host_frame_bytes) {
|
||||
if (value.output.len > max_read_output_bytes) {
|
||||
return error.HostFrameTooLarge;
|
||||
}
|
||||
if (value.raw_range) |range| {
|
||||
@@ -3438,303 +3164,3 @@ test "next actions are the pure lifecycle authority and lease intersection" {
|
||||
);
|
||||
try std.testing.expectEqual(lifecycle_controls(.closed), closed);
|
||||
}
|
||||
|
||||
test "protocol negotiates current and previous revisions without destructive fallback" {
|
||||
const capabilities =
|
||||
protocol_capability_authority_generations |
|
||||
protocol_capability_screen_checkpoints;
|
||||
const current = ProtocolHello{
|
||||
.range = local_protocol_range,
|
||||
.capabilities = capabilities,
|
||||
.required_capabilities = protocol_capability_authority_generations,
|
||||
};
|
||||
const previous = ProtocolHello{
|
||||
.range = .{
|
||||
.minimum = previous_protocol_revision,
|
||||
.current = previous_protocol_revision,
|
||||
},
|
||||
.capabilities = protocol_capability_authority_generations,
|
||||
};
|
||||
|
||||
const same = try negotiate_protocol(current, current);
|
||||
try std.testing.expectEqual(
|
||||
current_protocol_revision,
|
||||
same.compatible.revision,
|
||||
);
|
||||
const skewed = try negotiate_protocol(current, previous);
|
||||
try std.testing.expectEqual(
|
||||
previous_protocol_revision,
|
||||
skewed.compatible.revision,
|
||||
);
|
||||
try std.testing.expectEqual(
|
||||
protocol_capability_authority_generations,
|
||||
skewed.compatible.capabilities,
|
||||
);
|
||||
|
||||
const future = ProtocolHello{
|
||||
.range = .{
|
||||
.minimum = current_protocol_revision + 1,
|
||||
.current = current_protocol_revision + 2,
|
||||
},
|
||||
.capabilities = known_protocol_capabilities,
|
||||
};
|
||||
const incompatible = try negotiate_protocol(current, future);
|
||||
try std.testing.expectEqual(
|
||||
IncompatibilityReason.revision_mismatch,
|
||||
incompatible.incompatible.reason,
|
||||
);
|
||||
|
||||
const missing_capability = ProtocolHello{
|
||||
.range = local_protocol_range,
|
||||
.capabilities = protocol_capability_authority_generations,
|
||||
.required_capabilities = protocol_capability_authority_generations,
|
||||
};
|
||||
const no_capabilities = ProtocolHello{
|
||||
.range = local_protocol_range,
|
||||
.capabilities = 0,
|
||||
};
|
||||
const missing = try negotiate_protocol(missing_capability, no_capabilities);
|
||||
try std.testing.expectEqual(
|
||||
IncompatibilityReason.required_capability_missing,
|
||||
missing.incompatible.reason,
|
||||
);
|
||||
try std.testing.expectError(
|
||||
error.UnknownRequiredCapability,
|
||||
(ProtocolHello{
|
||||
.range = local_protocol_range,
|
||||
.capabilities = std.math.maxInt(u64),
|
||||
.required_capabilities = 1 << 63,
|
||||
}).validate(),
|
||||
);
|
||||
}
|
||||
|
||||
test "protocol accepts complete process tree signal capability" {
|
||||
const complete_process_tree_signals: u64 = 1 << 4;
|
||||
try (ProtocolHello{
|
||||
.range = local_protocol_range,
|
||||
.capabilities = complete_process_tree_signals,
|
||||
.required_capabilities = complete_process_tree_signals,
|
||||
}).validate();
|
||||
}
|
||||
|
||||
test "durable actions derive policy specific protocol capabilities" {
|
||||
const authority = protocol_capability_authority_generations;
|
||||
const complete_signals = protocol_capability_complete_process_tree_signals;
|
||||
const tmux_recovery = protocol_capability_tmux_recovery;
|
||||
const cases = [_]struct {
|
||||
request: ActionRequest,
|
||||
expected: u64,
|
||||
}{
|
||||
.{
|
||||
.request = .{ .start = .{ .cwd = "/" } },
|
||||
.expected = authority | complete_signals,
|
||||
},
|
||||
.{
|
||||
.request = .{ .start = .{ .cwd = "/", .backend = .tmux } },
|
||||
.expected = authority | complete_signals | tmux_recovery,
|
||||
},
|
||||
.{
|
||||
.request = .{ .read = .{
|
||||
.session_id = "terminal-a",
|
||||
.cursor = .{ .segment = 1, .offset = 0 },
|
||||
} },
|
||||
.expected = authority,
|
||||
},
|
||||
.{
|
||||
.request = .{ .screen = .{ .session_id = "terminal-a" } },
|
||||
.expected = authority,
|
||||
},
|
||||
.{
|
||||
.request = .{ .write = .{
|
||||
.session_id = "terminal-a",
|
||||
.lease = .acquire,
|
||||
} },
|
||||
.expected = authority,
|
||||
},
|
||||
.{
|
||||
.request = .{ .wait = .{
|
||||
.session_id = "terminal-a",
|
||||
.return_when = .exit,
|
||||
.safety_ceiling_ms = 1,
|
||||
} },
|
||||
.expected = authority,
|
||||
},
|
||||
.{
|
||||
.request = .{ .inspect = .{ .session_id = "terminal-a" } },
|
||||
.expected = authority,
|
||||
},
|
||||
.{
|
||||
.request = .{ .list = .{} },
|
||||
.expected = authority,
|
||||
},
|
||||
.{
|
||||
.request = .{ .resize = .{
|
||||
.session_id = "terminal-a",
|
||||
.dimensions = .{ .rows = 24, .columns = 80 },
|
||||
} },
|
||||
.expected = authority,
|
||||
},
|
||||
.{
|
||||
.request = .{ .signal = .{
|
||||
.session_id = "terminal-a",
|
||||
.signal = .interrupt,
|
||||
} },
|
||||
.expected = authority | complete_signals,
|
||||
},
|
||||
.{
|
||||
.request = .{ .close = .{
|
||||
.session_id = "terminal-a",
|
||||
.policy = .graceful,
|
||||
} },
|
||||
.expected = authority,
|
||||
},
|
||||
.{
|
||||
.request = .{ .close = .{
|
||||
.session_id = "terminal-a",
|
||||
.policy = .force,
|
||||
} },
|
||||
.expected = authority | complete_signals,
|
||||
},
|
||||
};
|
||||
|
||||
for (cases) |case| {
|
||||
try case.request.validate();
|
||||
try std.testing.expectEqual(
|
||||
case.expected,
|
||||
required_capabilities(case.request),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
test "hello envelopes bridge older incompatible ranges without admitting old actions" {
|
||||
try (MessageEnvelope{
|
||||
.revision = oldest_hello_revision,
|
||||
.kind = .hello,
|
||||
.payload_len = 0,
|
||||
}).validate(0);
|
||||
try std.testing.expectError(
|
||||
error.UnsupportedProtocolRevision,
|
||||
(MessageEnvelope{
|
||||
.revision = compatibility_hello_revision,
|
||||
.kind = .{ .request = .screen },
|
||||
.correlation_id = .{ .value = 1 },
|
||||
.payload_len = 0,
|
||||
}).validate(0),
|
||||
);
|
||||
}
|
||||
|
||||
test "host message correlation isolates request response and cancel semantics" {
|
||||
const correlation_id = try CorrelationId.init(7);
|
||||
const request = HostMessage{
|
||||
.envelope = .{
|
||||
.revision = current_protocol_revision,
|
||||
.kind = .{ .request = .screen },
|
||||
.correlation_id = correlation_id,
|
||||
.payload_len = 0,
|
||||
},
|
||||
.payload = .{ .request = .{
|
||||
.screen = .{ .session_id = "terminal-1" },
|
||||
} },
|
||||
};
|
||||
try request.validate(0);
|
||||
|
||||
const response = HostMessage{
|
||||
.envelope = .{
|
||||
.revision = current_protocol_revision,
|
||||
.kind = .{ .response = .screen },
|
||||
.correlation_id = correlation_id,
|
||||
.payload_len = 0,
|
||||
},
|
||||
.payload = .{ .response = .{ .failure = .{
|
||||
.action = .screen,
|
||||
.code = .screen_unavailable,
|
||||
.session_id = "terminal-1",
|
||||
} } },
|
||||
};
|
||||
try response.validate(0);
|
||||
|
||||
try (HostMessage{
|
||||
.envelope = .{
|
||||
.revision = current_protocol_revision,
|
||||
.kind = .hello,
|
||||
.payload_len = 0,
|
||||
},
|
||||
.payload = .{ .hello = .{
|
||||
.range = local_protocol_range,
|
||||
.capabilities = known_protocol_capabilities,
|
||||
} },
|
||||
}).validate(0);
|
||||
try (HostMessage{
|
||||
.envelope = .{
|
||||
.revision = current_protocol_revision,
|
||||
.kind = .cancel,
|
||||
.correlation_id = correlation_id,
|
||||
.payload_len = 0,
|
||||
},
|
||||
.payload = .cancel,
|
||||
}).validate(0);
|
||||
|
||||
try std.testing.expectError(
|
||||
error.MissingCorrelationId,
|
||||
(MessageEnvelope{
|
||||
.revision = current_protocol_revision,
|
||||
.kind = .{ .request = .read },
|
||||
.payload_len = 0,
|
||||
}).validate(0),
|
||||
);
|
||||
try std.testing.expectError(
|
||||
error.InvalidCorrelationId,
|
||||
(MessageEnvelope{
|
||||
.revision = current_protocol_revision,
|
||||
.kind = .cancel,
|
||||
.correlation_id = .{ .value = 0 },
|
||||
.payload_len = 0,
|
||||
}).validate(0),
|
||||
);
|
||||
try std.testing.expectError(
|
||||
error.UnexpectedCorrelationId,
|
||||
(MessageEnvelope{
|
||||
.revision = current_protocol_revision,
|
||||
.kind = .hello,
|
||||
.correlation_id = correlation_id,
|
||||
.payload_len = 0,
|
||||
}).validate(0),
|
||||
);
|
||||
}
|
||||
|
||||
test "typed host messages reject action and payload mismatches" {
|
||||
const cells = test_render_cells();
|
||||
try std.testing.expectError(
|
||||
error.MessageKindMismatch,
|
||||
(HostMessage{
|
||||
.envelope = .{
|
||||
.revision = current_protocol_revision,
|
||||
.kind = .{ .response = .write },
|
||||
.correlation_id = .{ .value = 1 },
|
||||
.payload_len = 0,
|
||||
},
|
||||
.payload = .{ .response = .{ .success = .{ .screen = .{
|
||||
.session = test_session_facts(),
|
||||
.snapshot = .{
|
||||
.dimensions = .{ .rows = 2, .columns = 2 },
|
||||
.cursor = .{ .row = 1, .column = 1 },
|
||||
.cells = &cells,
|
||||
},
|
||||
} } } },
|
||||
}).validate(0),
|
||||
);
|
||||
try std.testing.expectError(
|
||||
error.MessageKindMismatch,
|
||||
(HostMessage{
|
||||
.envelope = .{
|
||||
.revision = current_protocol_revision,
|
||||
.kind = .{ .request = .read },
|
||||
.correlation_id = .{ .value = 1 },
|
||||
.payload_len = 0,
|
||||
},
|
||||
.payload = .{ .request = .{
|
||||
.screen = .{ .session_id = "terminal-1" },
|
||||
} },
|
||||
}).validate(0),
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,201 +0,0 @@
|
||||
const std = @import("std");
|
||||
const contracts = @import("contracts.zig");
|
||||
|
||||
pub const queue_capacity: usize = 16;
|
||||
pub const outcome_capacity: usize = 32;
|
||||
|
||||
pub const IdleFacts = struct {
|
||||
connected_clients: usize = 0,
|
||||
pending_requests: usize = 0,
|
||||
live_work: usize = 0,
|
||||
};
|
||||
|
||||
pub fn idleEligible(facts: IdleFacts) bool {
|
||||
return facts.connected_clients == 0 and
|
||||
facts.pending_requests == 0 and
|
||||
facts.live_work == 0;
|
||||
}
|
||||
|
||||
pub const QueueAdmission = enum {
|
||||
admit,
|
||||
full,
|
||||
stopping,
|
||||
};
|
||||
|
||||
pub fn classifyQueueAdmission(
|
||||
queued: usize,
|
||||
capacity: usize,
|
||||
stopping: bool,
|
||||
) QueueAdmission {
|
||||
if (stopping) return .stopping;
|
||||
if (queued >= capacity) return .full;
|
||||
return .admit;
|
||||
}
|
||||
|
||||
pub const ConnectionEvidence = enum {
|
||||
connected,
|
||||
endpoint_missing,
|
||||
refused,
|
||||
failed,
|
||||
};
|
||||
|
||||
pub const LockEvidence = enum {
|
||||
acquired,
|
||||
held,
|
||||
unavailable,
|
||||
};
|
||||
|
||||
pub const IdentityEvidence = enum {
|
||||
absent,
|
||||
live,
|
||||
dead,
|
||||
unverifiable,
|
||||
};
|
||||
|
||||
pub const Reconciliation = enum {
|
||||
reuse_connected,
|
||||
wait_for_authority,
|
||||
start_host,
|
||||
remove_stale_then_start,
|
||||
preserve_identity_conflict,
|
||||
unavailable,
|
||||
};
|
||||
|
||||
pub fn classifyReconciliation(
|
||||
connection: ConnectionEvidence,
|
||||
lock: LockEvidence,
|
||||
identity: IdentityEvidence,
|
||||
) Reconciliation {
|
||||
if (connection == .connected) return .reuse_connected;
|
||||
return switch (lock) {
|
||||
.held => .wait_for_authority,
|
||||
.unavailable => .unavailable,
|
||||
.acquired => switch (identity) {
|
||||
.live, .unverifiable => .preserve_identity_conflict,
|
||||
.absent, .dead => if (connection == .endpoint_missing)
|
||||
.start_host
|
||||
else
|
||||
.remove_stale_then_start,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
pub const PendingRequests = struct {
|
||||
values: [outcome_capacity]?contracts.CorrelationId = @splat(null),
|
||||
|
||||
pub fn add(
|
||||
self: *PendingRequests,
|
||||
correlation_id: contracts.CorrelationId,
|
||||
) error{ DuplicateCorrelation, CapacityExceeded }!void {
|
||||
correlation_id.validate() catch return error.DuplicateCorrelation;
|
||||
var free_index: ?usize = null;
|
||||
for (self.values, 0..) |entry, index| {
|
||||
if (entry) |existing| {
|
||||
if (existing.value == correlation_id.value) {
|
||||
return error.DuplicateCorrelation;
|
||||
}
|
||||
} else if (free_index == null) {
|
||||
free_index = index;
|
||||
}
|
||||
}
|
||||
self.values[free_index orelse return error.CapacityExceeded] = correlation_id;
|
||||
}
|
||||
|
||||
pub fn complete(
|
||||
self: *PendingRequests,
|
||||
correlation_id: contracts.CorrelationId,
|
||||
) bool {
|
||||
return self.remove(correlation_id);
|
||||
}
|
||||
|
||||
pub fn cancel(
|
||||
self: *PendingRequests,
|
||||
correlation_id: contracts.CorrelationId,
|
||||
) bool {
|
||||
return self.remove(correlation_id);
|
||||
}
|
||||
|
||||
pub fn contains(
|
||||
self: *const PendingRequests,
|
||||
correlation_id: contracts.CorrelationId,
|
||||
) bool {
|
||||
for (self.values) |entry| {
|
||||
if (entry) |existing| {
|
||||
if (existing.value == correlation_id.value) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
fn remove(
|
||||
self: *PendingRequests,
|
||||
correlation_id: contracts.CorrelationId,
|
||||
) bool {
|
||||
for (&self.values) |*entry| {
|
||||
const existing = entry.* orelse continue;
|
||||
if (existing.value != correlation_id.value) continue;
|
||||
entry.* = null;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
test "idle eligibility accounts for every host owner" {
|
||||
try std.testing.expect(idleEligible(.{}));
|
||||
try std.testing.expect(!idleEligible(.{ .connected_clients = 1 }));
|
||||
try std.testing.expect(!idleEligible(.{ .pending_requests = 1 }));
|
||||
try std.testing.expect(!idleEligible(.{ .live_work = 1 }));
|
||||
}
|
||||
|
||||
test "queue admission is bounded and rejects shutdown" {
|
||||
try std.testing.expectEqual(
|
||||
QueueAdmission.admit,
|
||||
classifyQueueAdmission(queue_capacity - 1, queue_capacity, false),
|
||||
);
|
||||
try std.testing.expectEqual(
|
||||
QueueAdmission.full,
|
||||
classifyQueueAdmission(queue_capacity, queue_capacity, false),
|
||||
);
|
||||
try std.testing.expectEqual(
|
||||
QueueAdmission.stopping,
|
||||
classifyQueueAdmission(0, queue_capacity, true),
|
||||
);
|
||||
}
|
||||
|
||||
test "reconciliation requires both lock and liveness proof before cleanup" {
|
||||
try std.testing.expectEqual(
|
||||
Reconciliation.wait_for_authority,
|
||||
classifyReconciliation(.refused, .held, .dead),
|
||||
);
|
||||
try std.testing.expectEqual(
|
||||
Reconciliation.preserve_identity_conflict,
|
||||
classifyReconciliation(.refused, .acquired, .live),
|
||||
);
|
||||
try std.testing.expectEqual(
|
||||
Reconciliation.preserve_identity_conflict,
|
||||
classifyReconciliation(.refused, .acquired, .unverifiable),
|
||||
);
|
||||
try std.testing.expectEqual(
|
||||
Reconciliation.remove_stale_then_start,
|
||||
classifyReconciliation(.refused, .acquired, .dead),
|
||||
);
|
||||
try std.testing.expectEqual(
|
||||
Reconciliation.start_host,
|
||||
classifyReconciliation(.endpoint_missing, .acquired, .absent),
|
||||
);
|
||||
}
|
||||
|
||||
test "targeted cancellation releases only its pending request" {
|
||||
var pending: PendingRequests = .{};
|
||||
const first = contracts.CorrelationId{ .value = 1 };
|
||||
const second = contracts.CorrelationId{ .value = 2 };
|
||||
try pending.add(first);
|
||||
try pending.add(second);
|
||||
try std.testing.expectError(error.DuplicateCorrelation, pending.add(first));
|
||||
try std.testing.expect(pending.cancel(first));
|
||||
try std.testing.expect(!pending.contains(first));
|
||||
try std.testing.expect(pending.contains(second));
|
||||
try std.testing.expect(!pending.cancel(.{ .value = 99 }));
|
||||
try std.testing.expect(pending.complete(second));
|
||||
}
|
||||
@@ -223,7 +223,18 @@ pub fn observe(
|
||||
};
|
||||
}
|
||||
|
||||
pub fn syncOwned(ctx: Context) !void {
|
||||
pub const SyncOutcome = enum {
|
||||
/// The requested terminal is indexed, or this owner has no such terminal.
|
||||
indexed,
|
||||
/// The requested terminal ended with the fx process that started it.
|
||||
ended_with_owner,
|
||||
};
|
||||
|
||||
/// Indexes this owner's terminals that the managed runtime does not know yet,
|
||||
/// which happens after resume. Listing reconciles them first, so a terminal
|
||||
/// whose owning fx process is gone reads as lost. Such terminals are never
|
||||
/// reattached or indexed; `requested_session_id` reports whether it is one.
|
||||
pub fn syncOwned(ctx: Context, requested_session_id: []const u8) !SyncOutcome {
|
||||
var catalog_authority = try reloadOwnerCatalogAuthority(ctx);
|
||||
defer catalog_authority.deinit();
|
||||
var listed = try execute(ctx, .{ .list = .{
|
||||
@@ -237,12 +248,21 @@ pub fn syncOwned(ctx: Context) !void {
|
||||
else => return error.InvalidTerminalResult,
|
||||
},
|
||||
};
|
||||
var outcome: SyncOutcome = .indexed;
|
||||
for (sessions) |facts| {
|
||||
if (facts.lifecycle == .closed or
|
||||
ctx.managed_runtime.backendFor(facts.session_id) != null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// This process starts and indexes its own terminals, so an unknown
|
||||
// lost terminal belonged to an fx process instance that has exited.
|
||||
if (facts.lifecycle == .lost) {
|
||||
if (std.mem.eql(u8, facts.session_id, requested_session_id)) {
|
||||
outcome = .ended_with_owner;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
var authority = try reloadAuthority(ctx, facts.session_id);
|
||||
defer authority.deinit();
|
||||
var inspected = try execute(ctx, .{ .inspect = .{
|
||||
@@ -285,6 +305,7 @@ pub fn syncOwned(ctx: Context) !void {
|
||||
prepared.reservation_id,
|
||||
);
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
|
||||
fn resolveCompletedStatus(
|
||||
|
||||
+316
-1094
File diff suppressed because it is too large.
Load diff
@@ -1,518 +0,0 @@
|
||||
const std = @import("std");
|
||||
const contracts = @import("contracts.zig");
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
pub const header_len: usize = 28;
|
||||
const magic = "FXTH";
|
||||
|
||||
pub const DecodeError = error{
|
||||
TruncatedFrame,
|
||||
InvalidFrameMagic,
|
||||
InvalidFrameKind,
|
||||
InvalidFrameSubject,
|
||||
InvalidHostFrame,
|
||||
HostFrameTooLarge,
|
||||
InvalidPayload,
|
||||
} || contracts.HostMessageValidationError || Allocator.Error;
|
||||
|
||||
pub const Header = struct {
|
||||
envelope: contracts.MessageEnvelope,
|
||||
|
||||
pub fn decode(bytes: []const u8) DecodeError!Header {
|
||||
if (bytes.len < header_len) return error.TruncatedFrame;
|
||||
if (!std.mem.eql(u8, bytes[0..magic.len], magic)) {
|
||||
return error.InvalidFrameMagic;
|
||||
}
|
||||
|
||||
const revision = std.mem.readInt(u16, bytes[4..6], .little);
|
||||
const kind = try decodeKind(bytes[6], bytes[7]);
|
||||
const required_capabilities = std.mem.readInt(u64, bytes[8..16], .little);
|
||||
const correlation_value = std.mem.readInt(u64, bytes[16..24], .little);
|
||||
const payload_len = std.mem.readInt(u32, bytes[24..28], .little);
|
||||
const correlation_id: ?contracts.CorrelationId =
|
||||
if (correlation_value == 0) null else .{ .value = correlation_value };
|
||||
const envelope = contracts.MessageEnvelope{
|
||||
.revision = revision,
|
||||
.kind = kind,
|
||||
.required_capabilities = required_capabilities,
|
||||
.correlation_id = correlation_id,
|
||||
.payload_len = payload_len,
|
||||
};
|
||||
try envelope.validate(payload_len);
|
||||
return .{ .envelope = envelope };
|
||||
}
|
||||
|
||||
pub fn encode(self: Header) DecodeError![header_len]u8 {
|
||||
try self.envelope.validate(self.envelope.payload_len);
|
||||
const wire_kind = encodeKind(self.envelope.kind);
|
||||
var bytes: [header_len]u8 = @splat(0);
|
||||
@memcpy(bytes[0..magic.len], magic);
|
||||
std.mem.writeInt(u16, bytes[4..6], self.envelope.revision, .little);
|
||||
bytes[6] = wire_kind.kind;
|
||||
bytes[7] = wire_kind.subject;
|
||||
std.mem.writeInt(
|
||||
u64,
|
||||
bytes[8..16],
|
||||
self.envelope.required_capabilities,
|
||||
.little,
|
||||
);
|
||||
std.mem.writeInt(
|
||||
u64,
|
||||
bytes[16..24],
|
||||
if (self.envelope.correlation_id) |id| id.value else 0,
|
||||
.little,
|
||||
);
|
||||
std.mem.writeInt(u32, bytes[24..28], self.envelope.payload_len, .little);
|
||||
return bytes;
|
||||
}
|
||||
};
|
||||
|
||||
pub const EncodedFrame = struct {
|
||||
bytes: []u8,
|
||||
|
||||
pub fn deinit(self: *EncodedFrame, alloc: Allocator) void {
|
||||
alloc.free(self.bytes);
|
||||
self.* = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
pub const DecodedFrame = struct {
|
||||
envelope: contracts.MessageEnvelope,
|
||||
parsed: std.json.Parsed(contracts.MessagePayload),
|
||||
|
||||
pub fn message(self: *const DecodedFrame) contracts.HostMessage {
|
||||
return .{
|
||||
.envelope = self.envelope,
|
||||
.payload = self.parsed.value,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *DecodedFrame) void {
|
||||
self.parsed.deinit();
|
||||
self.* = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
pub fn encodeFrame(
|
||||
alloc: Allocator,
|
||||
revision: u16,
|
||||
required_capabilities: u64,
|
||||
correlation_id: ?contracts.CorrelationId,
|
||||
payload: contracts.MessagePayload,
|
||||
) DecodeError!EncodedFrame {
|
||||
var payload_out: std.Io.Writer.Allocating = .init(alloc);
|
||||
defer payload_out.deinit();
|
||||
try stringifyPayload(revision, payload, &payload_out.writer);
|
||||
const payload_bytes = payload_out.written();
|
||||
if (payload_bytes.len > contracts.max_host_frame_bytes) {
|
||||
return error.HostFrameTooLarge;
|
||||
}
|
||||
const payload_len = std.math.cast(u32, payload_bytes.len) orelse
|
||||
return error.HostFrameTooLarge;
|
||||
const kind = kindForPayload(payload);
|
||||
const envelope = contracts.MessageEnvelope{
|
||||
.revision = revision,
|
||||
.kind = kind,
|
||||
.required_capabilities = required_capabilities,
|
||||
.correlation_id = correlation_id,
|
||||
.payload_len = payload_len,
|
||||
};
|
||||
try (contracts.HostMessage{
|
||||
.envelope = envelope,
|
||||
.payload = payload,
|
||||
}).validate(payload_bytes.len);
|
||||
|
||||
const total_len = std.math.add(usize, header_len, payload_bytes.len) catch
|
||||
return error.HostFrameTooLarge;
|
||||
var bytes = try alloc.alloc(u8, total_len);
|
||||
errdefer alloc.free(bytes);
|
||||
const header = try (Header{ .envelope = envelope }).encode();
|
||||
@memcpy(bytes[0..header_len], &header);
|
||||
@memcpy(bytes[header_len..], payload_bytes);
|
||||
return .{ .bytes = bytes };
|
||||
}
|
||||
|
||||
fn stringifyPayload(
|
||||
revision: u16,
|
||||
payload: contracts.MessagePayload,
|
||||
writer: *std.Io.Writer,
|
||||
) DecodeError!void {
|
||||
if (revision == contracts.previous_protocol_revision) {
|
||||
const projected = projectPayloadV4(payload);
|
||||
std.json.Stringify.value(projected, .{}, writer) catch
|
||||
return error.OutOfMemory;
|
||||
return;
|
||||
}
|
||||
std.json.Stringify.value(payload, .{}, writer) catch
|
||||
return error.OutOfMemory;
|
||||
}
|
||||
|
||||
fn projectPayloadV4(payload: contracts.MessagePayload) contracts.MessagePayload {
|
||||
return switch (payload) {
|
||||
.hello => |hello| .{ .hello = hello },
|
||||
.request => |request| .{ .request = request },
|
||||
.response => |response| .{ .response = projectResultV4(response) },
|
||||
.cancel => .cancel,
|
||||
};
|
||||
}
|
||||
|
||||
fn projectResultV4(result: contracts.Result) contracts.Result {
|
||||
return result;
|
||||
}
|
||||
|
||||
pub fn projectResultForCapabilities(
|
||||
result: contracts.Result,
|
||||
capabilities: u64,
|
||||
) contracts.Result {
|
||||
const failure = switch (result) {
|
||||
.success => return result,
|
||||
.failure => |value| value,
|
||||
};
|
||||
if (failure.code != .path_outside_workspace or
|
||||
capabilities & contracts.protocol_capability_path_outside_workspace_error != 0)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
var compatible = failure;
|
||||
compatible.code = .invalid_request;
|
||||
return .{ .failure = compatible };
|
||||
}
|
||||
|
||||
inline fn failDecodedFrame(err: anytype) @TypeOf(err)!DecodedFrame {
|
||||
return @errorCast(failDecodedFrameDynamic(err));
|
||||
}
|
||||
|
||||
noinline fn failDecodedFrameDynamic(err: anyerror) anyerror!DecodedFrame {
|
||||
return err;
|
||||
}
|
||||
|
||||
test "decoded frame failures preserve exact error types and identities" {
|
||||
const truncated = failDecodedFrame(error.TruncatedFrame);
|
||||
try std.testing.expect(
|
||||
@TypeOf(truncated) == error{TruncatedFrame}!DecodedFrame,
|
||||
);
|
||||
try std.testing.expectError(error.TruncatedFrame, truncated);
|
||||
try std.testing.expectError(error.OutOfMemory, failDecodedFrame(error.OutOfMemory));
|
||||
}
|
||||
|
||||
pub fn decodeFrame(alloc: Allocator, bytes: []const u8) DecodeError!DecodedFrame {
|
||||
const header = try Header.decode(bytes);
|
||||
const payload_len: usize = header.envelope.payload_len;
|
||||
const expected_len = std.math.add(usize, header_len, payload_len) catch
|
||||
return failDecodedFrame(error.HostFrameTooLarge);
|
||||
if (bytes.len < expected_len) return failDecodedFrame(error.TruncatedFrame);
|
||||
if (bytes.len != expected_len) return failDecodedFrame(error.InvalidHostFrame);
|
||||
|
||||
var parsed = std.json.parseFromSlice(
|
||||
contracts.MessagePayload,
|
||||
alloc,
|
||||
bytes[header_len..],
|
||||
.{ .allocate = .alloc_always },
|
||||
) catch |err| switch (err) {
|
||||
error.OutOfMemory => return failDecodedFrame(error.OutOfMemory),
|
||||
else => return failDecodedFrame(error.InvalidPayload),
|
||||
};
|
||||
errdefer parsed.deinit();
|
||||
try (contracts.HostMessage{
|
||||
.envelope = header.envelope,
|
||||
.payload = parsed.value,
|
||||
}).validate(payload_len);
|
||||
return .{
|
||||
.envelope = header.envelope,
|
||||
.parsed = parsed,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn readFrame(
|
||||
alloc: Allocator,
|
||||
reader: *std.Io.Reader,
|
||||
) (DecodeError || std.Io.Reader.Error)!DecodedFrame {
|
||||
var header_bytes: [header_len]u8 = undefined;
|
||||
reader.readSliceAll(&header_bytes) catch |err| switch (err) {
|
||||
error.EndOfStream => return failDecodedFrame(error.TruncatedFrame),
|
||||
else => return err,
|
||||
};
|
||||
const header = try Header.decode(&header_bytes);
|
||||
const payload_len: usize = header.envelope.payload_len;
|
||||
const total_len = std.math.add(usize, header_len, payload_len) catch
|
||||
return failDecodedFrame(error.HostFrameTooLarge);
|
||||
var bytes = try alloc.alloc(u8, total_len);
|
||||
defer alloc.free(bytes);
|
||||
@memcpy(bytes[0..header_len], &header_bytes);
|
||||
reader.readSliceAll(bytes[header_len..]) catch |err| switch (err) {
|
||||
error.EndOfStream => return failDecodedFrame(error.TruncatedFrame),
|
||||
else => return err,
|
||||
};
|
||||
return decodeFrame(alloc, bytes);
|
||||
}
|
||||
|
||||
pub fn writeFrame(
|
||||
writer: *std.Io.Writer,
|
||||
frame: EncodedFrame,
|
||||
) std.Io.Writer.Error!void {
|
||||
try writer.writeAll(frame.bytes);
|
||||
try writer.flush();
|
||||
}
|
||||
|
||||
fn kindForPayload(payload: contracts.MessagePayload) contracts.MessageKind {
|
||||
return switch (payload) {
|
||||
.hello => .hello,
|
||||
.request => |request| .{ .request = request.action() },
|
||||
.response => |response| .{ .response = response.action() },
|
||||
.cancel => .cancel,
|
||||
};
|
||||
}
|
||||
|
||||
const WireKind = struct {
|
||||
kind: u8,
|
||||
subject: u8,
|
||||
};
|
||||
|
||||
fn encodeKind(kind: contracts.MessageKind) WireKind {
|
||||
return switch (kind) {
|
||||
.hello => .{ .kind = 0, .subject = 0 },
|
||||
.request => |action| .{ .kind = 1, .subject = @intFromEnum(action) },
|
||||
.response => |action| .{ .kind = 2, .subject = @intFromEnum(action) },
|
||||
.cancel => .{ .kind = 4, .subject = 0 },
|
||||
};
|
||||
}
|
||||
|
||||
fn decodeKind(kind: u8, subject: u8) DecodeError!contracts.MessageKind {
|
||||
return switch (kind) {
|
||||
0 => if (subject == 0) .hello else error.InvalidFrameSubject,
|
||||
1 => if (subject <= @intFromEnum(contracts.Action.close))
|
||||
.{ .request = @enumFromInt(subject) }
|
||||
else
|
||||
error.InvalidFrameSubject,
|
||||
2 => if (subject <= @intFromEnum(contracts.Action.close))
|
||||
.{ .response = @enumFromInt(subject) }
|
||||
else
|
||||
error.InvalidFrameSubject,
|
||||
3 => error.InvalidFrameKind,
|
||||
4 => if (subject == 0) .cancel else error.InvalidFrameSubject,
|
||||
else => error.InvalidFrameKind,
|
||||
};
|
||||
}
|
||||
|
||||
fn testRequestFrame(alloc: Allocator) !void {
|
||||
var encoded = try encodeFrame(
|
||||
alloc,
|
||||
contracts.current_protocol_revision,
|
||||
0,
|
||||
.{ .value = 41 },
|
||||
.{ .request = .{ .screen = .{ .session_id = "terminal-1" } } },
|
||||
);
|
||||
defer encoded.deinit(alloc);
|
||||
var decoded = try decodeFrame(alloc, encoded.bytes);
|
||||
defer decoded.deinit();
|
||||
const message = decoded.message();
|
||||
try std.testing.expectEqual(@as(u64, 41), message.envelope.correlation_id.?.value);
|
||||
try std.testing.expectEqual(contracts.Action.screen, message.payload.request.action());
|
||||
}
|
||||
|
||||
test "host protocol round trips owned correlated frames" {
|
||||
try std.testing.checkAllAllocationFailures(
|
||||
std.testing.allocator,
|
||||
testRequestFrame,
|
||||
.{},
|
||||
);
|
||||
}
|
||||
|
||||
test "host protocol reads fragmented frames and preserves cancel framing" {
|
||||
var encoded = try encodeFrame(
|
||||
std.testing.allocator,
|
||||
contracts.current_protocol_revision,
|
||||
0,
|
||||
.{ .value = 7 },
|
||||
.cancel,
|
||||
);
|
||||
defer encoded.deinit(std.testing.allocator);
|
||||
var reader_buffer: [64]u8 = undefined;
|
||||
var fragmented: std.testing.Reader = .init(&reader_buffer, &.{
|
||||
.{ .buffer = encoded.bytes },
|
||||
});
|
||||
fragmented.artificial_limit = .limited(1);
|
||||
var decoded = try readFrame(
|
||||
std.testing.allocator,
|
||||
&fragmented.interface,
|
||||
);
|
||||
defer decoded.deinit();
|
||||
try std.testing.expectEqual(contracts.MessageKind.cancel, decoded.message().envelope.kind);
|
||||
try std.testing.expectEqual(@as(u8, 4), encoded.bytes[6]);
|
||||
}
|
||||
|
||||
test "host protocol reserves removed event kind" {
|
||||
var encoded = try encodeFrame(
|
||||
std.testing.allocator,
|
||||
contracts.current_protocol_revision,
|
||||
0,
|
||||
.{ .value = 7 },
|
||||
.cancel,
|
||||
);
|
||||
defer encoded.deinit(std.testing.allocator);
|
||||
encoded.bytes[6] = 3;
|
||||
try std.testing.expectError(
|
||||
error.InvalidFrameKind,
|
||||
decodeFrame(std.testing.allocator, encoded.bytes),
|
||||
);
|
||||
}
|
||||
|
||||
test "host protocol rejects partial truncated oversized and invalid correlation frames" {
|
||||
var encoded = try encodeFrame(
|
||||
std.testing.allocator,
|
||||
contracts.current_protocol_revision,
|
||||
0,
|
||||
.{ .value = 9 },
|
||||
.{ .request = .{ .screen = .{ .session_id = "terminal-1" } } },
|
||||
);
|
||||
defer encoded.deinit(std.testing.allocator);
|
||||
|
||||
try std.testing.expectError(
|
||||
error.TruncatedFrame,
|
||||
decodeFrame(std.testing.allocator, encoded.bytes[0 .. header_len - 1]),
|
||||
);
|
||||
try std.testing.expectError(
|
||||
error.TruncatedFrame,
|
||||
decodeFrame(std.testing.allocator, encoded.bytes[0 .. encoded.bytes.len - 1]),
|
||||
);
|
||||
|
||||
var oversized = encoded.bytes[0..header_len].*;
|
||||
std.mem.writeInt(
|
||||
u32,
|
||||
oversized[24..28],
|
||||
contracts.max_host_frame_bytes + 1,
|
||||
.little,
|
||||
);
|
||||
try std.testing.expectError(
|
||||
error.HostFrameTooLarge,
|
||||
Header.decode(&oversized),
|
||||
);
|
||||
|
||||
var invalid_correlation = encoded.bytes[0..header_len].*;
|
||||
@memset(invalid_correlation[16..24], 0);
|
||||
try std.testing.expectError(
|
||||
error.MissingCorrelationId,
|
||||
Header.decode(&invalid_correlation),
|
||||
);
|
||||
}
|
||||
|
||||
test "host protocol rejects required capabilities before payload allocation" {
|
||||
var encoded = try encodeFrame(
|
||||
std.testing.allocator,
|
||||
contracts.current_protocol_revision,
|
||||
0,
|
||||
null,
|
||||
.{ .hello = .{
|
||||
.range = contracts.local_protocol_range,
|
||||
.capabilities = contracts.known_protocol_capabilities,
|
||||
} },
|
||||
);
|
||||
defer encoded.deinit(std.testing.allocator);
|
||||
std.mem.writeInt(u64, encoded.bytes[8..16], 1 << 63, .little);
|
||||
try std.testing.expectError(
|
||||
error.UnknownRequiredCapability,
|
||||
decodeFrame(std.testing.allocator, encoded.bytes),
|
||||
);
|
||||
}
|
||||
|
||||
test "host protocol preserves opaque terminal output bytes" {
|
||||
const output = [_]u8{ 0, 0x80, 0xff };
|
||||
var encoded = try encodeFrame(
|
||||
std.testing.allocator,
|
||||
contracts.current_protocol_revision,
|
||||
0,
|
||||
.{ .value = 1 },
|
||||
.{ .response = .{ .success = .{ .read = .{
|
||||
.session = .{
|
||||
.session_id = "terminal-opaque",
|
||||
.lifecycle = .running,
|
||||
.attention = .{},
|
||||
.backend = .native,
|
||||
.output_cursor = .{ .segment = 1, .offset = output.len },
|
||||
.screen_recovery = .{ .unavailable = .missing },
|
||||
},
|
||||
.output = &output,
|
||||
.raw_range = .{
|
||||
.start = .{ .segment = 1, .offset = 0 },
|
||||
.end = .{ .segment = 1, .offset = output.len },
|
||||
},
|
||||
} } } },
|
||||
);
|
||||
defer encoded.deinit(std.testing.allocator);
|
||||
var decoded = try decodeFrame(std.testing.allocator, encoded.bytes);
|
||||
defer decoded.deinit();
|
||||
const result = switch (decoded.message().payload) {
|
||||
.response => |response| response,
|
||||
else => return error.TestExpectedResponse,
|
||||
};
|
||||
const read = switch (result) {
|
||||
.success => |success| switch (success) {
|
||||
.read => |value| value,
|
||||
else => return error.TestExpectedRead,
|
||||
},
|
||||
.failure => return error.TestExpectedSuccess,
|
||||
};
|
||||
try std.testing.expectEqualSlices(u8, &output, read.output);
|
||||
}
|
||||
|
||||
fn expectProjectedErrorCode(
|
||||
capabilities: u64,
|
||||
expected: contracts.StructuredErrorCode,
|
||||
) !void {
|
||||
const projected = projectResultForCapabilities(.{ .failure = .{
|
||||
.action = .start,
|
||||
.code = .path_outside_workspace,
|
||||
} }, capabilities);
|
||||
var encoded = try encodeFrame(
|
||||
std.testing.allocator,
|
||||
contracts.current_protocol_revision,
|
||||
0,
|
||||
.{ .value = 1 },
|
||||
.{ .response = projected },
|
||||
);
|
||||
defer encoded.deinit(std.testing.allocator);
|
||||
var decoded = try decodeFrame(std.testing.allocator, encoded.bytes);
|
||||
defer decoded.deinit();
|
||||
const response = switch (decoded.message().payload) {
|
||||
.response => |value| value,
|
||||
else => return error.TestExpectedResponse,
|
||||
};
|
||||
const failure = switch (response) {
|
||||
.failure => |value| value,
|
||||
.success => return error.TestExpectedFailure,
|
||||
};
|
||||
try std.testing.expectEqual(expected, failure.code);
|
||||
}
|
||||
|
||||
test "host protocol projects path scope failures by negotiated capability" {
|
||||
try expectProjectedErrorCode(
|
||||
contracts.protocol_capability_path_outside_workspace_error,
|
||||
.path_outside_workspace,
|
||||
);
|
||||
try expectProjectedErrorCode(0, .invalid_request);
|
||||
|
||||
const generic: contracts.Result = .{ .failure = .{
|
||||
.action = .start,
|
||||
.code = .invalid_request,
|
||||
} };
|
||||
try std.testing.expectEqual(
|
||||
contracts.StructuredErrorCode.invalid_request,
|
||||
projectResultForCapabilities(generic, 0).failure.code,
|
||||
);
|
||||
}
|
||||
|
||||
test "host protocol fuzzes the real untrusted frame decoder" {
|
||||
const Context = struct {
|
||||
fn run(_: @This(), smith: *std.testing.Smith) anyerror!void {
|
||||
var bytes: [4096]u8 = undefined;
|
||||
const len: usize = @intCast(smith.slice(&bytes));
|
||||
var decoded = decodeFrame(
|
||||
std.testing.allocator,
|
||||
bytes[0..len],
|
||||
) catch return;
|
||||
decoded.deinit();
|
||||
}
|
||||
};
|
||||
try std.testing.fuzz(Context{}, Context.run, .{});
|
||||
}
|
||||
@@ -14,9 +14,9 @@ pub const ResolveError = error{
|
||||
pub const Profile = command_environment.Profile;
|
||||
pub const Environment = command_environment.Environment;
|
||||
|
||||
const ShellKind = enum { bash, zsh };
|
||||
pub const ShellKind = enum { bash, zsh };
|
||||
|
||||
fn shellKind(path: []const u8) ?ShellKind {
|
||||
pub fn shellKind(path: []const u8) ?ShellKind {
|
||||
const basename = std.fs.path.basename(path);
|
||||
if (std.mem.eql(u8, basename, "bash")) return .bash;
|
||||
if (std.mem.eql(u8, basename, "zsh")) return .zsh;
|
||||
@@ -36,7 +36,7 @@ fn supportedLoginShell(configured_login_shell: ?[]const u8) ResolveError![]const
|
||||
|
||||
pub const Invocation = struct {
|
||||
path: []const u8,
|
||||
values: [6][]const u8 = @splat(""),
|
||||
values: [8][]const u8 = @splat(""),
|
||||
len: usize = 0,
|
||||
|
||||
pub fn argv(self: *const Invocation) []const []const u8 {
|
||||
@@ -217,6 +217,138 @@ pub fn capturedInvocation(
|
||||
}
|
||||
}
|
||||
|
||||
/// Stderr marker, followed by a nonce, that a snapshot bootstrap writes with
|
||||
/// exit status 125 when the replay stopped before the command could start.
|
||||
pub const snapshot_replay_failure_prefix = "fx-shell-snapshot-replay-failed:";
|
||||
|
||||
/// Builds the clean-shell argv for a snapshot run. The shell sources the
|
||||
/// replay and the command from stdin, so no snapshot text or command appears
|
||||
/// in argv. `failure_marker` reaches stderr, with exit status 125, only when
|
||||
/// the replay stopped before the command started.
|
||||
pub fn snapshotInvocation(
|
||||
alloc: Allocator,
|
||||
shell_path: []const u8,
|
||||
failure_marker: []const u8,
|
||||
) (ResolveError || Allocator.Error)!Invocation {
|
||||
const kind = shellKind(shell_path) orelse return error.UnsupportedShell;
|
||||
var invocation = Invocation{ .path = shell_path };
|
||||
invocation.append(shell_path);
|
||||
switch (kind) {
|
||||
.zsh => invocation.append("-f"),
|
||||
.bash => {
|
||||
invocation.append("--noprofile");
|
||||
invocation.append("--norc");
|
||||
invocation.append("-O");
|
||||
invocation.append("expand_aliases");
|
||||
},
|
||||
}
|
||||
var bootstrap: std.ArrayList(u8) = .empty;
|
||||
errdefer bootstrap.deinit(alloc);
|
||||
try bootstrap.appendSlice(
|
||||
alloc,
|
||||
"\\builtin source /dev/fd/0; __fx_snapshot_status=$?; " ++
|
||||
"if [[ -z ${__fx_snapshot_restored-} ]]; then \\builtin printf '%s' ",
|
||||
);
|
||||
try appendShellWord(&bootstrap, alloc, failure_marker);
|
||||
// Re-raise the common termination signals so the supervisor reports a
|
||||
// signal, as it does when the shell runs the command directly.
|
||||
try bootstrap.appendSlice(
|
||||
alloc,
|
||||
" >&2; \\builtin exit 125; fi; " ++
|
||||
"case $__fx_snapshot_status in 129|130|137|143) " ++
|
||||
"\\builtin kill -$((__fx_snapshot_status - 128)) $$;; esac; " ++
|
||||
"\\builtin exit $__fx_snapshot_status",
|
||||
);
|
||||
invocation.setCommand(try bootstrap.toOwnedSlice(alloc));
|
||||
return invocation;
|
||||
}
|
||||
|
||||
/// Builds the stdin script for a snapshot run: the replay, the restored flag
|
||||
/// the bootstrap checks, and the command. The command is single-quoted so it
|
||||
/// is parsed only when `eval` runs, after the replayed aliases exist, and it
|
||||
/// runs with stdin from /dev/null so it cannot read the rest of the script.
|
||||
pub fn snapshotScript(
|
||||
alloc: Allocator,
|
||||
replay: []const u8,
|
||||
command: []const u8,
|
||||
) Allocator.Error![]u8 {
|
||||
var output: std.ArrayList(u8) = .empty;
|
||||
errdefer output.deinit(alloc);
|
||||
try output.appendSlice(alloc, replay);
|
||||
if (replay.len != 0 and replay[replay.len - 1] != '\n') try output.append(alloc, '\n');
|
||||
try output.appendSlice(alloc, "__fx_snapshot_restored=1\n\\builtin eval -- ");
|
||||
try appendShellWord(&output, alloc, command);
|
||||
try output.appendSlice(alloc, " </dev/null\n");
|
||||
return output.toOwnedSlice(alloc);
|
||||
}
|
||||
|
||||
/// Payload section markers written by `snapshotCaptureScript`. Each marker is
|
||||
/// followed by `:` and the capture nonce.
|
||||
pub const snapshot_marker_prefix = "FXSNAP:";
|
||||
|
||||
const zsh_capture_script =
|
||||
\\{
|
||||
\\\builtin zmodload zsh/parameter 2>/dev/null
|
||||
\\\builtin printf 'FXSNAP:@NONCE@:ENV\0'
|
||||
\\/usr/bin/env -0
|
||||
\\\builtin printf 'FXSNAP:@NONCE@:NAMES\0'
|
||||
\\\builtin printf '%s\0' ${(k)aliases} ${(k)galiases} ${(k)saliases} ${(k)functions}
|
||||
\\\builtin printf 'FXSNAP:@NONCE@:REPLAY\0'
|
||||
\\\builtin typeset -p fpath
|
||||
\\for __fx_snapshot_name in ${(ko)functions}; do
|
||||
\\ case $__fx_snapshot_name in (_*|prompt_*|__fx_snapshot_*) continue;; esac
|
||||
\\ \builtin functions -- $__fx_snapshot_name
|
||||
\\done
|
||||
\\\builtin alias -L
|
||||
\\\builtin alias -sL
|
||||
\\for __fx_snapshot_name in extendedglob globdots nullglob nomatch kshglob shglob globstarshort bareglobqual caseglob numericglobsort rcexpandparam shwordsplit ksharrays magicequalsubst equals braceccl rcquotes shortloops cshnullglob globsubst; do
|
||||
\\ if [[ -o $__fx_snapshot_name ]]; then
|
||||
\\ \builtin print -r -- "\\builtin setopt $__fx_snapshot_name"
|
||||
\\ else
|
||||
\\ \builtin print -r -- "\\builtin unsetopt $__fx_snapshot_name"
|
||||
\\ fi
|
||||
\\done
|
||||
\\\builtin printf '\0FXSNAP:@NONCE@:END\0'
|
||||
\\} 2>/dev/null
|
||||
;
|
||||
|
||||
const bash_capture_script =
|
||||
\\{
|
||||
\\\builtin printf 'FXSNAP:@NONCE@:ENV\0'
|
||||
\\/usr/bin/env -0
|
||||
\\\builtin printf 'FXSNAP:@NONCE@:NAMES\0'
|
||||
\\\builtin printf '%s\0' $(\builtin compgen -a) $(\builtin compgen -A function)
|
||||
\\\builtin printf 'FXSNAP:@NONCE@:REPLAY\0'
|
||||
\\for __fx_snapshot_name in $(\builtin compgen -A function); do
|
||||
\\ case $__fx_snapshot_name in _*|__fx_snapshot_*) continue;; esac
|
||||
\\ \builtin declare -f -- "$__fx_snapshot_name"
|
||||
\\done
|
||||
\\\builtin alias -p
|
||||
\\for __fx_snapshot_name in extglob nullglob dotglob globstar nocaseglob failglob nocasematch globasciiranges; do
|
||||
\\ \builtin shopt -p "$__fx_snapshot_name" 2>/dev/null
|
||||
\\done
|
||||
\\\builtin printf '\0FXSNAP:@NONCE@:END\0'
|
||||
\\} 2>/dev/null
|
||||
;
|
||||
|
||||
/// Builds the script a login shell runs once to capture its state as a
|
||||
/// NUL-delimited payload: exported environment, alias and function names, and
|
||||
/// a replay of functions, aliases, and selected options. Every section marker
|
||||
/// carries `nonce`, so output printed by the user's startup files cannot be
|
||||
/// mistaken for the payload. `nonce` must be ASCII hex.
|
||||
pub fn snapshotCaptureScript(
|
||||
alloc: Allocator,
|
||||
kind: ShellKind,
|
||||
nonce: []const u8,
|
||||
) Allocator.Error![]u8 {
|
||||
for (nonce) |byte| std.debug.assert(std.ascii.isHex(byte));
|
||||
const template = switch (kind) {
|
||||
.zsh => zsh_capture_script,
|
||||
.bash => bash_capture_script,
|
||||
};
|
||||
return std.mem.replaceOwned(u8, alloc, template, "@NONCE@", nonce);
|
||||
}
|
||||
|
||||
pub fn formatInvocationCommand(
|
||||
alloc: Allocator,
|
||||
invocation: *const Invocation,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+490
-661
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -3,6 +3,7 @@ const builtin = @import("builtin");
|
||||
const debug_trace = @import("../shared/debug_trace.zig");
|
||||
const vision_contracts = @import("../agent/runtime/vision_contracts.zig");
|
||||
const command_admission = @import("../permissions/command_admission.zig");
|
||||
const shell_snapshot = @import("../terminal/shell_snapshot.zig");
|
||||
const command_environment = @import("../execution/command_environment.zig");
|
||||
const command_effect = @import("../shell_command/command_effect.zig");
|
||||
const command_lex = @import("../shell_command/command_lex.zig");
|
||||
@@ -1126,7 +1127,13 @@ fn resolveOrdinaryPermissionOutcome(
|
||||
if (permission_mode == .auto) {
|
||||
if (command_call) {
|
||||
const command = try runCommandContext(input, arena, call);
|
||||
// A user-profile alias or function can redefine a routine name,
|
||||
// so those commands go to review instead, as does every command
|
||||
// while a failed capture hides which names are redefined.
|
||||
if (command.execution_mode == .captured and
|
||||
!(command.environment == .user and
|
||||
(shell_snapshot.processOwner().definesAnyWord(command.command) or
|
||||
shell_snapshot.processOwner().startupNamesUnknown())) and
|
||||
try command_effect.knownReversibleAutoCommand(
|
||||
arena,
|
||||
command.command,
|
||||
@@ -1368,16 +1375,25 @@ fn requestPermissionOutcomeResolved(
|
||||
.yolo,
|
||||
), vision_path_authority);
|
||||
}
|
||||
// Detect startup-file edits before matching remembered shell approvals,
|
||||
// so a refresh asks again instead of reusing an approval from before it.
|
||||
const shell_grant_call = std.mem.eql(u8, permissions.permissionNameForTool(permission_name), "bash");
|
||||
if (shell_grant_call) shell_snapshot.processOwner().checkStartupFiles();
|
||||
// A reload changes what the command means, so earlier approvals do not
|
||||
// cover it.
|
||||
const grants_apply = !(shell_grant_call and shellCallRequestsReload(arena, call));
|
||||
var configured_ask = false;
|
||||
var all_targets_authorized_by_rule = true;
|
||||
var used_session_grant = false;
|
||||
var grant_epoch: ?u64 = null;
|
||||
for (policy_targets) |target| {
|
||||
switch (try permissions.ruleDecisionFor(arena, input.permission_rules, input.workspace_root, permission_name, target.path, target_kind)) {
|
||||
.deny => return .{ .decision = .policy_denied },
|
||||
.allow => {},
|
||||
.ask => {
|
||||
if (permissions.sessionGrantAllowed(local_grants, permission_name, target.path)) {
|
||||
if (grants_apply and permissions.sessionGrantAllowed(local_grants, permission_name, target.path)) {
|
||||
used_session_grant = true;
|
||||
if (shell_grant_call) grant_epoch = shell_snapshot.processOwner().shellGrantEpoch(target.path);
|
||||
continue;
|
||||
}
|
||||
configured_ask = true;
|
||||
@@ -1427,17 +1443,23 @@ fn requestPermissionOutcomeResolved(
|
||||
}
|
||||
|
||||
if (all_targets_authorized_by_rule) {
|
||||
return bindVisionPathExecutionAuthority(try permissionOutcomeForDecision(
|
||||
return bindVisionPathExecutionAuthority(withShellGrantEpoch(try permissionOutcomeForDecision(
|
||||
input,
|
||||
arena,
|
||||
call,
|
||||
.once,
|
||||
if (used_session_grant) .session_grant else .configured_rule,
|
||||
), vision_path_authority);
|
||||
), grant_epoch), vision_path_authority);
|
||||
}
|
||||
if (sessionGrantsAllowAll(local_grants, permission_name, policy_targets)) {
|
||||
if (grants_apply and sessionGrantsAllowAll(local_grants, permission_name, policy_targets)) {
|
||||
if (shell_grant_call and policy_targets.len > 0) {
|
||||
grant_epoch = shell_snapshot.processOwner().shellGrantEpoch(policy_targets[0].path);
|
||||
}
|
||||
return bindVisionPathExecutionAuthority(
|
||||
try permissionOutcomeForDecision(input, arena, call, .once, .session_grant),
|
||||
withShellGrantEpoch(
|
||||
try permissionOutcomeForDecision(input, arena, call, .once, .session_grant),
|
||||
grant_epoch,
|
||||
),
|
||||
vision_path_authority,
|
||||
);
|
||||
}
|
||||
@@ -1924,6 +1946,32 @@ fn permissionOutcomeForDecision(
|
||||
return shellPermissionOutcome(command_ctx, decision, source);
|
||||
}
|
||||
|
||||
/// Reports whether a shell call asks to reload the user's startup files.
|
||||
fn shellCallRequestsReload(arena: Allocator, call: ToolCall) bool {
|
||||
const value = std.json.parseFromSliceLeaky(std.json.Value, arena, call.arguments_json, .{}) catch return false;
|
||||
if (value != .object) return false;
|
||||
const request = value.object.get("request") orelse return false;
|
||||
if (request != .object) return false;
|
||||
const reload = request.object.get("reload") orelse return false;
|
||||
return reload == .bool and reload.bool;
|
||||
}
|
||||
|
||||
/// Binds the remembered grant's shell snapshot epoch to a shell admission.
|
||||
fn withShellGrantEpoch(
|
||||
outcome: command_admission.PermissionOutcome,
|
||||
grant_epoch: ?u64,
|
||||
) command_admission.PermissionOutcome {
|
||||
var bound = outcome;
|
||||
if (bound.execution_authority) |*authority| switch (authority.*) {
|
||||
.run_command => |*command| switch (command.*) {
|
||||
.shell_allowed => |*shell| shell.grant_epoch = grant_epoch,
|
||||
.direct_only => {},
|
||||
},
|
||||
else => {},
|
||||
};
|
||||
return bound;
|
||||
}
|
||||
|
||||
fn shellPermissionOutcome(
|
||||
command_ctx: command_admission.CommandContext,
|
||||
decision: ToolPermissionDecision,
|
||||
@@ -5584,7 +5632,103 @@ test "TTY admission fingerprints route and explicit shell startup" {
|
||||
));
|
||||
}
|
||||
|
||||
fn npmAliasSnapshotCapture(request: shell_snapshot.CaptureRequest) shell_snapshot.CaptureOutcome {
|
||||
const generation = shell_snapshot.Generation.create() catch return .{ .failed = .out_of_memory };
|
||||
const alloc = generation.arena.allocator();
|
||||
generation.shell_path = alloc.dupe(u8, request.shell_path) catch {
|
||||
generation.destroy();
|
||||
return .{ .failed = .out_of_memory };
|
||||
};
|
||||
generation.names.put(alloc, "npm", {}) catch {
|
||||
generation.destroy();
|
||||
return .{ .failed = .out_of_memory };
|
||||
};
|
||||
return .{ .ready = generation };
|
||||
}
|
||||
|
||||
test "routine commands that a user alias or function redefines go to review" {
|
||||
shell_snapshot.resetProcessOwnerForTest(npmAliasSnapshotCapture);
|
||||
defer shell_snapshot.resetProcessOwnerForTest(shell_snapshot.real_capture_fn);
|
||||
const lease = (try shell_snapshot.processOwner().acquire("/bin/zsh", "/tmp", .{})).snapshot;
|
||||
lease.release();
|
||||
|
||||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
var worker: WorkerRuntime = .{};
|
||||
defer worker.deinit(std.testing.allocator);
|
||||
var fake = FakeAutoClassifier{};
|
||||
const input = testInputWithClassifier(
|
||||
&worker,
|
||||
permission_auto_classifier.Classifier.withOverride(
|
||||
@ptrCast(&fake),
|
||||
FakeAutoClassifier.classify,
|
||||
),
|
||||
);
|
||||
const redefined = try requestPermissionOutcome(
|
||||
input,
|
||||
arena_state.allocator(),
|
||||
.{ .id = "aliased", .name = "shell", .arguments_json = "{\"action\":\"run\",\"command\":\"npm install 2>&1\"}" },
|
||||
.auto,
|
||||
&.{},
|
||||
);
|
||||
try std.testing.expectEqual(
|
||||
command_admission.ShellAuthorizationSource.auto_classifier,
|
||||
redefined.execution_authority.?.run_command.shell_allowed.source,
|
||||
);
|
||||
try std.testing.expectEqual(@as(usize, 1), fake.calls);
|
||||
|
||||
const routine = try requestPermissionOutcome(
|
||||
input,
|
||||
arena_state.allocator(),
|
||||
.{ .id = "routine", .name = "shell", .arguments_json = "{\"action\":\"run\",\"command\":\"git status --short\"}" },
|
||||
.auto,
|
||||
&.{},
|
||||
);
|
||||
try std.testing.expectEqual(
|
||||
command_admission.ShellAuthorizationSource.auto_mode,
|
||||
routine.execution_authority.?.run_command.shell_allowed.source,
|
||||
);
|
||||
try std.testing.expectEqual(@as(usize, 1), fake.calls);
|
||||
}
|
||||
|
||||
fn failedSnapshotCapture(_: shell_snapshot.CaptureRequest) shell_snapshot.CaptureOutcome {
|
||||
return .{ .failed = .too_large };
|
||||
}
|
||||
|
||||
test "routine commands go to review while the snapshot has fallen back" {
|
||||
shell_snapshot.resetProcessOwnerForTest(failedSnapshotCapture);
|
||||
defer shell_snapshot.resetProcessOwnerForTest(shell_snapshot.real_capture_fn);
|
||||
try std.testing.expect((try shell_snapshot.processOwner().acquire("/bin/zsh", "/tmp", .{})) == .full_startup);
|
||||
|
||||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
var worker: WorkerRuntime = .{};
|
||||
defer worker.deinit(std.testing.allocator);
|
||||
var fake = FakeAutoClassifier{};
|
||||
const input = testInputWithClassifier(
|
||||
&worker,
|
||||
permission_auto_classifier.Classifier.withOverride(
|
||||
@ptrCast(&fake),
|
||||
FakeAutoClassifier.classify,
|
||||
),
|
||||
);
|
||||
const outcome = try requestPermissionOutcome(
|
||||
input,
|
||||
arena_state.allocator(),
|
||||
.{ .id = "fallback", .name = "shell", .arguments_json = "{\"action\":\"run\",\"command\":\"git status --short\"}" },
|
||||
.auto,
|
||||
&.{},
|
||||
);
|
||||
try std.testing.expectEqual(
|
||||
command_admission.ShellAuthorizationSource.auto_classifier,
|
||||
outcome.execution_authority.?.run_command.shell_allowed.source,
|
||||
);
|
||||
try std.testing.expectEqual(@as(usize, 1), fake.calls);
|
||||
}
|
||||
|
||||
test "known reversible auto commands bypass the reviewer" {
|
||||
// An earlier test's snapshot could redefine a routine name.
|
||||
shell_snapshot.resetProcessOwnerForTest(shell_snapshot.real_capture_fn);
|
||||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
var worker: WorkerRuntime = .{};
|
||||
|
||||
@@ -58,6 +58,52 @@ pub fn terminalActionFieldCorrectionJson(
|
||||
return try out.toOwnedSlice();
|
||||
}
|
||||
|
||||
/// Code of the shell failure for a terminal that ended with the fx process
|
||||
/// that started it. Such terminals are never reattached.
|
||||
pub const terminal_ended_error_code = "TerminalEnded";
|
||||
|
||||
pub fn isTerminalEndedFailure(
|
||||
alloc: Allocator,
|
||||
output: []const u8,
|
||||
) Allocator.Error!bool {
|
||||
var parsed = std.json.parseFromSlice(
|
||||
std.json.Value,
|
||||
alloc,
|
||||
output,
|
||||
.{},
|
||||
) catch |err| return switch (err) {
|
||||
error.OutOfMemory => error.OutOfMemory,
|
||||
else => false,
|
||||
};
|
||||
defer parsed.deinit();
|
||||
const root = switch (parsed.value) {
|
||||
.object => |value| value,
|
||||
else => return false,
|
||||
};
|
||||
const error_value = switch (root.get("error") orelse return false) {
|
||||
.object => |value| value,
|
||||
else => return false,
|
||||
};
|
||||
const code = switch (error_value.get("code") orelse return false) {
|
||||
.string => |value| value,
|
||||
else => return false,
|
||||
};
|
||||
return std.mem.eql(u8, code, terminal_ended_error_code);
|
||||
}
|
||||
|
||||
test "terminal ended failures are recognized only by their exact code" {
|
||||
const alloc = std.testing.allocator;
|
||||
try std.testing.expect(try isTerminalEndedFailure(
|
||||
alloc,
|
||||
"{\"error\":{\"tool\":\"shell\",\"code\":\"TerminalEnded\"}}",
|
||||
));
|
||||
try std.testing.expect(!try isTerminalEndedFailure(
|
||||
alloc,
|
||||
"{\"error\":{\"tool\":\"shell\",\"code\":\"TerminalSessionLost\"}}",
|
||||
));
|
||||
try std.testing.expect(!try isTerminalEndedFailure(alloc, "TerminalEnded"));
|
||||
}
|
||||
|
||||
pub fn inspectTerminalActionFieldCorrection(
|
||||
alloc: Allocator,
|
||||
output: []const u8,
|
||||
|
||||
+10
-54
@@ -105,9 +105,7 @@ const compiled_update_channel = update_target.Channel.parse(build_options.update
|
||||
const shell_process_provider = @import("tools/shell/process_provider.zig");
|
||||
const process_provider = @import("core/execution/process_provider.zig");
|
||||
const terminal_client_runtime = @import("core/terminal/client.zig");
|
||||
const terminal_host = @import("core/terminal/host.zig");
|
||||
const terminal_native_session = @import("core/terminal/native_session.zig");
|
||||
const terminal_tmux_session = @import("core/terminal/tmux_session.zig");
|
||||
const session_runtime = @import("core/session/session.zig");
|
||||
const session_codec = @import("core/session/session_codec.zig");
|
||||
const session_child_store = @import("core/session/session_child_store.zig");
|
||||
@@ -882,6 +880,11 @@ const App = struct {
|
||||
WorkspaceAppRuntime.requestStop(self);
|
||||
self.managed_executions.terminateForProcessExit();
|
||||
shutdown_trace.mark("background_stops_requested");
|
||||
// Terminals belong to this process and end with it, with no prompt.
|
||||
// Subagents share this client, so theirs end here too. Ending them
|
||||
// also releases a worker waiting on one.
|
||||
self.terminal_client.closeOwnedTerminals();
|
||||
shutdown_trace.mark("terminals_ended");
|
||||
|
||||
// The worker mutates session state, so it stops before persistence.
|
||||
if (self.worker_thread) |thread| thread.join();
|
||||
@@ -943,6 +946,7 @@ const App = struct {
|
||||
self.worker.requestShutdown();
|
||||
SessionAppRuntime.requestPersistenceShutdown(self);
|
||||
self.managed_executions.shutdown();
|
||||
self.terminal_client.closeOwnedTerminals();
|
||||
self.upgrader.stop();
|
||||
self.file_index.requestStop();
|
||||
WorkspaceAppRuntime.requestStop(self);
|
||||
@@ -3088,6 +3092,7 @@ const App = struct {
|
||||
try app_commands.Handlers(App).collectMcpAuthenticationFacts(self);
|
||||
try app_commands.Handlers(App).collectMcpReloadFacts(self);
|
||||
try app_commands.Handlers(App).collectMcpStartupHealthFacts(self);
|
||||
try app_commands.Handlers(App).collectShellSnapshotFacts(self);
|
||||
if (try self.mcp.refreshMenuHealth(self.alloc, @intCast(@max(io_mod.milliTimestamp(), 0)))) {
|
||||
RenderAppRuntime.requestActiveSurfaceFrame(self, .footer);
|
||||
}
|
||||
@@ -3418,35 +3423,7 @@ fn mainC(c_argc: c_int, c_argv: [*][*:0]c_char, c_envp: [*:null]?[*:0]c_char) !v
|
||||
const raw_args = rawArgs(c_argc, c_argv);
|
||||
const raw_env: RawEnviron = @ptrCast(c_envp);
|
||||
|
||||
if (comptime terminal_host.isSupported()) {
|
||||
if (terminal_tmux_session.isCaptureModeRaw(raw_args)) {
|
||||
io_mod.setRawEnviron(raw_env);
|
||||
const process_args = argsFromRaw(raw_args);
|
||||
var threaded = std.Io.Threaded.init(processAllocator(), .{
|
||||
.argv0 = .init(process_args),
|
||||
.environ = .{ .block = environBlockFromRaw(raw_env) },
|
||||
});
|
||||
defer threaded.deinit();
|
||||
io_mod.setIo(threaded.io());
|
||||
try terminal_tmux_session.runCapture(raw_args);
|
||||
return;
|
||||
}
|
||||
if (terminal_tmux_session.isLauncherModeRaw(raw_args)) {
|
||||
io_mod.setRawEnviron(raw_env);
|
||||
const process_args = argsFromRaw(raw_args);
|
||||
var threaded = std.Io.Threaded.init(processAllocator(), .{
|
||||
.argv0 = .init(process_args),
|
||||
.environ = .{ .block = environBlockFromRaw(raw_env) },
|
||||
});
|
||||
defer threaded.deinit();
|
||||
io_mod.setIo(threaded.io());
|
||||
try terminal_tmux_session.runLauncher(
|
||||
processAllocator(),
|
||||
shell_process_provider.provider,
|
||||
raw_args,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (comptime terminal_native_session.isSupported()) {
|
||||
if (terminal_native_session.isControlModeRaw(raw_args)) {
|
||||
io_mod.setRawEnviron(raw_env);
|
||||
const process_args = argsFromRaw(raw_args);
|
||||
@@ -3471,25 +3448,6 @@ fn mainC(c_argc: c_int, c_argv: [*][*:0]c_char, c_envp: [*:null]?[*:0]c_char) !v
|
||||
try terminal_native_session.runLauncher(processAllocator());
|
||||
return;
|
||||
}
|
||||
if (terminal_host.isInternalModeRaw(raw_args)) {
|
||||
io_mod.setRawEnviron(raw_env);
|
||||
const process_args = argsFromRaw(raw_args);
|
||||
var threaded = std.Io.Threaded.init(processAllocator(), .{
|
||||
.argv0 = .init(process_args),
|
||||
.environ = .{ .block = environBlockFromRaw(raw_env) },
|
||||
});
|
||||
defer threaded.deinit();
|
||||
io_mod.setIo(threaded.io());
|
||||
defer debug_trace.shutdown();
|
||||
debug_trace.configureFromEnv(processAllocator(), ".");
|
||||
try terminal_host.run(
|
||||
processAllocator(),
|
||||
try terminal_host.Config.fromEnvironment(
|
||||
shell_process_provider.provider,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldRunBenchmarkNoArgRaw(raw_args, raw_env)) {
|
||||
@@ -4892,6 +4850,7 @@ test {
|
||||
_ = @import("core/shared/token_estimate.zig");
|
||||
_ = @import("core/shell_command/command_effect.zig");
|
||||
_ = @import("core/execution/router.zig");
|
||||
_ = @import("core/execution/command_runner.zig");
|
||||
_ = @import("core/permissions/direct_command.zig");
|
||||
_ = @import("core/permissions/auto_classifier.zig");
|
||||
_ = @import("core/permissions/command_admission.zig");
|
||||
@@ -4942,14 +4901,11 @@ test {
|
||||
_ = @import("core/subagent/approval_registry.zig");
|
||||
_ = @import("core/terminal/contracts.zig");
|
||||
_ = @import("core/terminal/operation.zig");
|
||||
_ = @import("core/terminal/protocol.zig");
|
||||
_ = @import("core/terminal/host_policy.zig");
|
||||
_ = @import("core/terminal/shell_resolver.zig");
|
||||
_ = @import("core/terminal/shell_snapshot.zig");
|
||||
_ = @import("core/terminal/native_session.zig");
|
||||
_ = @import("core/terminal/recovery.zig");
|
||||
_ = @import("core/terminal/store.zig");
|
||||
_ = @import("core/terminal/host.zig");
|
||||
_ = @import("core/terminal/tmux_session.zig");
|
||||
_ = @import("core/terminal/client.zig");
|
||||
_ = @import("core/terminal/managed_observer.zig");
|
||||
_ = @import("tools/shell/shell.zig");
|
||||
|
||||
@@ -1,574 +0,0 @@
|
||||
const std = @import("std");
|
||||
const builtin = @import("builtin");
|
||||
const shell_process_provider = @import("tools/shell/process_provider.zig");
|
||||
const process_provider_mod = @import("core/execution/process_provider.zig");
|
||||
const process_identity = @import("core/execution/process_identity.zig");
|
||||
const client = @import("core/terminal/client.zig");
|
||||
const contracts = @import("core/terminal/contracts.zig");
|
||||
const debug_trace = @import("core/shared/debug_trace.zig");
|
||||
const host = @import("core/terminal/host.zig");
|
||||
const io_mod = @import("core/shared/io.zig");
|
||||
const operation = @import("core/terminal/operation.zig");
|
||||
const policy = @import("core/terminal/host_policy.zig");
|
||||
const profile_paths = @import("core/shared/profile_paths.zig");
|
||||
const command_runner = @import("core/execution/command_runner.zig");
|
||||
const session_child_store = @import("core/session/session_child_store.zig");
|
||||
const store = @import("core/terminal/store.zig");
|
||||
const native_session = @import("core/terminal/native_session.zig");
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
const process_allocator = if (builtin.link_libc)
|
||||
std.heap.c_allocator
|
||||
else
|
||||
std.heap.page_allocator;
|
||||
const fixture_owner_session_id = "terminal-fixture-owner";
|
||||
const fixture_profile_user = "terminal-fixture-profile";
|
||||
const protocol_fixture_profile_user = "terminal-fixture";
|
||||
const fixture_marker = "authority-reload-ready";
|
||||
const fixture_observation_timeout_ms = 120_000;
|
||||
|
||||
comptime {
|
||||
@export(&main, .{ .name = "main" });
|
||||
}
|
||||
|
||||
pub fn main(
|
||||
argc: c_int,
|
||||
argv: [*][*:0]c_char,
|
||||
environ: [*:null]?[*:0]c_char,
|
||||
) callconv(.c) c_int {
|
||||
mainInner(argc, argv, environ) catch return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
fn mainInner(
|
||||
argc: c_int,
|
||||
argv: [*][*:0]c_char,
|
||||
environ: [*:null]?[*:0]c_char,
|
||||
) !void {
|
||||
const args: []const [*:0]const u8 = @as(
|
||||
[*][*:0]const u8,
|
||||
@ptrCast(argv),
|
||||
)[0..@intCast(argc)];
|
||||
const raw_environ: io_mod.RawEnviron = @ptrCast(environ);
|
||||
io_mod.setRawEnviron(raw_environ);
|
||||
var environment_count: usize = 0;
|
||||
while (raw_environ[environment_count] != null) : (environment_count += 1) {}
|
||||
var threaded = std.Io.Threaded.init(process_allocator, .{
|
||||
.argv0 = .init(.{ .vector = args }),
|
||||
.environ = .{ .block = .{
|
||||
.slice = raw_environ[0..environment_count :null],
|
||||
} },
|
||||
});
|
||||
defer threaded.deinit();
|
||||
io_mod.setIo(threaded.io());
|
||||
defer debug_trace.shutdown();
|
||||
debug_trace.configureFromEnv(process_allocator, ".");
|
||||
if (native_session.isControlModeRaw(args)) {
|
||||
return native_session.runControlMarker(args);
|
||||
}
|
||||
if (native_session.isLauncherModeRaw(args)) {
|
||||
return native_session.runLauncher(process_allocator);
|
||||
}
|
||||
if (host.isInternalModeRaw(args)) {
|
||||
var failure_provider = CaptureFailureProvider{
|
||||
.delegate = shell_process_provider.provider,
|
||||
};
|
||||
const provider = if (io_mod.getenv(
|
||||
"FX_TERMINAL_FIXTURE_FAIL_PROCESS_TOKEN",
|
||||
) != null)
|
||||
failure_provider.provider()
|
||||
else
|
||||
shell_process_provider.provider;
|
||||
return host.run(
|
||||
process_allocator,
|
||||
try host.Config.fromEnvironment(provider),
|
||||
);
|
||||
}
|
||||
var cli_arg_buffer: [64][:0]const u8 = undefined;
|
||||
if (args.len - 1 > cli_arg_buffer.len) return error.TooManyArguments;
|
||||
for (args[1..], 0..) |arg, index| {
|
||||
cli_arg_buffer[index] = std.mem.sliceTo(arg, 0);
|
||||
}
|
||||
const cli_args = cli_arg_buffer[0 .. args.len - 1];
|
||||
if (command_runner.isForegroundSessionInvocation(cli_args)) {
|
||||
return command_runner.runForegroundSessionBootstrap(cli_args);
|
||||
}
|
||||
try runFixture(process_allocator, shell_process_provider.provider);
|
||||
}
|
||||
|
||||
const CaptureFailureProvider = struct {
|
||||
delegate: process_provider_mod.Provider,
|
||||
captures: usize = 0,
|
||||
|
||||
fn provider(self: *@This()) process_provider_mod.Provider {
|
||||
return .{
|
||||
.context = self,
|
||||
.capture_token_fn = captureToken,
|
||||
.match_token_fn = matchToken,
|
||||
.signal_process_fn = signalProcess,
|
||||
};
|
||||
}
|
||||
|
||||
fn from(raw: ?*anyopaque) *@This() {
|
||||
return @ptrCast(@alignCast(raw.?));
|
||||
}
|
||||
|
||||
fn captureToken(
|
||||
raw: ?*anyopaque,
|
||||
alloc: Allocator,
|
||||
pid: []const u8,
|
||||
) process_provider_mod.ProviderError!process_identity.ProcessInstanceToken {
|
||||
const self = from(raw);
|
||||
self.captures += 1;
|
||||
if (self.captures > 2) return error.ProcessIdentityUnavailable;
|
||||
return self.delegate.captureToken(alloc, pid);
|
||||
}
|
||||
|
||||
fn matchToken(
|
||||
raw: ?*anyopaque,
|
||||
alloc: Allocator,
|
||||
pid: []const u8,
|
||||
expected: process_identity.ProcessInstanceToken,
|
||||
) process_identity.TokenMatch {
|
||||
return from(raw).delegate.matchToken(alloc, pid, expected);
|
||||
}
|
||||
|
||||
fn signalProcess(
|
||||
raw: ?*anyopaque,
|
||||
alloc: Allocator,
|
||||
pid: []const u8,
|
||||
expected: process_identity.ProcessInstanceToken,
|
||||
) process_provider_mod.ProviderError!void {
|
||||
return from(raw).delegate.signalProcess(alloc, pid, expected);
|
||||
}
|
||||
};
|
||||
|
||||
fn runFixture(
|
||||
alloc: Allocator,
|
||||
process_provider_value: process_provider_mod.Provider,
|
||||
) !void {
|
||||
if (io_mod.getenv("FX_TERMINAL_CAPABILITY_FIXTURE")) |mode| {
|
||||
if (std.mem.eql(u8, mode, "start")) {
|
||||
return runCapabilityStartFixture(alloc, process_provider_value);
|
||||
}
|
||||
if (std.mem.eql(u8, mode, "force_close")) {
|
||||
return runCapabilityForceCloseFixture(alloc, process_provider_value);
|
||||
}
|
||||
return error.InvalidTerminalCapabilityFixtureMode;
|
||||
}
|
||||
if (io_mod.getenv("FX_TERMINAL_OUTCOME_FIXTURE")) |mode| {
|
||||
if (std.mem.eql(u8, mode, "retention")) {
|
||||
return runOutcomeRetentionFixture(alloc, process_provider_value);
|
||||
}
|
||||
if (std.mem.eql(u8, mode, "failure")) {
|
||||
return runOutcomeFailureFixture(alloc, process_provider_value);
|
||||
}
|
||||
return error.InvalidTerminalOutcomeFixtureMode;
|
||||
}
|
||||
if (io_mod.getenv("FX_TERMINAL_AUTHORITY_FIXTURE")) |mode| {
|
||||
if (std.mem.eql(u8, mode, "start")) {
|
||||
return runAuthorityStartFixture(alloc, process_provider_value);
|
||||
}
|
||||
if (std.mem.eql(u8, mode, "reload")) {
|
||||
return runAuthorityReloadFixture(alloc, process_provider_value);
|
||||
}
|
||||
return error.InvalidTerminalAuthorityFixtureMode;
|
||||
}
|
||||
|
||||
var runtime = client.Runtime.init(process_provider_value);
|
||||
defer runtime.deinit();
|
||||
const correlation_id = contracts.CorrelationId{ .value = 1 };
|
||||
try runtime.admit(
|
||||
alloc,
|
||||
correlation_id,
|
||||
.{ .screen = .{ .session_id = "terminal-fixture" } },
|
||||
);
|
||||
var completion = try awaitCompletionFor(&runtime, correlation_id);
|
||||
defer completion.deinit();
|
||||
try writeCompletionJson(alloc, completion);
|
||||
}
|
||||
|
||||
fn runCapabilityStartFixture(
|
||||
alloc: Allocator,
|
||||
process_provider: process_provider_mod.Provider,
|
||||
) !void {
|
||||
const home = io_mod.getenv("HOME") orelse return error.HomeNotSet;
|
||||
var runtime = client.Runtime.init(process_provider);
|
||||
defer runtime.deinit();
|
||||
const correlation_id = contracts.CorrelationId{ .value = 1 };
|
||||
try runtime.admit(alloc, correlation_id, .{ .start = .{ .cwd = home } });
|
||||
var completion = try awaitCompletionFor(&runtime, correlation_id);
|
||||
defer completion.deinit();
|
||||
try writeCompletionJson(alloc, completion);
|
||||
}
|
||||
|
||||
fn runCapabilityForceCloseFixture(
|
||||
alloc: Allocator,
|
||||
process_provider: process_provider_mod.Provider,
|
||||
) !void {
|
||||
const home = io_mod.getenv("HOME") orelse return error.HomeNotSet;
|
||||
const terminal_session_id = io_mod.getenv(
|
||||
"FX_TERMINAL_AUTHORITY_SESSION_ID",
|
||||
) orelse return error.TerminalAuthorityFixtureSessionMissing;
|
||||
var owner = try openFixtureOwnerCapability(alloc, home);
|
||||
defer owner.deinit();
|
||||
var loaded = try store.reloadAuthorityClaim(alloc, &owner, .{
|
||||
.terminal_session_id = terminal_session_id,
|
||||
.principal = authorityFixturePrincipal(home),
|
||||
.actor = .agent,
|
||||
.generation = .{ .value = 1 },
|
||||
});
|
||||
defer loaded.deinit();
|
||||
|
||||
var runtime = client.Runtime.init(process_provider);
|
||||
defer runtime.deinit();
|
||||
const correlation_id = contracts.CorrelationId{ .value = 1 };
|
||||
try runtime.admit(alloc, correlation_id, .{ .close = .{
|
||||
.session_id = terminal_session_id,
|
||||
.policy = .force,
|
||||
.authority = loaded.view(),
|
||||
} });
|
||||
var completion = try awaitCompletionFor(&runtime, correlation_id);
|
||||
defer completion.deinit();
|
||||
try writeCompletionJson(alloc, completion);
|
||||
}
|
||||
|
||||
fn fixturePreparation(home: []const u8) operation.AuthorityPreparation {
|
||||
return .{
|
||||
.profile_user = fixture_profile_user,
|
||||
.durable_session_id = fixture_owner_session_id,
|
||||
.workspace_root = home,
|
||||
.cwd = home,
|
||||
.transport_role = .interactive,
|
||||
.backend = .native,
|
||||
.actor = .agent,
|
||||
.controls = .full(),
|
||||
.lifetime = .session,
|
||||
};
|
||||
}
|
||||
|
||||
fn fixturePrincipal(home: []const u8) contracts.Principal {
|
||||
const input = fixturePreparation(home);
|
||||
return .{
|
||||
.profile_user = input.profile_user,
|
||||
.durable_session_id = input.durable_session_id,
|
||||
.workspace_root = input.workspace_root,
|
||||
.cwd = input.cwd,
|
||||
.transport_role = input.transport_role,
|
||||
.backend = input.backend,
|
||||
.lifetime = input.lifetime,
|
||||
};
|
||||
}
|
||||
|
||||
fn authorityFixturePrincipal(home: []const u8) contracts.Principal {
|
||||
var principal = fixturePrincipal(home);
|
||||
if (io_mod.getenv("FX_TERMINAL_AUTHORITY_FIXTURE_COMPAT")) |value| {
|
||||
if (std.mem.eql(u8, value, "1")) {
|
||||
principal.profile_user = protocol_fixture_profile_user;
|
||||
}
|
||||
}
|
||||
return principal;
|
||||
}
|
||||
|
||||
fn runAuthorityStartFixture(
|
||||
alloc: Allocator,
|
||||
process_provider: process_provider_mod.Provider,
|
||||
) !void {
|
||||
const home = io_mod.getenv("HOME") orelse return error.HomeNotSet;
|
||||
const preparation = fixturePreparation(home);
|
||||
var prepared = try operation.prepareStartPersistence(alloc, preparation);
|
||||
defer prepared.deinit();
|
||||
var runtime = client.Runtime.init(process_provider);
|
||||
defer runtime.deinit();
|
||||
try runtime.admit(alloc, .{ .value = 1 }, .{ .start = .{
|
||||
.cwd = home,
|
||||
.command = "printf 'authority-reload-ready\\n'; sleep 30",
|
||||
.shell = .{ .executable = .{
|
||||
.path = "/bin/bash",
|
||||
.clean_start = true,
|
||||
} },
|
||||
.return_when = .{ .match = fixture_marker },
|
||||
.wait_ceiling_ms = 5_000,
|
||||
.persistence = prepared.view(),
|
||||
} });
|
||||
var completion = try awaitCompletionFor(&runtime, .{ .value = 1 });
|
||||
defer completion.deinit();
|
||||
const result = try fixtureSuccess(completion, .start);
|
||||
const session_id = switch (result) {
|
||||
.start => |value| value.session.session_id,
|
||||
else => return error.TerminalAuthorityFixtureUnexpectedResult,
|
||||
};
|
||||
try writeJson(alloc, .{
|
||||
.session_id = session_id,
|
||||
.generation = prepared.view().grant.generation.value,
|
||||
.minted = true,
|
||||
});
|
||||
}
|
||||
|
||||
fn runAuthorityReloadFixture(
|
||||
alloc: Allocator,
|
||||
process_provider: process_provider_mod.Provider,
|
||||
) !void {
|
||||
const home = io_mod.getenv("HOME") orelse return error.HomeNotSet;
|
||||
const terminal_session_id = io_mod.getenv(
|
||||
"FX_TERMINAL_AUTHORITY_SESSION_ID",
|
||||
) orelse return error.TerminalAuthorityFixtureSessionMissing;
|
||||
var owner = try openFixtureOwnerCapability(alloc, home);
|
||||
defer owner.deinit();
|
||||
var loaded = try store.reloadAuthorityClaim(alloc, &owner, .{
|
||||
.terminal_session_id = terminal_session_id,
|
||||
.principal = authorityFixturePrincipal(home),
|
||||
.actor = .agent,
|
||||
.generation = .{ .value = 1 },
|
||||
});
|
||||
defer loaded.deinit();
|
||||
|
||||
var runtime = client.Runtime.init(process_provider);
|
||||
defer runtime.deinit();
|
||||
try runtime.admit(alloc, .{ .value = 1 }, .{ .inspect = .{
|
||||
.session_id = terminal_session_id,
|
||||
.authority = loaded.view(),
|
||||
} });
|
||||
var inspected = try awaitCompletionFor(&runtime, .{ .value = 1 });
|
||||
defer inspected.deinit();
|
||||
_ = try fixtureSuccess(inspected, .inspect);
|
||||
|
||||
try runtime.admit(alloc, .{ .value = 2 }, .{ .read = .{
|
||||
.session_id = terminal_session_id,
|
||||
.cursor = .{ .segment = 1, .offset = 0 },
|
||||
.authority = loaded.view(),
|
||||
} });
|
||||
var read = try awaitCompletionFor(&runtime, .{ .value = 2 });
|
||||
defer read.deinit();
|
||||
const result = try fixtureSuccess(read, .read);
|
||||
const output = switch (result) {
|
||||
.read => |value| value.output,
|
||||
else => return error.TerminalAuthorityFixtureUnexpectedResult,
|
||||
};
|
||||
if (std.mem.find(u8, output, fixture_marker) == null) {
|
||||
return error.TerminalAuthorityFixtureMarkerMissing;
|
||||
}
|
||||
|
||||
try runtime.admit(alloc, .{ .value = 3 }, .{ .close = .{
|
||||
.session_id = terminal_session_id,
|
||||
.policy = .graceful,
|
||||
.authority = loaded.view(),
|
||||
} });
|
||||
var closed = try awaitCompletionFor(&runtime, .{ .value = 3 });
|
||||
defer closed.deinit();
|
||||
_ = try fixtureSuccess(closed, .close);
|
||||
try writeJson(alloc, .{
|
||||
.read = true,
|
||||
.inspect = true,
|
||||
.closed = true,
|
||||
});
|
||||
}
|
||||
|
||||
fn openFixtureOwnerCapability(
|
||||
alloc: Allocator,
|
||||
home: []const u8,
|
||||
) !session_child_store.SessionChildCapability {
|
||||
const sessions_path = try profile_paths.sessionsDir(alloc, home);
|
||||
defer alloc.free(sessions_path);
|
||||
const owner_path = try std.fs.path.join(
|
||||
alloc,
|
||||
&.{ sessions_path, fixture_owner_session_id },
|
||||
);
|
||||
defer alloc.free(owner_path);
|
||||
var owner_dir = try std.Io.Dir.openDirAbsolute(io_mod.getIo(), owner_path, .{
|
||||
.iterate = true,
|
||||
.follow_symlinks = false,
|
||||
});
|
||||
defer owner_dir.close(io_mod.getIo());
|
||||
return session_child_store.SessionChildCapability.init(
|
||||
alloc,
|
||||
owner_dir,
|
||||
owner_path,
|
||||
.read_only,
|
||||
);
|
||||
}
|
||||
|
||||
fn runOutcomeRetentionFixture(
|
||||
alloc: Allocator,
|
||||
process_provider: process_provider_mod.Provider,
|
||||
) !void {
|
||||
var runtime = client.Runtime.init(process_provider);
|
||||
defer runtime.deinit();
|
||||
for (1..policy.outcome_capacity + 1) |value| {
|
||||
const correlation_id = contracts.CorrelationId{ .value = value };
|
||||
const deadline = io_mod.milliTimestamp() + 8_000;
|
||||
while (true) {
|
||||
runtime.admit(
|
||||
alloc,
|
||||
correlation_id,
|
||||
.{ .screen = .{ .session_id = "terminal-retention" } },
|
||||
) catch |err| switch (err) {
|
||||
error.QueueFull => {
|
||||
if (io_mod.milliTimestamp() >= deadline) return error.TerminalClientFixtureTimeout;
|
||||
io_mod.sleep(5 * std.time.ns_per_ms);
|
||||
continue;
|
||||
},
|
||||
else => return err,
|
||||
};
|
||||
break;
|
||||
}
|
||||
io_mod.sleep(25 * std.time.ns_per_ms);
|
||||
}
|
||||
const boundary = contracts.CorrelationId{
|
||||
.value = policy.outcome_capacity + 1,
|
||||
};
|
||||
if (runtime.admit(
|
||||
alloc,
|
||||
boundary,
|
||||
.{ .screen = .{ .session_id = "terminal-retention" } },
|
||||
)) |_| return error.TerminalOutcomeFixtureExpectedCapacity else |err| if (err != error.QueueFull) return err;
|
||||
|
||||
for (1..policy.outcome_capacity + 1) |value| {
|
||||
const correlation_id = contracts.CorrelationId{ .value = value };
|
||||
var completion = try awaitCompletionFor(&runtime, correlation_id);
|
||||
defer completion.deinit();
|
||||
try expectFixtureFailure(completion, .screen, .authority_denied);
|
||||
if (runtime.takeCompletionFor(correlation_id)) |duplicate_value| {
|
||||
var duplicate = duplicate_value;
|
||||
duplicate.deinit();
|
||||
return error.TerminalOutcomeFixtureDuplicateCompletion;
|
||||
}
|
||||
}
|
||||
try writeJson(alloc, .{
|
||||
.retained = policy.outcome_capacity,
|
||||
.consumed = policy.outcome_capacity,
|
||||
.boundary_rejected = true,
|
||||
});
|
||||
}
|
||||
|
||||
fn runOutcomeFailureFixture(
|
||||
alloc: Allocator,
|
||||
process_provider: process_provider_mod.Provider,
|
||||
) !void {
|
||||
const point = io_mod.getenv("FX_TERMINAL_TEST_HOST_FAILURE_POINT") orelse
|
||||
return error.TerminalOutcomeFixtureFailurePointMissing;
|
||||
const ordered = std.mem.eql(u8, point, "task_allocation") or
|
||||
std.mem.eql(u8, point, "worker_start");
|
||||
var runtime = client.Runtime.init(process_provider);
|
||||
defer runtime.deinit();
|
||||
const first_request: contracts.ActionRequest = if (ordered)
|
||||
.{ .write = .{
|
||||
.session_id = "terminal-failure",
|
||||
.payload = .{ .text = "first" },
|
||||
} }
|
||||
else
|
||||
.{ .screen = .{ .session_id = "terminal-failure" } };
|
||||
try runtime.admit(alloc, .{ .value = 1 }, first_request);
|
||||
var first = try awaitCompletionFor(&runtime, .{ .value = 1 });
|
||||
defer first.deinit();
|
||||
if (first.kind != .disconnected) {
|
||||
return error.TerminalOutcomeFixtureExpectedDisconnect;
|
||||
}
|
||||
|
||||
try runtime.admit(alloc, .{ .value = 2 }, .{ .write = .{
|
||||
.session_id = "terminal-failure",
|
||||
.payload = .{ .text = "second" },
|
||||
} });
|
||||
var second = try awaitCompletionFor(&runtime, .{ .value = 2 });
|
||||
defer second.deinit();
|
||||
try expectFixtureFailure(second, .write, .authority_denied);
|
||||
try writeJson(alloc, .{
|
||||
.failure = point,
|
||||
.first = @tagName(first.kind),
|
||||
.later = @tagName(second.kind),
|
||||
});
|
||||
}
|
||||
|
||||
fn awaitCompletionFor(
|
||||
runtime: *client.Runtime,
|
||||
correlation_id: contracts.CorrelationId,
|
||||
) !client.Completion {
|
||||
const started = io_mod.milliTimestamp();
|
||||
while (io_mod.milliTimestamp() - started < fixture_observation_timeout_ms) {
|
||||
if (runtime.takeCompletionFor(correlation_id)) |completion| {
|
||||
return completion;
|
||||
}
|
||||
io_mod.sleep(5 * std.time.ns_per_ms);
|
||||
}
|
||||
return error.TerminalClientFixtureTimeout;
|
||||
}
|
||||
|
||||
fn fixtureSuccess(
|
||||
completion: client.Completion,
|
||||
expected: contracts.Action,
|
||||
) !contracts.ActionResult {
|
||||
const frame = completion.frame orelse
|
||||
return error.TerminalOutcomeFixtureUnexpectedResult;
|
||||
return switch (frame.message().payload) {
|
||||
.response => |response| switch (response) {
|
||||
.success => |success| if (success.action() == expected)
|
||||
success
|
||||
else
|
||||
error.TerminalOutcomeFixtureUnexpectedResult,
|
||||
.failure => error.TerminalOutcomeFixtureFailed,
|
||||
},
|
||||
else => error.TerminalOutcomeFixtureUnexpectedResult,
|
||||
};
|
||||
}
|
||||
|
||||
fn expectFixtureFailure(
|
||||
completion: client.Completion,
|
||||
action: contracts.Action,
|
||||
code: contracts.StructuredErrorCode,
|
||||
) !void {
|
||||
const frame = completion.frame orelse
|
||||
return error.TerminalOutcomeFixtureUnexpectedResult;
|
||||
switch (frame.message().payload) {
|
||||
.response => |response| switch (response) {
|
||||
.failure => |failure| if (failure.action != action or failure.code != code) {
|
||||
return error.TerminalOutcomeFixtureUnexpectedResult;
|
||||
},
|
||||
.success => return error.TerminalOutcomeFixtureUnexpectedResult,
|
||||
},
|
||||
else => return error.TerminalOutcomeFixtureUnexpectedResult,
|
||||
}
|
||||
}
|
||||
|
||||
fn writeCompletionJson(alloc: Allocator, completion: client.Completion) !void {
|
||||
var output: std.Io.Writer.Allocating = .init(alloc);
|
||||
defer output.deinit();
|
||||
try output.writer.writeAll("{\"kind\":");
|
||||
try std.json.Stringify.value(@tagName(completion.kind), .{}, &output.writer);
|
||||
try output.writer.writeAll(",\"correlation\":1");
|
||||
if (completion.incompatibility) |incompatibility| {
|
||||
try output.writer.writeAll(",\"incompatibility\":");
|
||||
try std.json.Stringify.value(incompatibility, .{}, &output.writer);
|
||||
}
|
||||
if (completion.missing_capabilities != 0) {
|
||||
try output.writer.print(
|
||||
",\"missing_capabilities\":{d}",
|
||||
.{completion.missing_capabilities},
|
||||
);
|
||||
}
|
||||
if (completion.frame) |*frame| {
|
||||
switch (frame.message().payload) {
|
||||
.response => |response| switch (response) {
|
||||
.failure => |failure| {
|
||||
try output.writer.writeAll(",\"code\":");
|
||||
try std.json.Stringify.value(
|
||||
@tagName(failure.code),
|
||||
.{},
|
||||
&output.writer,
|
||||
);
|
||||
},
|
||||
.success => {},
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
try output.writer.writeAll("}\n");
|
||||
try std.Io.File.stdout().writeStreamingAll(io_mod.getIo(), output.written());
|
||||
}
|
||||
|
||||
fn writeJson(alloc: Allocator, value: anytype) !void {
|
||||
var output: std.Io.Writer.Allocating = .init(alloc);
|
||||
defer output.deinit();
|
||||
try std.json.Stringify.value(value, .{}, &output.writer);
|
||||
try output.writer.writeByte('\n');
|
||||
try std.Io.File.stdout().writeStreamingAll(io_mod.getIo(), output.written());
|
||||
}
|
||||
+99
-11
@@ -15,6 +15,7 @@ const terminal_managed_observer = @import("../../core/terminal/managed_observer.
|
||||
const terminal_operation = @import("../../core/terminal/operation.zig");
|
||||
const terminal_store = @import("../../core/terminal/store.zig");
|
||||
const shell_resolver = @import("../../core/terminal/shell_resolver.zig");
|
||||
const shell_snapshot = @import("../../core/terminal/shell_snapshot.zig");
|
||||
const sort_utils = @import("../../core/shared/sort_utils.zig");
|
||||
const terminal_contracts = @import("../../core/terminal/contracts.zig");
|
||||
const tool_args = @import("../../core/tooling/tool_args.zig");
|
||||
@@ -52,6 +53,7 @@ pub const Input = struct {
|
||||
tty: bool = false,
|
||||
yield_time_ms: u32 = managed_contract.default_yield_time_ms,
|
||||
timeout_ms: ?u64 = null,
|
||||
reload: bool = false,
|
||||
session_id: ?[]const u8 = null,
|
||||
chars: ?[]const u8 = null,
|
||||
force: bool = false,
|
||||
@@ -73,7 +75,7 @@ pub const ActionFieldContract = struct {
|
||||
pub fn actionFieldContract(action: Action) ActionFieldContract {
|
||||
return switch (action) {
|
||||
.run => .{
|
||||
.allowed = &.{ "action", "command", "cwd", "profile", "shell", "tty", "yield_time_ms", "timeout_ms" },
|
||||
.allowed = &.{ "action", "command", "cwd", "profile", "shell", "tty", "yield_time_ms", "timeout_ms", "reload" },
|
||||
.required = &.{ "action", "command" },
|
||||
.conflicts = &.{.{ "profile", "shell" }},
|
||||
},
|
||||
@@ -534,6 +536,9 @@ fn callRun(
|
||||
else => return unavailable(ctx),
|
||||
};
|
||||
const command = input.command orelse return unavailable(ctx);
|
||||
// Admission never lets a remembered grant authorize a reload, so the
|
||||
// command runs under the startup files its approval was given for.
|
||||
if (input.reload) shell_snapshot.processOwner().markDirty(.agent_reload);
|
||||
var request_arena_state = std.heap.ArenaAllocator.init(ctx.allocator);
|
||||
defer mem_utils.deinit_arena(request_arena_state);
|
||||
const request_arena = request_arena_state.allocator();
|
||||
@@ -587,7 +592,39 @@ fn callRun(
|
||||
return runtimeFailure(ctx, err);
|
||||
};
|
||||
defer prepared.deinit(ctx.allocator);
|
||||
return finishPrepared(ctx, runtime, &prepared, .command);
|
||||
const result = try finishPrepared(ctx, runtime, &prepared, .command);
|
||||
return attachSnapshotNotice(ctx.allocator, result) catch |err| {
|
||||
result.deinit(ctx.allocator);
|
||||
return err;
|
||||
};
|
||||
}
|
||||
|
||||
/// Adds the pending shell snapshot fallback notice to a JSON run result, so
|
||||
/// the model and ACP clients learn once that startup files run per command.
|
||||
fn attachSnapshotNotice(
|
||||
alloc: Allocator,
|
||||
result: tool_dispatch.ToolResult,
|
||||
) Allocator.Error!tool_dispatch.ToolResult {
|
||||
const body = switch (result) {
|
||||
.success, .failure => |value| value,
|
||||
.rich => return result,
|
||||
};
|
||||
if (body.len == 0 or body[body.len - 1] != '}') return result;
|
||||
const notice = (try shell_snapshot.processOwner().takeResultNotice(alloc)) orelse return result;
|
||||
defer alloc.free(notice);
|
||||
var out: std.Io.Writer.Allocating = .init(alloc);
|
||||
errdefer out.deinit();
|
||||
out.writer.writeAll(body[0 .. body.len - 1]) catch return error.OutOfMemory;
|
||||
out.writer.writeAll(",\"notice\":") catch return error.OutOfMemory;
|
||||
std.json.Stringify.value(notice, .{}, &out.writer) catch return error.OutOfMemory;
|
||||
out.writer.writeByte('}') catch return error.OutOfMemory;
|
||||
const updated = try out.toOwnedSlice();
|
||||
alloc.free(body);
|
||||
return switch (result) {
|
||||
.success => .{ .success = updated },
|
||||
.failure => .{ .failure = updated },
|
||||
.rich => unreachable,
|
||||
};
|
||||
}
|
||||
|
||||
fn callInteract(
|
||||
@@ -596,8 +633,11 @@ fn callInteract(
|
||||
) tool_dispatch.DispatchError!tool_dispatch.ToolResult {
|
||||
const runtime = ctx.managed_executions orelse return unavailable(ctx);
|
||||
const session_id = input.session_id orelse return unavailable(ctx);
|
||||
ensureOwnedTtyIndexed(ctx, runtime, session_id) catch |err|
|
||||
return runtimeFailure(ctx, err);
|
||||
switch (ensureOwnedTtyIndexed(ctx, runtime, session_id) catch |err|
|
||||
return runtimeFailure(ctx, err)) {
|
||||
.indexed => {},
|
||||
.ended_with_owner => return terminalEndedWithOwner(ctx, session_id),
|
||||
}
|
||||
const chars = input.chars orelse "";
|
||||
const yield_time_ms = effective_interact_yield_time(chars.len != 0, input.yield_time_ms);
|
||||
if (runtime.isTombstone(session_id)) {
|
||||
@@ -630,8 +670,11 @@ fn callStop(
|
||||
) tool_dispatch.DispatchError!tool_dispatch.ToolResult {
|
||||
const runtime = ctx.managed_executions orelse return unavailable(ctx);
|
||||
const session_id = input.session_id orelse return unavailable(ctx);
|
||||
ensureOwnedTtyIndexed(ctx, runtime, session_id) catch |err|
|
||||
return runtimeFailure(ctx, err);
|
||||
switch (ensureOwnedTtyIndexed(ctx, runtime, session_id) catch |err|
|
||||
return runtimeFailure(ctx, err)) {
|
||||
.indexed => {},
|
||||
.ended_with_owner => return terminalEndedWithOwner(ctx, session_id),
|
||||
}
|
||||
if (runtime.isTombstone(session_id)) {
|
||||
if (runtime.retainedTerminalSnapshot(ctx.allocator, session_id) catch |err|
|
||||
return runtimeFailure(ctx, err)) |retained|
|
||||
@@ -1241,10 +1284,28 @@ fn ensureOwnedTtyIndexed(
|
||||
ctx: tool_dispatch.DispatchContext,
|
||||
runtime: *managed_execution.Runtime,
|
||||
session_id: []const u8,
|
||||
) !void {
|
||||
if (runtime.stateFor(session_id) != null) return;
|
||||
const observer = ttyObserverContext(ctx, runtime) orelse return;
|
||||
try terminal_managed_observer.syncOwned(observer);
|
||||
) !terminal_managed_observer.SyncOutcome {
|
||||
if (runtime.stateFor(session_id) != null) return .indexed;
|
||||
const observer = ttyObserverContext(ctx, runtime) orelse return .indexed;
|
||||
return terminal_managed_observer.syncOwned(observer, session_id);
|
||||
}
|
||||
|
||||
/// Terminals end with the fx process that starts them and are never
|
||||
/// reattached, so the agent is told to start a new one.
|
||||
fn terminalEndedWithOwner(
|
||||
ctx: tool_dispatch.DispatchContext,
|
||||
session_id: []const u8,
|
||||
) tool_dispatch.DispatchError!tool_dispatch.ToolResult {
|
||||
var out: std.Io.Writer.Allocating = .init(ctx.allocator);
|
||||
errdefer out.deinit();
|
||||
std.json.Stringify.value(.{ .@"error" = .{
|
||||
.tool = "shell",
|
||||
.code = tool_result_errors.terminal_ended_error_code,
|
||||
.session_id = session_id,
|
||||
.message = "This terminal ended when the fx process that started it exited. It cannot be resumed. Start a new terminal with shell action run and tty true.",
|
||||
.retryable = false,
|
||||
} }, .{}, &out.writer) catch return error.OutOfMemory;
|
||||
return .{ .failure = try out.toOwnedSlice() };
|
||||
}
|
||||
|
||||
fn refreshTtyExecution(
|
||||
@@ -1830,7 +1891,7 @@ pub fn isIrreversible(_: tool_dispatch.ToolInput) bool {
|
||||
test "shell action fields are closed and command authority covers every run" {
|
||||
try std.testing.expectEqualSlices(
|
||||
[]const u8,
|
||||
&.{ "action", "command", "cwd", "profile", "shell", "tty", "yield_time_ms", "timeout_ms" },
|
||||
&.{ "action", "command", "cwd", "profile", "shell", "tty", "yield_time_ms", "timeout_ms", "reload" },
|
||||
actionFieldContract(.run).allowed,
|
||||
);
|
||||
try std.testing.expectEqualSlices(
|
||||
@@ -2778,3 +2839,30 @@ test "shell delivery advances only after result commit" {
|
||||
replayed.reservation_id,
|
||||
);
|
||||
}
|
||||
|
||||
fn timedOutSnapshotCapture(_: shell_snapshot.CaptureRequest) shell_snapshot.CaptureOutcome {
|
||||
return .{ .failed = .timed_out };
|
||||
}
|
||||
|
||||
test "shell run result carries a snapshot fallback notice once" {
|
||||
const alloc = std.testing.allocator;
|
||||
shell_snapshot.resetProcessOwnerForTest(timedOutSnapshotCapture);
|
||||
defer shell_snapshot.resetProcessOwnerForTest(shell_snapshot.real_capture_fn);
|
||||
try std.testing.expect((try shell_snapshot.processOwner().acquire("/bin/zsh", "/tmp", .{})) == .full_startup);
|
||||
|
||||
const plain = try attachSnapshotNotice(alloc, .{ .failure = try alloc.dupe(u8, "shell result is unavailable") });
|
||||
defer plain.deinit(alloc);
|
||||
try std.testing.expectEqualStrings("shell result is unavailable", plain.failure);
|
||||
|
||||
const first = try attachSnapshotNotice(alloc, .{ .success = try alloc.dupe(u8, "{\"state\":\"completed\"}") });
|
||||
defer first.deinit(alloc);
|
||||
const parsed = try std.json.parseFromSlice(std.json.Value, alloc, first.success, .{});
|
||||
defer parsed.deinit();
|
||||
try std.testing.expectEqualStrings("completed", parsed.value.object.get("state").?.string);
|
||||
const notice = parsed.value.object.get("notice").?.string;
|
||||
try std.testing.expect(std.mem.find(u8, notice, "did not finish within 10 s") != null);
|
||||
|
||||
const second = try attachSnapshotNotice(alloc, .{ .success = try alloc.dupe(u8, "{\"state\":\"completed\"}") });
|
||||
defer second.deinit(alloc);
|
||||
try std.testing.expectEqualStrings("{\"state\":\"completed\"}", second.success);
|
||||
}
|
||||
@@ -6329,7 +6329,7 @@ test "slash main page renders header categories selection range and contextual c
|
||||
try renderTestFooter(&h, &input, &approval, &h.frame_redraw);
|
||||
try h.flush();
|
||||
|
||||
try expectGridContains(&h, "Commands 35 · type to filter");
|
||||
try expectGridContains(&h, "Commands 36 · type to filter");
|
||||
try expectGridContains(&h, "1–6");
|
||||
try expectGridContains(&h, "/help");
|
||||
try expectGridContains(&h, "General");
|
||||
@@ -6350,7 +6350,7 @@ test "slash main page renders header categories selection range and contextual c
|
||||
|
||||
try expectGridContains(&h, "ask");
|
||||
try expectGridContains(&h, "test-model");
|
||||
try expectGridNotContains(&h, "Commands 35");
|
||||
try expectGridNotContains(&h, "Commands 36");
|
||||
try expectGridNotContains(&h, "↑↓ navigate");
|
||||
}
|
||||
|
||||
|
||||
+60
-16
@@ -61,7 +61,6 @@ import {
|
||||
|
||||
const TIMEOUT = 30_000;
|
||||
const LIVE_TIMEOUT = 120_000;
|
||||
const TERMINAL_HOST_EXIT_TIMEOUT_MS = 20_000;
|
||||
const SEEDED_GATEWAY_TOKEN = "seeded-access-token";
|
||||
const TERMINAL_FIXTURE_SHELL = terminalFixtureShell();
|
||||
const MCP_STDIO_FIXTURE = join(
|
||||
@@ -1043,16 +1042,6 @@ function createShortIsolatedRoot(prefix: string) {
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForTerminalHostExit(root: string): Promise<void> {
|
||||
const identityPath = join(root, "home", ".fx", "terminal-host-v7", "host.json");
|
||||
const deadline = Date.now() + TERMINAL_HOST_EXIT_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
if (!existsSync(identityPath)) return;
|
||||
await Bun.sleep(25);
|
||||
}
|
||||
throw new Error(`terminal host did not exit for ${root}`);
|
||||
}
|
||||
|
||||
function writeProjectOmissionFixture(root: ReturnType<typeof createIsolatedRoot>) {
|
||||
const rootRules = join(root.workspace, "AGENTS.md");
|
||||
writeFileSync(rootRules, "");
|
||||
@@ -2523,10 +2512,7 @@ describe("acp: model-independent", () => {
|
||||
try {
|
||||
client = await AcpClient.create({
|
||||
cwd: root.workspace,
|
||||
env: {
|
||||
...fakeGatewayEnv(root, gateway),
|
||||
FX_TERMINAL_HOST_IDLE_MS: "200",
|
||||
},
|
||||
env: fakeGatewayEnv(root, gateway),
|
||||
});
|
||||
client.setPermissionOption("allow_once");
|
||||
await startCodeSession(client);
|
||||
@@ -2549,10 +2535,68 @@ describe("acp: model-independent", () => {
|
||||
expect(toolResult).not.toContain("owner_authority");
|
||||
expect(toolResult).not.toContain("proof");
|
||||
expect(client.stderr).toBe("");
|
||||
await client.close();
|
||||
// The ACP process owned the terminal itself: no host daemon state.
|
||||
expect(existsSync(join(root.root, "home", ".fx", "terminal-host-v7"))).toBe(false);
|
||||
} finally {
|
||||
await client?.close();
|
||||
gateway.stop();
|
||||
rmSync(root.root, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
TIMEOUT,
|
||||
);
|
||||
|
||||
test(
|
||||
"ACP reports a shell snapshot fallback once on the triggering shell call",
|
||||
async () => {
|
||||
const root = createShortIsolatedRoot("fx-acp-snapshot-");
|
||||
// A 9 MB alias exceeds the 8 MiB capture limit, while full startup still
|
||||
// works. Both login shells read these files.
|
||||
const startup =
|
||||
"alias fxbig=\"$(head -c 9000000 /dev/zero | tr '\\0' x)\"\n" +
|
||||
"alias fxsmall='printf ACP_FULL_STARTUP_ALIAS'\n";
|
||||
writeFileSync(join(root.home, ".zshrc"), startup);
|
||||
writeFileSync(join(root.home, ".bash_profile"), startup);
|
||||
const run = (id: string) => fakeGatewayToolCall(id, "shell", {
|
||||
request: { action: "run", cwd: root.workspace, command: "fxsmall", yield_time_ms: 30_000 },
|
||||
});
|
||||
const gateway = startFakeGateway([
|
||||
run("acp_snapshot_1"),
|
||||
run("acp_snapshot_2"),
|
||||
finalText("ACP snapshot fallback complete"),
|
||||
]);
|
||||
const notice = "shell snapshot unavailable (the captured state exceeded 8 MiB)";
|
||||
try {
|
||||
client = await AcpClient.create({
|
||||
cwd: root.workspace,
|
||||
env: fakeGatewayEnv(root, gateway),
|
||||
});
|
||||
client.setPermissionOption("allow_once");
|
||||
await startCodeSession(client);
|
||||
const result = await runPrompt(client, "Run the alias twice.", TIMEOUT);
|
||||
|
||||
expect(result.promptResult.result.stopReason).toBe("end_turn");
|
||||
const updates = result.messages
|
||||
.filter((message: any) => message.method === "session/update")
|
||||
.map((message: any) => message.params.update);
|
||||
const noticeUpdates = updates.filter((update: any) => JSON.stringify(update).includes(notice));
|
||||
expect(noticeUpdates).toHaveLength(1);
|
||||
expect(noticeUpdates[0].sessionUpdate).toBe("tool_call_update");
|
||||
expect(noticeUpdates[0].toolCallId).toBe("acp_snapshot_1");
|
||||
expect(
|
||||
updates.some((update: any) =>
|
||||
update.sessionUpdate === "agent_message_chunk" && JSON.stringify(update).includes(notice)
|
||||
),
|
||||
).toBe(false);
|
||||
expect(acpToolResultText(gateway.requests[1]!.body, "acp_snapshot_1")).toContain(notice);
|
||||
const second = acpToolResultText(gateway.requests[2]!.body, "acp_snapshot_2");
|
||||
expect(second).toContain("ACP_FULL_STARTUP_ALIAS");
|
||||
expect(second).not.toContain(notice);
|
||||
expect(client.stderr).toBe("");
|
||||
} finally {
|
||||
await client?.close();
|
||||
gateway.stop();
|
||||
await waitForTerminalHostExit(root.root);
|
||||
rmSync(root.root, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
@@ -49,20 +52,9 @@ const sessions: TmuxSession[] = [];
|
||||
afterEach(async () => {
|
||||
for (const session of sessions.splice(0)) await session.kill();
|
||||
for (const gateway of gateways.splice(0)) gateway.stop();
|
||||
await Promise.all(roots.map(waitForTerminalHostExit));
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function waitForTerminalHostExit(root: string): Promise<void> {
|
||||
const identityPath = join(root, "home", ".fx", "terminal-host-v7", "host.json");
|
||||
const deadline = Date.now() + 5_000;
|
||||
while (Date.now() < deadline) {
|
||||
if (!existsSync(identityPath)) return;
|
||||
await Bun.sleep(25);
|
||||
}
|
||||
throw new Error(`terminal host did not exit for ${root}`);
|
||||
}
|
||||
|
||||
function createRoot() {
|
||||
const root = mkdtempSync(join(tmpdir(), "fx-e2e-ask-presentation-"));
|
||||
const home = join(root, "home");
|
||||
@@ -307,9 +299,15 @@ describe("fx ask presentation", () => {
|
||||
expect(gateway.requests[5]!.body).toContain("nested");
|
||||
expect(existsSync(nestedExecMarker)).toBe(true);
|
||||
expect(gateway.requests[6]!.body).toContain("neighbor-exec");
|
||||
// The refused tty run never reached the terminal store.
|
||||
expect(
|
||||
existsSync(join(root.home, ".fx", "terminal-host-v7", "host.json")),
|
||||
).toBe(false);
|
||||
existsSync(join(root.home, ".fx"))
|
||||
? Array.from(new Bun.Glob("**/terminal/state/record-*.json").scanSync({
|
||||
cwd: join(root.home, ".fx"),
|
||||
dot: true,
|
||||
}))
|
||||
: [],
|
||||
).toEqual([]);
|
||||
}, TIMEOUT);
|
||||
|
||||
test("redirected and JSON stdout preserve raw assistant Markdown", async () => {
|
||||
@@ -952,3 +950,185 @@ describe("fx ask presentation", () => {
|
||||
TIMEOUT,
|
||||
);
|
||||
});
|
||||
|
||||
// Both login shells read these files, so the tests hold for zsh and bash.
|
||||
function writeStartupFiles(home: string, body: string): void {
|
||||
writeFileSync(join(home, ".zshrc"), body);
|
||||
writeFileSync(join(home, ".bash_profile"), body);
|
||||
}
|
||||
|
||||
function startupLoads(home: string): number {
|
||||
const log = join(home, "rc.log");
|
||||
return existsSync(log) ? readFileSync(log, "utf8").split("\n").filter(Boolean).length : 0;
|
||||
}
|
||||
|
||||
function userShellRun(id: string, command: string) {
|
||||
return fakeGatewayToolCall(id, "shell", {
|
||||
request: { action: "run", command, yield_time_ms: 30_000 },
|
||||
});
|
||||
}
|
||||
|
||||
function filesContaining(dir: string, needle: string): string[] {
|
||||
if (!existsSync(dir)) return [];
|
||||
return readdirSync(dir).flatMap((name) => {
|
||||
const path = join(dir, name);
|
||||
if (statSync(path).isDirectory()) return filesContaining(path, needle);
|
||||
return readFileSync(path).includes(needle) ? [path] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function occurrences(text: string, needle: string): number {
|
||||
return text.split(needle).length - 1;
|
||||
}
|
||||
|
||||
describe("fx ask shell startup files", () => {
|
||||
test("user-profile commands load startup files once and reload after an edit", async () => {
|
||||
const root = createRoot();
|
||||
writeStartupFiles(
|
||||
root.home,
|
||||
"printf 'loaded\\n' >> \"$HOME/rc.log\"\n" +
|
||||
"export PATH=\"$HOME/fx-startup-marker:$PATH\"\n",
|
||||
);
|
||||
const edit = "for f in ~/.zshrc ~/.bash_profile; do " +
|
||||
"printf '%s\\n' \"alias fxlate='printf FX_RELOADED_ALIAS'\" >> \"$f\"; done";
|
||||
const gateway = startFakeGateway([
|
||||
userShellRun("path-0", "echo \"PATH=$PATH\""),
|
||||
userShellRun("path-1", "echo \"PATH=$PATH\""),
|
||||
userShellRun("path-2", "echo \"PATH=$PATH\""),
|
||||
userShellRun("edit", edit),
|
||||
userShellRun("reloaded", "fxlate"),
|
||||
fakeGatewayFinalText("Startup files checked.\n"),
|
||||
]);
|
||||
gateways.push(gateway);
|
||||
|
||||
const result = await runFx(
|
||||
["ask", "--json", "--yolo", "--no-save", "Check the startup files."],
|
||||
{ cwd: root.workspace, env: gatewayEnv(root.home, gateway), timeoutMs: TIMEOUT },
|
||||
);
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
const marker = `${root.home}/fx-startup-marker:`;
|
||||
for (const index of [1, 2, 3]) {
|
||||
expect(gateway.requests[index]!.body).toContain(`PATH=${marker}`);
|
||||
}
|
||||
expect(gateway.requests[5]!.body).toContain("FX_RELOADED_ALIAS");
|
||||
// One capture, and one more after the edit: never once per command.
|
||||
expect(startupLoads(root.home)).toBe(2);
|
||||
expect(result.stderr).not.toContain("shell snapshot unavailable");
|
||||
}, TIMEOUT);
|
||||
|
||||
test("startup files the snapshot cannot capture fall back with one notice", async () => {
|
||||
const root = createRoot();
|
||||
// A 9 MB alias exceeds the 8 MiB capture limit, while full startup still
|
||||
// works for every command.
|
||||
writeStartupFiles(
|
||||
root.home,
|
||||
"printf 'loaded\\n' >> \"$HOME/rc.log\"\n" +
|
||||
"alias fxbig=\"$(head -c 9000000 /dev/zero | tr '\\0' x)\"\n" +
|
||||
"alias fxsmall='printf FX_FULL_STARTUP_ALIAS'\n",
|
||||
);
|
||||
const gateway = startFakeGateway([
|
||||
userShellRun("first", "fxsmall"),
|
||||
userShellRun("second", "fxsmall"),
|
||||
fakeGatewayFinalText("Fallback checked.\n"),
|
||||
]);
|
||||
gateways.push(gateway);
|
||||
|
||||
const result = await runFx(
|
||||
["ask", "--json", "--yolo", "--no-save", "Check the fallback."],
|
||||
{ cwd: root.workspace, env: gatewayEnv(root.home, gateway), timeoutMs: TIMEOUT },
|
||||
);
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
const notice = "shell snapshot unavailable (the captured state exceeded 8 MiB)";
|
||||
expect(occurrences(result.stderr, `fx ask: warning: ${notice}`)).toBe(1);
|
||||
expect(occurrences(gateway.requests[1]!.body, notice)).toBe(1);
|
||||
// The later request repeats the history, so one notice in total.
|
||||
expect(occurrences(gateway.requests[2]!.body, notice)).toBe(1);
|
||||
expect(occurrences(gateway.requests[2]!.body, "FX_FULL_STARTUP_ALIAS")).toBe(2);
|
||||
// The failed capture plus one full startup per command.
|
||||
expect(startupLoads(root.home)).toBe(3);
|
||||
}, TIMEOUT);
|
||||
|
||||
test("a fallback notice still appears when the snapshot recovers in the same run", async () => {
|
||||
const root = createRoot();
|
||||
// The first startup run, the capture, kills its own shell; later runs work.
|
||||
writeStartupFiles(
|
||||
root.home,
|
||||
"if [ ! -e \"$HOME/fault-fired\" ]; then : > \"$HOME/fault-fired\"; kill -9 $$; fi\n" +
|
||||
"alias fxa='printf FX_RECOVERY_ALIAS'\n",
|
||||
);
|
||||
const gateway = startFakeGateway([
|
||||
userShellRun("fallback", "fxa"),
|
||||
userShellRun("edit", "printf '# edited\\n' >> ~/.zshrc; printf '# edited\\n' >> ~/.bash_profile"),
|
||||
userShellRun("recovered", "fxa"),
|
||||
fakeGatewayFinalText("Recovery checked.\n"),
|
||||
]);
|
||||
gateways.push(gateway);
|
||||
|
||||
const result = await runFx(
|
||||
["ask", "--json", "--yolo", "--no-save", "Check recovery."],
|
||||
{ cwd: root.workspace, env: gatewayEnv(root.home, gateway), timeoutMs: TIMEOUT },
|
||||
);
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(existsSync(join(root.home, "fault-fired"))).toBe(true);
|
||||
const notice = "fx ask: warning: shell snapshot unavailable (the login shell exited before reporting its state)";
|
||||
expect(occurrences(result.stderr, notice)).toBe(1);
|
||||
expect(gateway.requests[3]!.body).toContain("FX_RECOVERY_ALIAS");
|
||||
}, TIMEOUT);
|
||||
|
||||
test.skipIf(!tmuxAvailable())(
|
||||
"exported startup-file secrets stay out of traces, sessions, and trace reports",
|
||||
async () => {
|
||||
const root = createRoot();
|
||||
const secret = `FX_SENTINEL_${crypto.randomUUID()}`;
|
||||
writeStartupFiles(root.home, `export FX_E2E_SECRET=${secret}\n`);
|
||||
const clipboardBin = join(root.root, "clipboard-bin");
|
||||
mkdirSync(clipboardBin);
|
||||
writeFileSync(join(clipboardBin, "osascript"), "#!/bin/sh\nexit 1\n");
|
||||
chmodSync(join(clipboardBin, "osascript"), 0o755);
|
||||
const tracePath = join(root.root, "fx-trace.log");
|
||||
const gateway = startFakeGateway([
|
||||
userShellRun("secret", "test -n \"$FX_E2E_SECRET\" && printf FX_SECRET_VISIBLE"),
|
||||
fakeGatewayFinalText("SECRET_CHECK_DONE"),
|
||||
]);
|
||||
gateways.push(gateway);
|
||||
const session = await TmuxSession.create({
|
||||
cwd: root.workspace,
|
||||
width: 110,
|
||||
height: 34,
|
||||
stderrPath: join(root.root, "stderr.log"),
|
||||
env: {
|
||||
...gatewayEnv(root.home, gateway),
|
||||
FX_PERMISSION_MODE: "full-access",
|
||||
FX_SOUND: "0",
|
||||
FX_AUTO_UPGRADE: "0",
|
||||
FX_TRACE_LOG: tracePath,
|
||||
FX_TRACE_SCOPES: "core,tool,agent,session",
|
||||
PATH: `${clipboardBin}:${process.env.PATH}`,
|
||||
TMPDIR: root.root,
|
||||
},
|
||||
});
|
||||
try {
|
||||
await session.waitForComposer(TIMEOUT);
|
||||
await session.sendText("Check the secret.");
|
||||
await session.waitForText("SECRET_CHECK_DONE", TIMEOUT);
|
||||
await session.sendText("/trace");
|
||||
await session.waitForText("Trace saved at", TIMEOUT);
|
||||
} finally {
|
||||
await session.kill();
|
||||
}
|
||||
|
||||
expect(gateway.requests[1]!.body).toContain("FX_SECRET_VISIBLE");
|
||||
const report = readdirSync(root.root).find((name) => name.startsWith("fx-trace-") && name.endsWith(".md"));
|
||||
expect(report).toBeDefined();
|
||||
expect(readFileSync(join(root.root, report!), "utf8")).not.toContain(secret);
|
||||
expect(readFileSync(tracePath, "utf8")).toContain("shell snapshot ready");
|
||||
expect(readFileSync(tracePath, "utf8")).not.toContain(secret);
|
||||
expect(filesContaining(join(root.home, ".fx"), secret)).toEqual([]);
|
||||
for (const request of gateway.requests) expect(request.body).not.toContain(secret);
|
||||
},
|
||||
TIMEOUT,
|
||||
);
|
||||
});
|
||||
@@ -26,7 +26,6 @@
|
||||
{ "file": "session-recovery.test.ts", "weight": 13 },
|
||||
{ "file": "sessions-v2.test.ts", "weight": 80 },
|
||||
{ "file": "session-title.test.ts", "weight": 5 },
|
||||
{ "file": "terminal-host.test.ts", "weight": 273 },
|
||||
{ "file": "tmux-helpers.test.ts", "weight": 2 },
|
||||
{ "file": "tui-agent.test.ts", "weight": 1 },
|
||||
{ "file": "tui-auth-source-selection.test.ts", "weight": 49 },
|
||||
@@ -57,7 +56,7 @@
|
||||
{ "file": "tui-slash-extra.test.ts", "weight": 4 },
|
||||
{ "file": "tui-slash-menu.test.ts", "weight": 88 },
|
||||
{ "file": "tui-startup.test.ts", "weight": 7 },
|
||||
{ "file": "tui-terminal-tool.test.ts", "weight": 129 },
|
||||
{ "file": "tui-terminal-tool.test.ts", "weight": 170 },
|
||||
{ "file": "ultrafast-fake-gateway.test.ts", "weight": 8 },
|
||||
{ "file": "vision-route-fake-gateway.test.ts", "weight": 20 },
|
||||
{ "file": "web-fetch-fake-network.test.ts", "weight": 3 },
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
truncateSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { tmpdir, userInfo } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { FX_BIN, runFx } from "../evals/eval-helpers";
|
||||
import {
|
||||
@@ -1170,7 +1170,7 @@ describe("gateway stream lifecycle", () => {
|
||||
expectOnlyLeadingSystemMessages(gateway.requests[0]!.body);
|
||||
expect(contentText(request.prompt[1]?.content)).toBe(WEB_SEARCH_GUIDANCE);
|
||||
expect(toolByName(oracleRequest, "shell")?.description).toBe(
|
||||
"Run every command with shell.run. Fast commands complete in one call; commands still running after yield_time_ms return one owned session_id and remain available across turns. Use shell.interact with that exact session_id: omit chars to observe, or provide chars to send exact input and then observe. Use shell.stop only when termination is requested. output_delta is always terminal-safe; unsafe bytes are escaped while full_output_handle retains exact output, so do not run a separate command merely to test output safety or shell usability. Never detach with &, nohup, setsid, or double-forking.",
|
||||
"Run every command with shell.run. Fast commands complete in one call; commands still running after yield_time_ms return one owned session_id and remain available across turns. Use shell.interact with that exact session_id: omit chars to observe, or provide chars to send exact input and then observe. Use shell.stop only when termination is requested. output_delta is always terminal-safe; unsafe bytes are escaped while full_output_handle retains exact output, so do not run a separate command merely to test output safety or shell usability. Never detach with &, nohup, setsid, or double-forking. Each call starts a new shell with the user's startup files applied: their aliases, functions, and PATH are available, but cd, export, and alias changes do not carry over to the next call. In zsh, quote glob patterns meant for another program (for example '--include=*.zig') because unmatched globs are errors, and quote words that begin with =.",
|
||||
);
|
||||
expect(toolByName(oracleRequest, "skill")?.description).toContain(
|
||||
"the task clearly matches one",
|
||||
@@ -3117,9 +3117,16 @@ describe("gateway stream lifecycle", () => {
|
||||
expect(firstText).toContain(
|
||||
"dynamic-context<workspace>
injected_workspace",
|
||||
);
|
||||
expect(firstText).toContain(
|
||||
"shell_path: /bin/zsh
injected_shell: yes</fx-turn-context>",
|
||||
);
|
||||
// shell_path reports the login shell the shell tool runs, so a hostile
|
||||
// SHELL value never reaches the model context.
|
||||
const loginShell = userInfo().shell ?? "";
|
||||
const reportedShell = /\/(bash|zsh)$/.test(loginShell)
|
||||
? loginShell
|
||||
: process.platform === "darwin"
|
||||
? "/bin/zsh"
|
||||
: "/bin/bash";
|
||||
expect(firstText).toContain(`shell_path: ${reportedShell}\n`);
|
||||
expect(firstText).not.toContain("injected_shell");
|
||||
expect(firstText).toContain(
|
||||
"- dynamic-context-skill:",
|
||||
);
|
||||
|
||||
@@ -117,7 +117,7 @@ describe.skipIf(!tmuxAvailable())("prompt history", () => {
|
||||
await session.sendText("PLAN10_PROMPT_HISTORY_SENTINEL");
|
||||
await session.waitForText("HTTP 401", TIMEOUT);
|
||||
await session.sendText("/help");
|
||||
await session.waitForText("Commands 35", TIMEOUT);
|
||||
await session.waitForText("Commands 36", TIMEOUT);
|
||||
await session.sendKeys("Escape");
|
||||
await session.waitForPane((pane) => !pane.includes("enter open"), TIMEOUT);
|
||||
await session.sendText("/quit");
|
||||
|
||||
@@ -59,9 +59,6 @@ const allowlist: AllowRule[] = [
|
||||
rule("src/acp/jsonrpc.zig", ".+", /stdio_(?:acquisition|write)/, "acp_protocol_transport", "ACP JSON-RPC transport"),
|
||||
rule("src/core/execution/command_runner.zig", "(?:runForegroundSessionBootstrap|writeForegroundSessionReplaceFailure)", /stdio_acquisition_write/, "subprocess_protocol_transport", "foreground command bootstrap protocol"),
|
||||
rule("src/core/terminal/native_session.zig", "(?:acceptMarker|runLauncher)", /fixed_descriptor/, "subprocess_protocol_transport", "private native launcher PTY and control descriptors"),
|
||||
rule("src/core/terminal/tmux_session.zig", "runLauncher", /fixed_descriptor/, "subprocess_protocol_transport", "private tmux launcher PTY descriptor"),
|
||||
rule("src/core/terminal/client.zig", "(?:runFixture|writeFixtureJson)", /stdio_acquisition_write/, "tests", "private terminal client fixture output"),
|
||||
rule("src/terminal_client_fixture.zig", "(?:writeCompletionJson|writeJson)", /stdio_acquisition_write/, "tests", "private test-fixture output"),
|
||||
rule("src/core/shared/darwin_process_spawn.zig", "process_spawn_inheriting_fd", /fixed_descriptor/, "subprocess_protocol_transport", "child stdio and witness file-action mapping"),
|
||||
rule("src/core/app/app_entry_runtime.zig", "(?:writeRealStdout|writeRealStderr)", /stdio_acquisition_write/, "noninteractive_output", "post-terminal handoff or failure output"),
|
||||
rule("src/core/app/app_upgrade_runtime.zig", "writeStderrDefault", /stdio_acquisition_write/, "noninteractive_output", "post-terminal upgrade relaunch failure output"),
|
||||
|
||||
@@ -3406,7 +3406,7 @@ function terminalRecords(fixture: Fixture, id: string): Array<Record<string, unk
|
||||
.map((name) => JSON.parse(readFileSync(join(root, name), "utf8")));
|
||||
}
|
||||
|
||||
test.skipIf(!tmuxAvailable())("a hosted terminal survives an app crash, and the resumed app talks to it and stops it", async () => {
|
||||
test.skipIf(!tmuxAvailable())("a hosted terminal ends with an app crash, and the resumed app reports it ended", async () => {
|
||||
const fixture = createFixture("fx-v2-tty-crash-");
|
||||
let shellId = "";
|
||||
const gateway = startFakeGateway([
|
||||
@@ -3427,9 +3427,17 @@ test.skipIf(!tmuxAvailable())("a hosted terminal survives an app crash, and the
|
||||
request: { action: "interact", session_id: shellId, chars: "after the crash\n", yield_time_ms: 2000 },
|
||||
}),
|
||||
() => fakeGatewayToolCall("tty_stop", "shell", { request: { action: "stop", session_id: shellId, force: true } }),
|
||||
fakeGatewayFinalText("TTY_STOPPED"),
|
||||
fakeGatewayFinalText("TTY_ENDED_REPORTED"),
|
||||
]);
|
||||
const appEnv = { FX_PERMISSION_MODE: "full-access", SHELL: "/bin/sh" };
|
||||
const alive = (pid: number) => {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
try {
|
||||
const app = await startApp(fixture, gateway, [], true, appEnv);
|
||||
await app.session.sendText("Start a terminal.");
|
||||
@@ -3437,18 +3445,27 @@ test.skipIf(!tmuxAvailable())("a hosted terminal survives an app crash, and the
|
||||
expect(shellId).toMatch(/^shell-[A-Za-z0-9_-]{22}$/);
|
||||
const id = onlySession(fixture);
|
||||
await waitForLog(fixture, id, "turn_committed");
|
||||
const shellPid = Number(terminalRecords(fixture, id).find((record) => record.session_id === shellId)?.pid);
|
||||
expect(alive(shellPid)).toBe(true);
|
||||
Bun.spawnSync(["kill", "-9", String(app.session.processPid())]);
|
||||
await app.session.kill();
|
||||
|
||||
// The terminal belonged to the crashed process and ended with it.
|
||||
const deadline = Date.now() + 2_000;
|
||||
while (alive(shellPid) && Date.now() < deadline) await Bun.sleep(10);
|
||||
expect(alive(shellPid)).toBe(false);
|
||||
|
||||
const resumed = await startApp(fixture, gateway, ["--resume", id], true, appEnv);
|
||||
await resumed.session.sendText("Talk to the terminal, then stop it.");
|
||||
await resumed.session.waitForText("TTY_STOPPED", TIMEOUT);
|
||||
// The terminal kept running through the crash: it echoes a line sent
|
||||
// after it, and the stop finds it.
|
||||
expect(gateway.requests[3]!.body).toContain("TTY_ECHO:after the crash");
|
||||
expect(gateway.requests[4]!.body).toContain('\\"state\\":\\"stopped\\"');
|
||||
await resumed.session.waitForText("TTY_ENDED_REPORTED", TIMEOUT);
|
||||
// Neither interact nor stop reattaches; both report why it is gone.
|
||||
for (const index of [3, 4]) {
|
||||
expect(gateway.requests[index]!.body).toContain("TerminalEnded");
|
||||
expect(gateway.requests[index]!.body).toContain("ended when the fx process that started it exited");
|
||||
}
|
||||
expect(gateway.requests[3]!.body).not.toContain("TTY_ECHO:after the crash");
|
||||
await quitApp(resumed);
|
||||
expect(terminalRecords(fixture, id).find((record) => record.session_id === shellId)?.lifecycle).toBe("closed");
|
||||
expect(terminalRecords(fixture, id).find((record) => record.session_id === shellId)?.lifecycle).toBe("lost");
|
||||
expectWholeLog(fixture, id);
|
||||
} finally {
|
||||
gateway.stop();
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -86,7 +86,7 @@ describe("tui: direct-write audit", () => {
|
||||
/path=src\/core\/terminal\/native_session\.zig .*function=acceptMarker .*category=subprocess_protocol_transport/,
|
||||
);
|
||||
expect(output).toMatch(
|
||||
/path=src\/core\/terminal\/tmux_session\.zig .*function=runLauncher .*category=subprocess_protocol_transport/,
|
||||
/path=src\/core\/terminal\/native_session\.zig .*function=runLauncher .*category=subprocess_protocol_transport/,
|
||||
);
|
||||
expect(output).toContain("function=clearTmuxScreenAndHistory");
|
||||
expect(output).toContain("category=terminal_reset");
|
||||
|
||||
@@ -1406,7 +1406,7 @@ async function runCanonicalLifecycleFixture(
|
||||
reachedFinal = settled.matched;
|
||||
if (reachedFinal) {
|
||||
await session.sendText("/help");
|
||||
const help = await waitForPaneOrDone(session, "Commands 35", donePath);
|
||||
const help = await waitForPaneOrDone(session, "Commands 36", donePath);
|
||||
helpVisible = help.matched;
|
||||
requestCountAfterHelp = queuedGateway.requests.length;
|
||||
if (helpVisible) {
|
||||
@@ -5527,7 +5527,7 @@ describe.skipIf(!tmuxAvailable())("TUI gateway stream lifecycle", () => {
|
||||
FX_E2E_GATEWAY_CHAT_URL: unsupportedGateway.chatUrl,
|
||||
FX_MODEL: MODEL,
|
||||
FX_TRACE_LOG: tracePath,
|
||||
FX_TRACE_SCOPES: "tool",
|
||||
FX_TRACE_SCOPES: "tool,core",
|
||||
};
|
||||
session = await TmuxSession.create({
|
||||
cwd: workspace,
|
||||
@@ -5588,6 +5588,9 @@ describe.skipIf(!tmuxAvailable())("TUI gateway stream lifecycle", () => {
|
||||
expect(trace).toContain(
|
||||
`event=execution_start turn_id=1 step_id=1 call_id=${supportedCallId} name=shell`,
|
||||
);
|
||||
// The startup-file snapshot must capture while fx owns the terminal;
|
||||
// a capture that shares the terminal stops on job control.
|
||||
expect(trace).toContain("shell snapshot ready");
|
||||
expect(existsSync(tapePath)).toBe(true);
|
||||
expect(readFileSync(stderrPath, "utf8")).toBe("");
|
||||
|
||||
|
||||
@@ -322,7 +322,7 @@ tmuxTest(
|
||||
await waitForExactComposerRow(active, "┃ /");
|
||||
|
||||
await active.sendKeys("Enter");
|
||||
await active.waitForText("Commands 35", READY_TIMEOUT);
|
||||
await active.waitForText("Commands 36", READY_TIMEOUT);
|
||||
await active.sendKeys("Escape");
|
||||
await active.waitForPane(
|
||||
(pane) => hasEmptyComposer(pane) && !pane.includes("enter open"),
|
||||
@@ -1876,7 +1876,7 @@ tmuxTest(
|
||||
READY_TIMEOUT,
|
||||
);
|
||||
await active.resizeWindow(80, 24, 300);
|
||||
await active.waitForText("Commands 35", READY_TIMEOUT);
|
||||
await active.waitForText("Commands 36", READY_TIMEOUT);
|
||||
expect(gateway?.requests).toHaveLength(0);
|
||||
expectCleanStderr();
|
||||
},
|
||||
|
||||
@@ -710,7 +710,6 @@ test.skipIf(!ENABLED || !tmuxAvailable())(
|
||||
FX_SOUND: "0",
|
||||
FX_RECORD: fixture.tapePath,
|
||||
FX_RECORD_INPUT: "1",
|
||||
FX_TERMINAL_HOST_IDLE_MS: "250",
|
||||
NO_COLOR: "1",
|
||||
},
|
||||
stderrPath: fixture.stderrPath,
|
||||
|
||||
@@ -114,7 +114,7 @@ describe.skipIf(SKIP)("tui: render stress", () => {
|
||||
|
||||
await session.sendKeys("C-u");
|
||||
await session.sendText("/help");
|
||||
await session.waitForText("Commands 35", 5_000);
|
||||
await session.waitForText("Commands 36", 5_000);
|
||||
await session.sendKeys("Escape");
|
||||
await session.waitForPane((pane) => !pane.includes("enter open"), 5_000);
|
||||
await session.sendText("/status");
|
||||
|
||||
@@ -3192,7 +3192,7 @@ describe.skipIf(SKIP)("tui: resize", () => {
|
||||
await session.waitForText("/help", 10_000);
|
||||
await waitForSelectedSlashLabel(session, "/help");
|
||||
const shrinkStage = await session.captureFullScrollback();
|
||||
expect(shrinkStage).toContain("Commands 35 · type to filter");
|
||||
expect(shrinkStage).toContain("Commands 36 · type to filter");
|
||||
expect(shrinkStage).toContain("1–4");
|
||||
writeFileSync(join(root, "scrollback-after-shrink.txt"), shrinkStage);
|
||||
|
||||
@@ -4169,11 +4169,11 @@ describe.skipIf(SKIP)("tui: resize", () => {
|
||||
async () => {
|
||||
session = await launchAt(120, 40);
|
||||
await session.sendText("/help");
|
||||
await session.waitForText("Commands 35", 5_000);
|
||||
await session.waitForText("Commands 36", 5_000);
|
||||
await session.resizeWindow(76, 24, 400);
|
||||
|
||||
const grid = await session.capturePaneGrid();
|
||||
expect(grid.join("\n")).toContain("Commands 35");
|
||||
expect(grid.join("\n")).toContain("Commands 36");
|
||||
expect(findInlineHelpPicker(grid)).not.toBeNull();
|
||||
|
||||
await session.sendKeys("Escape");
|
||||
@@ -4191,7 +4191,7 @@ describe.skipIf(SKIP)("tui: resize", () => {
|
||||
async () => {
|
||||
session = await launchAt(120, 40);
|
||||
await session.sendText("/help");
|
||||
await session.waitForText("Commands 35", 5_000);
|
||||
await session.waitForText("Commands 36", 5_000);
|
||||
|
||||
const captureScrollback = () =>
|
||||
execSync(`tmux capture-pane -t ${session!.name} -p -S -`, {
|
||||
@@ -4199,7 +4199,7 @@ describe.skipIf(SKIP)("tui: resize", () => {
|
||||
stdio: "pipe",
|
||||
});
|
||||
const expectHelpCatalog = (grid: string[]) => {
|
||||
expect(grid.join("\n")).toContain("Commands 35");
|
||||
expect(grid.join("\n")).toContain("Commands 36");
|
||||
expect(findInlineHelpPicker(grid)).not.toBeNull();
|
||||
};
|
||||
|
||||
@@ -4217,7 +4217,7 @@ describe.skipIf(SKIP)("tui: resize", () => {
|
||||
const restored = captureScrollback();
|
||||
expect(restored.match(/𝒇x v\d+\.\d+\.\d+\b/g)).toHaveLength(1);
|
||||
expect(restored.match(/Run \/help for commands/g)).toHaveLength(1);
|
||||
expect(restored).not.toContain("Commands 35");
|
||||
expect(restored).not.toContain("Commands 36");
|
||||
expect(findFooter(await session.capturePaneGrid())).not.toBeNull();
|
||||
},
|
||||
TIMEOUT,
|
||||
@@ -4749,7 +4749,7 @@ describe.skipIf(SKIP)("tui: resize", () => {
|
||||
expect(await session.captureFullScrollback()).toContain(marker);
|
||||
|
||||
await session.sendText("/help");
|
||||
await session.waitForText("Commands 35", 5_000);
|
||||
await session.waitForText("Commands 36", 5_000);
|
||||
await session.resizeWindow(84, 28, 500);
|
||||
|
||||
const catalog = await session.capturePaneGrid();
|
||||
@@ -4763,7 +4763,7 @@ describe.skipIf(SKIP)("tui: resize", () => {
|
||||
);
|
||||
const scrollback = await session.captureFullScrollback();
|
||||
expect(scrollback).not.toContain(marker);
|
||||
expect(scrollback).not.toContain("Commands 35");
|
||||
expect(scrollback).not.toContain("Commands 36");
|
||||
const finalGrid = await session.capturePaneGrid();
|
||||
expect(findFooter(finalGrid), finalGrid.join("\n")).not.toBeNull();
|
||||
|
||||
|
||||
@@ -1036,7 +1036,7 @@ describe.skipIf(SKIP)("tui: slash menu", () => {
|
||||
).toBe(69);
|
||||
expect(closedComposerRow).toBe(73);
|
||||
await session.sendLiteralText("/");
|
||||
await session.waitForText("Commands 35", 5_000);
|
||||
await session.waitForText("Commands 36", 5_000);
|
||||
const afterSlash = await capture("after-slash");
|
||||
expect(visibleTranscriptTailRow(afterSlash)).toBe(60);
|
||||
expect(composerRow(afterSlash)).toBe(64);
|
||||
@@ -1561,7 +1561,7 @@ describe.skipIf(SKIP)("tui: slash menu", () => {
|
||||
await session.waitForComposer(10_000);
|
||||
|
||||
await session.sendText("/help");
|
||||
let grid = await waitForHelpMenu(session, 35);
|
||||
let grid = await waitForHelpMenu(session, 36);
|
||||
let pane = grid.join("\n");
|
||||
expect(pane).toContain("𝒇x");
|
||||
expect(pane).toContain("Run /help for commands");
|
||||
@@ -1577,7 +1577,7 @@ describe.skipIf(SKIP)("tui: slash menu", () => {
|
||||
grid = await waitForHelpMenu(session, 5);
|
||||
expect(grid.join("\n")).toContain("[General]");
|
||||
await session.sendKeys("BTab");
|
||||
grid = await waitForHelpMenu(session, 35);
|
||||
grid = await waitForHelpMenu(session, 36);
|
||||
expect(grid.join("\n")).toContain("[All]");
|
||||
|
||||
await session.sendLiteralText("clipboard");
|
||||
@@ -1588,11 +1588,11 @@ describe.skipIf(SKIP)("tui: slash menu", () => {
|
||||
expect(pane).not.toContain("/clear");
|
||||
|
||||
await session.sendKeys("C-u");
|
||||
await waitForHelpMenu(session, 35);
|
||||
await waitForHelpMenu(session, 36);
|
||||
await session.sendKeys("Down");
|
||||
await session.sendKeys("Enter");
|
||||
pane = await session.waitForPane(
|
||||
(current) => hasEmptyComposer(current) && !current.includes("Commands 35"),
|
||||
(current) => hasEmptyComposer(current) && !current.includes("Commands 36"),
|
||||
5_000,
|
||||
);
|
||||
expect(composerContains(pane, "/clear")).toBe(false);
|
||||
@@ -1601,7 +1601,7 @@ describe.skipIf(SKIP)("tui: slash menu", () => {
|
||||
|
||||
await session.sendKeys("C-u");
|
||||
await session.sendText("/help");
|
||||
await waitForHelpMenu(session, 35);
|
||||
await waitForHelpMenu(session, 36);
|
||||
await session.sendLiteralText("additional directories");
|
||||
await waitForHelpMenu(session, 1);
|
||||
await session.sendKeys("Enter");
|
||||
@@ -1618,7 +1618,7 @@ describe.skipIf(SKIP)("tui: slash menu", () => {
|
||||
|
||||
await session.sendKeys("C-u");
|
||||
await session.sendText("/help");
|
||||
await waitForHelpMenu(session, 35);
|
||||
await waitForHelpMenu(session, 36);
|
||||
await session.sendLiteralText("no command can match this query");
|
||||
await session.waitForText("No commands found.", 5_000);
|
||||
await session.sendKeys("Escape");
|
||||
@@ -2647,7 +2647,7 @@ describe.skipIf(SKIP)("tui: slash menu", () => {
|
||||
expect(alternateCount("\x1b[?1049l")).toBe(leavesBeforeSkills);
|
||||
|
||||
await session.sendText("/help");
|
||||
grid = await waitForHelpMenu(session, 35);
|
||||
grid = await waitForHelpMenu(session, 36);
|
||||
expect(grid.join("\n")).toContain("Run /help for commands");
|
||||
expect(alternateCount("\x1b[?1049h")).toBe(entersBeforeSkills);
|
||||
expect(alternateCount("\x1b[?1049l")).toBe(leavesBeforeSkills);
|
||||
@@ -3789,7 +3789,7 @@ describe.skipIf(SKIP)("tui: slash menu", () => {
|
||||
await session.waitForComposer(10_000);
|
||||
|
||||
await session.sendLiteralText("/");
|
||||
await session.waitForText("Commands 35", 5_000);
|
||||
await session.waitForText("Commands 36", 5_000);
|
||||
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
await session.sendKeys("Down");
|
||||
|
||||
@@ -47,7 +47,7 @@ describe.skipIf(SKIP)("tui: startup and exit", () => {
|
||||
session = await TmuxSession.create();
|
||||
await session.waitForComposer(10_000);
|
||||
await session.sendText("/help");
|
||||
const pane = await session.waitForText("Commands 35", 5_000);
|
||||
const pane = await session.waitForText("Commands 36", 5_000);
|
||||
expect(pane).toContain("[All]");
|
||||
expect(pane).toContain("tab category");
|
||||
expect(pane).toContain("enter open");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, expect, test } from "bun:test";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
@@ -10,7 +11,7 @@ import {
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { FX_BIN } from "../evals/eval-helpers";
|
||||
import { FX_BIN, runFx } from "../evals/eval-helpers";
|
||||
import {
|
||||
FAKE_GATEWAY_MODEL,
|
||||
fakeGatewayFinalText,
|
||||
@@ -24,12 +25,10 @@ import {
|
||||
const TIMEOUT = 30_000;
|
||||
const sessions: TmuxSession[] = [];
|
||||
const roots: string[] = [];
|
||||
const homes: string[] = [];
|
||||
const gateways: Array<ReturnType<typeof startFakeGateway>> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const session of sessions.splice(0)) await session.kill();
|
||||
for (const home of homes.splice(0)) await cleanupTerminalHost(home);
|
||||
for (const gateway of gateways.splice(0)) gateway.stop();
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
@@ -54,7 +53,6 @@ function createFixture(prefix: string) {
|
||||
writeFileSync(tracePath, "");
|
||||
writeFileSync(stderrPath, "");
|
||||
roots.push(root);
|
||||
homes.push(home);
|
||||
return {
|
||||
root,
|
||||
home,
|
||||
@@ -85,7 +83,6 @@ async function launch(
|
||||
FX_GATEWAY_CHAT_URL: gateway.chatUrl,
|
||||
FX_TRACE_LOG: fixture.tracePath,
|
||||
FX_TRACE_SCOPES: "shell,terminal,terminal_client,terminal_host,tool,agent",
|
||||
FX_TERMINAL_HOST_IDLE_MS: "500",
|
||||
},
|
||||
width: 120,
|
||||
height: 32,
|
||||
@@ -165,22 +162,76 @@ function terminalRecords(home: string): Array<Record<string, unknown>> {
|
||||
});
|
||||
}
|
||||
|
||||
async function cleanupTerminalHost(home: string): Promise<void> {
|
||||
const identityPath = join(home, ".fx", "terminal-host-v7", "host.json");
|
||||
const deadline = Date.now() + 3_000;
|
||||
while (Date.now() < deadline) {
|
||||
if (!existsSync(identityPath)) return;
|
||||
await Bun.sleep(25);
|
||||
}
|
||||
function askEnv(
|
||||
fixture: ReturnType<typeof createFixture>,
|
||||
gateway: ReturnType<typeof startFakeGateway>,
|
||||
): Record<string, string | undefined> {
|
||||
return {
|
||||
HOME: fixture.home,
|
||||
SHELL: terminalFixtureShell(),
|
||||
AI_GATEWAY_API_KEY: "fake-shell-tool-key",
|
||||
VERCEL_OIDC_TOKEN: undefined,
|
||||
FX_DISABLE_KEYCHAIN: "1",
|
||||
FX_SKIP_ONBOARDING: "1",
|
||||
FX_AUTO_UPGRADE: "0",
|
||||
FX_PERMISSION_MODE: "yolo",
|
||||
FX_MODEL: FAKE_GATEWAY_MODEL,
|
||||
FX_GATEWAY_BASE_URL: gateway.baseUrl,
|
||||
FX_GATEWAY_CHAT_URL: gateway.chatUrl,
|
||||
FX_TRACE_LOG: fixture.tracePath,
|
||||
FX_TRACE_SCOPES: "shell,terminal,terminal_client,terminal_host,terminal_store,shutdown",
|
||||
};
|
||||
}
|
||||
|
||||
function processAlive(pid: number): boolean {
|
||||
try {
|
||||
const identity = JSON.parse(readFileSync(identityPath, "utf8"));
|
||||
const pid = Number(identity.pid);
|
||||
if (Number.isSafeInteger(pid) && pid > 0) process.kill(pid, "SIGTERM");
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Pids of processes whose command line contains `fragment`. */
|
||||
function processesMatching(fragment: string, options: { exact?: boolean } = {}): number[] {
|
||||
const listing = execFileSync("ps", ["-axo", "pid=,command="], { encoding: "utf8" });
|
||||
return listing.split("\n").flatMap((line) => {
|
||||
const match = line.trim().match(/^(\d+)\s+(.*)$/);
|
||||
if (!match) return [];
|
||||
const command = match[2]!.trim();
|
||||
const matches = options.exact ? command === fragment : command.includes(fragment);
|
||||
return matches ? [Number(match[1])] : [];
|
||||
});
|
||||
}
|
||||
|
||||
async function waitUntilGone(pids: number[], timeoutMs: number): Promise<number> {
|
||||
const started = Date.now();
|
||||
while (pids.some(processAlive)) {
|
||||
if (Date.now() - started > timeoutMs) {
|
||||
throw new Error(`processes still running after ${timeoutMs}ms: ${pids.filter(processAlive)}`);
|
||||
}
|
||||
await Bun.sleep(10);
|
||||
}
|
||||
return Date.now() - started;
|
||||
}
|
||||
|
||||
function expectNoTerminalHostDaemon(fixture: ReturnType<typeof createFixture>): void {
|
||||
expect(processesMatching(`${FX_BIN} --fx-internal-terminal-host`)).toEqual([]);
|
||||
expect(existsSync(join(fixture.home, ".fx", "terminal-host-v7"))).toBe(false);
|
||||
}
|
||||
|
||||
function terminalRecord(home: string, sessionId: string): Record<string, unknown> {
|
||||
const record = terminalRecords(home).find((candidate) => candidate.session_id === sessionId);
|
||||
if (!record) throw new Error(`missing terminal record ${sessionId}`);
|
||||
return record;
|
||||
}
|
||||
|
||||
function ttyRun(id: string, command: string, extra: Record<string, unknown> = {}) {
|
||||
return fakeGatewayToolCall(id, "shell", {
|
||||
request: { action: "run", command, profile: "clean", tty: true, ...extra },
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForFile(path: string): Promise<void> {
|
||||
const deadline = Date.now() + TIMEOUT;
|
||||
while (Date.now() < deadline) {
|
||||
@@ -604,7 +655,6 @@ test.skipIf(!tmuxAvailable())(
|
||||
FX_GATEWAY_CHAT_URL: gateway.chatUrl,
|
||||
FX_TRACE_LOG: fixture.tracePath,
|
||||
FX_TRACE_SCOPES: "shell,terminal,terminal_client,terminal_host,tool,agent",
|
||||
FX_TERMINAL_HOST_IDLE_MS: "500",
|
||||
},
|
||||
width: 160,
|
||||
height: 32,
|
||||
@@ -678,7 +728,6 @@ test.skipIf(!tmuxAvailable())(
|
||||
FX_GATEWAY_CHAT_URL: gateway.chatUrl,
|
||||
FX_TRACE_LOG: fixture.tracePath,
|
||||
FX_TRACE_SCOPES: "shell,terminal,terminal_client,terminal_host,tool,agent",
|
||||
FX_TERMINAL_HOST_IDLE_MS: "500",
|
||||
PS1: "RESUME_SHELL> ",
|
||||
},
|
||||
width: 160,
|
||||
@@ -891,64 +940,256 @@ test.skipIf(!tmuxAvailable())(
|
||||
);
|
||||
|
||||
test.skipIf(!tmuxAvailable())(
|
||||
"resumed fx reindexes and stops its durable managed TTY",
|
||||
"quit ends a managed TTY and resume reports it as ended",
|
||||
async () => {
|
||||
const fixture = createFixture("fx-shell-tty-resume-");
|
||||
let sessionId = "";
|
||||
const gateway = startFakeGateway([
|
||||
fakeGatewayToolCall("shell_tty_resume_run", "shell", {
|
||||
request: {
|
||||
action: "run",
|
||||
command:
|
||||
"printf 'TTY_RESUME_READY\\n'; while IFS= read -r line; do printf 'TTY_RESUME_ECHO:%s\\n' \"$line\"; done",
|
||||
profile: "clean",
|
||||
tty: true,
|
||||
yield_time_ms: 0,
|
||||
},
|
||||
}),
|
||||
ttyRun(
|
||||
"shell_tty_resume_run",
|
||||
"printf 'TTY_RESUME_READY\\n'; sleep 37.25",
|
||||
{ yield_time_ms: 0 },
|
||||
),
|
||||
(body) => {
|
||||
sessionId = findSessionId(JSON.parse(body)) ?? "";
|
||||
return fakeGatewayFinalText("SHELL_TTY_RESUME_STARTED");
|
||||
},
|
||||
() => fakeGatewayToolCall("shell_tty_resume_interact", "shell", {
|
||||
request: { action: "interact", session_id: sessionId, chars: "x" },
|
||||
}),
|
||||
() => fakeGatewayToolCall("shell_tty_resume_stop", "shell", {
|
||||
request: {
|
||||
action: "stop",
|
||||
session_id: sessionId,
|
||||
force: true,
|
||||
},
|
||||
}),
|
||||
request: { action: "stop", session_id: sessionId, force: true },
|
||||
}),
|
||||
fakeGatewayFinalText("SHELL_TTY_RESUME_OK"),
|
||||
]);
|
||||
gateways.push(gateway);
|
||||
|
||||
const first = await launch(fixture, gateway);
|
||||
await first.sendText("Start the durable managed TTY.");
|
||||
await first.sendText("Start the managed TTY.");
|
||||
await first.waitForText("SHELL_TTY_RESUME_STARTED", TIMEOUT);
|
||||
expect(sessionId).toMatch(/^shell-[A-Za-z0-9_-]{22}$/);
|
||||
const shellPid = Number(terminalRecord(fixture.home, sessionId).pid);
|
||||
expect(processAlive(shellPid)).toBe(true);
|
||||
await first.sendText("/quit");
|
||||
expect(await first.waitForSessionEnd(TIMEOUT)).toBe(true);
|
||||
|
||||
// The terminal belonged to the fx process and ended with it, with no prompt.
|
||||
await waitUntilGone([shellPid, ...processesMatching("sleep 37.25")], 1_000);
|
||||
expect(terminalRecord(fixture.home, sessionId).lifecycle).toBe("lost");
|
||||
expectNoTerminalHostDaemon(fixture);
|
||||
|
||||
const resumed = await launch(
|
||||
fixture,
|
||||
gateway,
|
||||
`${FX_BIN} --resume-last`,
|
||||
);
|
||||
await resumed.sendText("Force-stop the exact retained managed TTY.");
|
||||
await resumed.sendText("Use the earlier managed TTY.");
|
||||
await resumed.waitForText("SHELL_TTY_RESUME_OK", TIMEOUT);
|
||||
|
||||
const stopResult = toolResultEnvelope(
|
||||
gateway.requests[3]!.body,
|
||||
"shell_tty_resume_stop",
|
||||
);
|
||||
expect(stopResult).toContain('\\"state\\":\\"stopped\\"');
|
||||
const scrollback = await resumed.captureFullScrollback();
|
||||
expect(scrollback).toContain(`Stopped session ${sessionId}`);
|
||||
expect(scrollback).not.toContain("Exited 143");
|
||||
const record = terminalRecords(fixture.home).find((candidate) =>
|
||||
candidate.session_id === sessionId
|
||||
);
|
||||
expect(record?.lifecycle).toBe("closed");
|
||||
// Neither interact nor stop reattaches; both tell the agent to start anew.
|
||||
for (const [index, id] of [
|
||||
[3, "shell_tty_resume_interact"],
|
||||
[4, "shell_tty_resume_stop"],
|
||||
] as const) {
|
||||
const result = toolResultEnvelope(gateway.requests[index]!.body, id);
|
||||
expect(result).toContain("TerminalEnded");
|
||||
expect(result).toContain("ended when the fx process that started it exited");
|
||||
expect(result).toContain("Start a new terminal");
|
||||
}
|
||||
// The transcript shows why the terminal is gone.
|
||||
expect(await resumed.captureFullScrollback()).toContain("ended when fx exited");
|
||||
expect(terminalRecord(fixture.home, sessionId).lifecycle).toBe("lost");
|
||||
expect(readFileSync(fixture.stderrPath, "utf8")).toBe("");
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
|
||||
test("fx ask exit ends its TTY terminal within one second", async () => {
|
||||
const fixture = createFixture("fx-shell-tty-ask-exit-");
|
||||
let sessionId = "";
|
||||
const gateway = startFakeGateway([
|
||||
ttyRun("shell_tty_ask_run", "sleep 37.25", { yield_time_ms: 0 }),
|
||||
(body) => {
|
||||
sessionId = findSessionId(JSON.parse(body)) ?? "";
|
||||
return fakeGatewayFinalText("ASK_TTY_STARTED");
|
||||
},
|
||||
]);
|
||||
gateways.push(gateway);
|
||||
|
||||
const result = await runFx(["ask", "--json", "Start a long TTY."], {
|
||||
cwd: fixture.workspace,
|
||||
env: askEnv(fixture, gateway),
|
||||
timeoutMs: TIMEOUT,
|
||||
});
|
||||
const exitedAt = Date.now();
|
||||
expect(result.code).toBe(0);
|
||||
expect(JSON.parse(result.stdout).output).toBe("ASK_TTY_STARTED");
|
||||
expect(sessionId).toMatch(/^shell-[A-Za-z0-9_-]{22}$/);
|
||||
|
||||
const shellPid = Number(terminalRecord(fixture.home, sessionId).pid);
|
||||
// Match the exact command line of a duration nothing else uses, so other
|
||||
// processes that merely mention it do not count.
|
||||
const sleepers = processesMatching("sleep 37.25", { exact: true });
|
||||
for (const pid of [shellPid, ...sleepers]) {
|
||||
if (!processAlive(pid)) continue;
|
||||
await waitUntilGone([pid], Math.max(0, 1_000 - (Date.now() - exitedAt)));
|
||||
}
|
||||
expect(processAlive(shellPid)).toBe(false);
|
||||
expect(terminalRecord(fixture.home, sessionId).lifecycle).toBe("lost");
|
||||
expectNoTerminalHostDaemon(fixture);
|
||||
expect(readFileSync(fixture.tracePath, "utf8")).toContain(
|
||||
"ended 1 terminal(s) for process exit",
|
||||
);
|
||||
}, TIMEOUT);
|
||||
|
||||
test("TTY runs in the requested cwd on a real PTY and reports the exact exit", async () => {
|
||||
const fixture = createFixture("fx-shell-tty-pty-");
|
||||
const nested = join(fixture.workspace, "nested");
|
||||
mkdirSync(nested);
|
||||
const gateway = startFakeGateway([
|
||||
ttyRun(
|
||||
"shell_tty_pty_run",
|
||||
"printf 'CWD:%s\\n' \"$PWD\"; " +
|
||||
"if [ -t 0 ] && [ -t 1 ]; then printf 'PTY:yes\\n'; fi; " +
|
||||
"printf 'SIZE:%s\\n' \"$(stty size)\"; exit 7",
|
||||
{ cwd: nested, yield_time_ms: 30_000 },
|
||||
),
|
||||
fakeGatewayFinalText("PTY_DONE"),
|
||||
]);
|
||||
gateways.push(gateway);
|
||||
|
||||
const result = await runFx(["ask", "--json", "Probe the PTY."], {
|
||||
cwd: fixture.workspace,
|
||||
env: askEnv(fixture, gateway),
|
||||
timeoutMs: TIMEOUT,
|
||||
});
|
||||
expect(result.code).toBe(0);
|
||||
const envelope = toolResultEnvelope(gateway.requests[1]!.body, "shell_tty_pty_run");
|
||||
expect(envelope).toContain(`CWD:${nested}`);
|
||||
expect(envelope).toContain("PTY:yes");
|
||||
expect(envelope).toContain("SIZE:24 80");
|
||||
expect(envelope).toContain('\\"exit_code\\":7');
|
||||
expect(envelope).toContain('\\"backend\\":\\"tty\\"');
|
||||
}, TIMEOUT);
|
||||
|
||||
test.skipIf(!tmuxAvailable())(
|
||||
"another fx process can neither drive nor end a running owner's terminal",
|
||||
async () => {
|
||||
const fixture = createFixture("fx-shell-tty-owner-");
|
||||
let ownerTerminal = "";
|
||||
const ownerGateway = startFakeGateway([
|
||||
ttyRun("shell_tty_owner_run", "sleep 41.5", { yield_time_ms: 0 }),
|
||||
(body) => {
|
||||
ownerTerminal = findSessionId(JSON.parse(body)) ?? "";
|
||||
return fakeGatewayFinalText("OWNER_TTY_STARTED");
|
||||
},
|
||||
]);
|
||||
gateways.push(ownerGateway);
|
||||
const owner = await launch(fixture, ownerGateway);
|
||||
await owner.sendText("Start the owned TTY.");
|
||||
await owner.waitForText("OWNER_TTY_STARTED", TIMEOUT);
|
||||
const shellPid = Number(terminalRecord(fixture.home, ownerTerminal).pid);
|
||||
|
||||
const intruderGateway = startFakeGateway([
|
||||
() => fakeGatewayToolCall("intruder_interact", "shell", {
|
||||
request: { action: "interact", session_id: ownerTerminal, chars: "exit\n" },
|
||||
}),
|
||||
() => fakeGatewayToolCall("intruder_stop", "shell", {
|
||||
request: { action: "stop", session_id: ownerTerminal, force: true },
|
||||
}),
|
||||
fakeGatewayFinalText("INTRUDER_DONE"),
|
||||
]);
|
||||
gateways.push(intruderGateway);
|
||||
const intruder = await runFx(["ask", "--json", "Touch the other terminal."], {
|
||||
cwd: fixture.workspace,
|
||||
env: askEnv(fixture, intruderGateway),
|
||||
timeoutMs: TIMEOUT,
|
||||
});
|
||||
expect(intruder.code).toBe(0);
|
||||
const intrusion = JSON.parse(intruder.stdout) as {
|
||||
tool_calls: Array<{ name: string; status: string }>;
|
||||
};
|
||||
expect(intrusion.tool_calls.map(({ status }) => status)).toEqual(["error", "error"]);
|
||||
|
||||
// The other process exited, but this terminal is still owned and running.
|
||||
await Bun.sleep(300);
|
||||
expect(processAlive(shellPid)).toBe(true);
|
||||
expect(terminalRecord(fixture.home, ownerTerminal).lifecycle).toBe("running");
|
||||
|
||||
await owner.sendText("/quit");
|
||||
expect(await owner.waitForSessionEnd(TIMEOUT)).toBe(true);
|
||||
await waitUntilGone([shellPid], 1_000);
|
||||
expect(terminalRecord(fixture.home, ownerTerminal).lifecycle).toBe("lost");
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
|
||||
test("TTY stop reaches a background job the terminal started", async () => {
|
||||
const fixture = createFixture("fx-shell-tty-signal-");
|
||||
const pidFile = join(fixture.root, "background.pid");
|
||||
let sessionId = "";
|
||||
const gateway = startFakeGateway([
|
||||
ttyRun(
|
||||
"shell_tty_signal_run",
|
||||
`sleep 43.5 & printf '%s' "$!" > ${JSON.stringify(pidFile)}; printf 'BG_READY\\n'; wait`,
|
||||
{ yield_time_ms: 0 },
|
||||
),
|
||||
async (body) => {
|
||||
sessionId = findSessionId(JSON.parse(body)) ?? "";
|
||||
await waitForFile(pidFile);
|
||||
return fakeGatewayToolCall("shell_tty_signal_stop", "shell", {
|
||||
request: { action: "stop", session_id: sessionId, force: true },
|
||||
});
|
||||
},
|
||||
fakeGatewayFinalText("SIGNAL_DONE"),
|
||||
]);
|
||||
gateways.push(gateway);
|
||||
|
||||
const result = await runFx(["ask", "--json", "Stop the background job."], {
|
||||
cwd: fixture.workspace,
|
||||
env: askEnv(fixture, gateway),
|
||||
timeoutMs: TIMEOUT,
|
||||
});
|
||||
expect(result.code).toBe(0);
|
||||
const stopResult = toolResultEnvelope(gateway.requests[2]!.body, "shell_tty_signal_stop");
|
||||
expect(stopResult).toContain('\\"state\\":\\"stopped\\"');
|
||||
const backgroundPid = Number(readFileSync(pidFile, "utf8"));
|
||||
await waitUntilGone([backgroundPid], 1_000);
|
||||
expect(terminalRecord(fixture.home, sessionId).lifecycle).toBe("closed");
|
||||
}, TIMEOUT);
|
||||
|
||||
test("the full terminal budget starts, refuses one more, and ends together at exit", async () => {
|
||||
const fixture = createFixture("fx-shell-tty-capacity-");
|
||||
const budget = 64;
|
||||
const responses: Parameters<typeof startFakeGateway>[0] = [];
|
||||
for (let index = 0; index <= budget; index += 1) {
|
||||
responses.push(ttyRun(`shell_tty_capacity_${index}`, "sleep 45.75", { yield_time_ms: 0 }));
|
||||
}
|
||||
responses.push(fakeGatewayFinalText("CAPACITY_DONE"));
|
||||
const gateway = startFakeGateway(responses);
|
||||
gateways.push(gateway);
|
||||
|
||||
const result = await runFx(["ask", "--json", "Fill the terminal budget."], {
|
||||
cwd: fixture.workspace,
|
||||
env: { ...askEnv(fixture, gateway), FX_MAX_AGENT_STEPS: "100" },
|
||||
timeoutMs: 120_000,
|
||||
});
|
||||
const exitedAt = Date.now();
|
||||
expect(result.code).toBe(0);
|
||||
const statuses = (JSON.parse(result.stdout) as {
|
||||
tool_calls: Array<{ status: string }>;
|
||||
}).tool_calls.map(({ status }) => status);
|
||||
expect(statuses.slice(0, budget).every((status) => status === "success")).toBe(true);
|
||||
expect(statuses[budget]).toBe("error");
|
||||
expect(toolResultEnvelope(gateway.requests[budget + 1]!.body, `shell_tty_capacity_${budget}`))
|
||||
.toContain("Capacity");
|
||||
|
||||
const records = terminalRecords(fixture.home);
|
||||
expect(records).toHaveLength(budget);
|
||||
const shellPids = records.map((record) => Number(record.pid));
|
||||
await waitUntilGone(
|
||||
[...shellPids, ...processesMatching("sleep 45.75")],
|
||||
Math.max(0, 1_500 - (Date.now() - exitedAt)),
|
||||
);
|
||||
expect(records.every((record) => record.lifecycle === "lost")).toBe(true);
|
||||
}, 150_000);
|
||||
@@ -80,6 +80,8 @@ export interface AbConfig {
|
||||
export interface AbRunOptions {
|
||||
env?: Record<string, string | undefined>;
|
||||
score?: (result: HeadlessResult) => AbScore;
|
||||
/** Prepares the fresh trial HOME before fx starts; it is removed after the trial. */
|
||||
setupHome?: (home: string) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function createTrialOrder(trialIndex: number): [AbSide, AbSide] {
|
||||
@@ -298,6 +300,7 @@ export async function runAbTrial(
|
||||
};
|
||||
|
||||
try {
|
||||
await options.setupHome?.(trialHome);
|
||||
const versionOutput = await versionFor(binaryPath, env);
|
||||
const result = await runProcess(
|
||||
binaryPath,
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* Model-backed A/B eval: shell commands see PATH edits from the user's startup
|
||||
* files.
|
||||
*
|
||||
* Each trial HOME gets a .zshrc and a .bash_profile that prepend a unique
|
||||
* marker directory to PATH, so whichever login shell passwd selects loads it.
|
||||
* The agent runs `echo $PATH` in 10 separate shell calls and replies DONE. A
|
||||
* trial passes when it makes at least 10 successful shell calls, uses no other
|
||||
* tool, and every printed PATH contains the marker directory. `fx ask --json`
|
||||
* reports no command output, so outputs are read from the raw command output
|
||||
* that `fx ask` streams to stderr.
|
||||
*
|
||||
* The comparison is report-only like agent-quality-ab: it prints and saves
|
||||
* per-side pass counts and per-trial wall time, and does not fail on model
|
||||
* noise. Per-trial wall time also includes one `fx --version` call.
|
||||
*
|
||||
* It makes real model calls, so run it only on the test billing lane, with
|
||||
* absolute paths to the two binaries:
|
||||
*
|
||||
* cd tests/evals
|
||||
* FX_AB_BASELINE_BIN=/abs/path/baseline/zig-out/bin/fx \
|
||||
* FX_AB_CANDIDATE_BIN=/abs/path/candidate/zig-out/bin/fx \
|
||||
* ~/.local/bin/aig test bun test shell-path-ab.test.ts
|
||||
*
|
||||
* The harness gives every fx run a fresh HOME and passes AI_GATEWAY_API_KEY
|
||||
* from the environment, so the test-lane key from `aig test` is the one billed.
|
||||
*
|
||||
* Optional: FX_AB_MODEL (defaults to EVAL_MODEL), FX_AB_TRIALS (default 3),
|
||||
* FX_AB_OUTPUT_DIR, FX_AB_TIMEOUT_MS, FX_AB_WORKSPACE_ROOT (defaults to an
|
||||
* empty temporary directory).
|
||||
*/
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
classifyObservedDelta,
|
||||
createTrialOrder,
|
||||
loadAbConfigFromEnv,
|
||||
runAbTrial,
|
||||
type AbConfig,
|
||||
type AbScore,
|
||||
type AbSide,
|
||||
} from "./agent-quality-ab";
|
||||
import {
|
||||
SHARED_MODEL_CONTEXT_CONTRACT,
|
||||
type AgentQualityMatrixRow,
|
||||
} from "./agent-quality-matrix";
|
||||
import { EVAL_MODEL, type HeadlessResult } from "./eval-helpers";
|
||||
|
||||
const ECHO_RUNS = 10;
|
||||
|
||||
const SHELL_PATH_ROW: AgentQualityMatrixRow = {
|
||||
id: "shell-path-startup-files",
|
||||
userPrompt:
|
||||
"Use the shell tool to run the command `echo $PATH` 10 separate times: one shell tool call per run, one call at a time, with exactly that command each time. Do not combine runs or change the command. After the tenth run, reply with exactly DONE.",
|
||||
failureCategory: "stale context",
|
||||
expectedFirstTool: { category: "shell", tools: ["shell"], commandPattern: "^echo \\$PATH$" },
|
||||
forbiddenTools: [],
|
||||
expectedUserVisibleBehavior:
|
||||
"Every shell call sees the PATH edits from the user's startup files.",
|
||||
deterministicCoverage: {
|
||||
type: "e2e",
|
||||
status: "planned",
|
||||
notes: "Fixture-gateway startup-file coverage lands with the shell snapshot change.",
|
||||
},
|
||||
modelBackedEval: {
|
||||
required: true,
|
||||
reason: "Checks that the model keeps calling shell and that each call sees the user's PATH.",
|
||||
},
|
||||
currentBaselineResult: { status: "unmeasured", notes: "Compared by this A/B eval." },
|
||||
targetResult: "The candidate passes every trial the baseline passes, with lower wall time.",
|
||||
coveredEntrypoints: [
|
||||
{
|
||||
entrypoint: "fx ask",
|
||||
contextContract: SHARED_MODEL_CONTEXT_CONTRACT,
|
||||
notes: "Captured shell calls under the default user profile.",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function writeStartupFiles(home: string): string {
|
||||
const marker = join(home, `fx-shell-path-marker-${randomUUID().slice(0, 8)}`);
|
||||
mkdirSync(marker);
|
||||
const line = `export PATH="${marker}:$PATH"\n`;
|
||||
writeFileSync(join(home, ".zshrc"), line);
|
||||
writeFileSync(join(home, ".bash_profile"), line);
|
||||
return marker;
|
||||
}
|
||||
|
||||
function shellCallCount(result: HeadlessResult | undefined): number {
|
||||
return (result?.tool_calls ?? []).filter((call) => call.name === "shell").length;
|
||||
}
|
||||
|
||||
function scoreShellPathCalls(result: HeadlessResult): AbScore {
|
||||
const calls = result.tool_calls ?? [];
|
||||
const shellCalls = calls.filter((call) => call.name === "shell");
|
||||
const otherTools = [...new Set(calls.filter((call) => call.name !== "shell").map((call) => call.name))];
|
||||
const failed = shellCalls.filter(
|
||||
(call) => call.status !== "success" || call.command_result?.exit_code !== 0,
|
||||
).length;
|
||||
const reasons: string[] = [];
|
||||
if (shellCalls.length < ECHO_RUNS) {
|
||||
reasons.push(`${shellCalls.length} shell calls, expected at least ${ECHO_RUNS}`);
|
||||
}
|
||||
if (otherTools.length > 0) reasons.push(`non-shell tools used: ${otherTools.join(", ")}`);
|
||||
if (failed > 0) reasons.push(`${failed} shell calls failed`);
|
||||
if (result.exit_code !== 0) reasons.push(`exit_code was ${result.exit_code}`);
|
||||
return {
|
||||
passed: reasons.length == 0,
|
||||
reason: reasons.length == 0 ? "passed" : reasons.join("; "),
|
||||
firstTool: calls[0]?.name,
|
||||
forbiddenTools: otherTools,
|
||||
predicatePassed: reasons.length == 0,
|
||||
};
|
||||
}
|
||||
|
||||
function isPathOutput(line: string): boolean {
|
||||
const entries = line.trim().split(":");
|
||||
return entries.length > 1 && entries.every((entry) => entry === "" || entry.startsWith("/"));
|
||||
}
|
||||
|
||||
function checkShellPathOutputs(stderr: string, marker: string, shellCalls: number) {
|
||||
const outputs = stderr.split("\n").filter(isPathOutput);
|
||||
const markedOutputs = outputs.filter((line) => line.trim().split(":").includes(marker)).length;
|
||||
const reasons: string[] = [];
|
||||
if (shellCalls == 0 || outputs.length < shellCalls) {
|
||||
reasons.push(`${outputs.length} PATH outputs for ${shellCalls} shell calls`);
|
||||
}
|
||||
if (markedOutputs < outputs.length) {
|
||||
reasons.push(`${outputs.length - markedOutputs} PATH outputs missing the marker directory`);
|
||||
}
|
||||
return {
|
||||
passed: reasons.length == 0,
|
||||
reason: reasons.join("; "),
|
||||
pathOutputs: outputs.length,
|
||||
markedOutputs,
|
||||
};
|
||||
}
|
||||
|
||||
interface ShellPathTrial {
|
||||
side: AbSide;
|
||||
trialIndex: number;
|
||||
orderIndex: number;
|
||||
wallMs: number;
|
||||
passed: boolean;
|
||||
reason: string;
|
||||
shellCalls: number;
|
||||
pathOutputs: number;
|
||||
markedOutputs: number;
|
||||
}
|
||||
|
||||
function sideSummary(trials: ShellPathTrial[], side: AbSide) {
|
||||
const runs = trials.filter((trial) => trial.side === side);
|
||||
const wall = runs.map((trial) => trial.wallMs).sort((a, b) => a - b);
|
||||
return {
|
||||
passes: runs.filter((trial) => trial.passed).length,
|
||||
trials: runs.length,
|
||||
meanWallMs: wall.length ? Math.round(wall.reduce((sum, ms) => sum + ms, 0) / wall.length) : null,
|
||||
medianWallMs: wall.length ? wall[Math.floor(wall.length / 2)] : null,
|
||||
};
|
||||
}
|
||||
|
||||
async function runShellPathComparison(config: AbConfig): Promise<void> {
|
||||
mkdirSync(config.outputDir, { recursive: true });
|
||||
const trials: ShellPathTrial[] = [];
|
||||
for (let trialIndex = 0; trialIndex < config.trials; trialIndex += 1) {
|
||||
for (const [orderIndex, side] of createTrialOrder(trialIndex).entries()) {
|
||||
let marker = "";
|
||||
const started = performance.now();
|
||||
const run = await runAbTrial(config, SHELL_PATH_ROW, side, trialIndex, orderIndex, {
|
||||
setupHome: (home) => {
|
||||
marker = writeStartupFiles(home);
|
||||
},
|
||||
score: scoreShellPathCalls,
|
||||
});
|
||||
const wallMs = Math.round(performance.now() - started);
|
||||
const shellCalls = shellCallCount(run.json);
|
||||
const outputs = checkShellPathOutputs(run.stderr, marker, shellCalls);
|
||||
const reasons = [run.score.passed ? "" : run.score.reason, outputs.reason].filter(Boolean);
|
||||
const trial: ShellPathTrial = {
|
||||
side,
|
||||
trialIndex,
|
||||
orderIndex,
|
||||
wallMs,
|
||||
passed: run.score.passed && outputs.passed,
|
||||
reason: reasons.length == 0 ? "passed" : reasons.join("; "),
|
||||
shellCalls,
|
||||
pathOutputs: outputs.pathOutputs,
|
||||
markedOutputs: outputs.markedOutputs,
|
||||
};
|
||||
trials.push(trial);
|
||||
console.log(
|
||||
`${side} trial ${trialIndex}: ${trial.passed ? "pass" : "fail"} in ${wallMs} ms ` +
|
||||
`(${shellCalls} shell calls, ${trial.markedOutputs}/${trial.pathOutputs} PATH outputs with marker)` +
|
||||
(trial.passed ? "" : `: ${trial.reason}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const baseline = sideSummary(trials, "baseline");
|
||||
const candidate = sideSummary(trials, "candidate");
|
||||
const delta = classifyObservedDelta({
|
||||
baselinePasses: baseline.passes,
|
||||
candidatePasses: candidate.passes,
|
||||
trials: config.trials,
|
||||
});
|
||||
writeFileSync(
|
||||
join(config.outputDir, "shell-path-summary.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
config: {
|
||||
baselineBin: config.baselineBin,
|
||||
candidateBin: config.candidateBin,
|
||||
model: config.model,
|
||||
trials: config.trials,
|
||||
workspaceRoot: config.workspaceRoot,
|
||||
},
|
||||
baseline,
|
||||
candidate,
|
||||
delta,
|
||||
trials,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
console.log(`A/B artifacts: ${config.outputDir}`);
|
||||
console.log(
|
||||
`${SHELL_PATH_ROW.id}: baseline ${baseline.passes}/${baseline.trials} (median ${baseline.medianWallMs} ms), ` +
|
||||
`candidate ${candidate.passes}/${candidate.trials} (median ${candidate.medianWallMs} ms) -> ${delta}`,
|
||||
);
|
||||
}
|
||||
|
||||
describe("shell PATH A/B scoring", () => {
|
||||
const marker = "/tmp/home/fx-shell-path-marker-test";
|
||||
const shell = { name: "shell", status: "success", command_result: { command: "echo $PATH", exit_code: 0 } };
|
||||
const headless = (tool_calls: HeadlessResult["tool_calls"]): HeadlessResult => ({
|
||||
output: "DONE", exit_code: 0, model: "fixture/model", session_id: "", steps: tool_calls.length, tool_calls,
|
||||
});
|
||||
|
||||
test("passes ten successful shell calls whose PATH outputs carry the marker", () => {
|
||||
expect(scoreShellPathCalls(headless(Array.from({ length: 10 }, () => shell))).passed).toBe(true);
|
||||
const stderr = Array.from({ length: 10 }, () => `Running echo $PATH\n${marker}:/usr/bin:/bin\n`).join("");
|
||||
expect(checkShellPathOutputs(stderr, marker, 10)).toMatchObject({ passed: true, pathOutputs: 10, markedOutputs: 10 });
|
||||
});
|
||||
|
||||
test("fails short runs, other tools and PATH outputs without the marker", () => {
|
||||
expect(scoreShellPathCalls(headless(Array.from({ length: 9 }, () => shell))).passed).toBe(false);
|
||||
const mixed = scoreShellPathCalls(headless([...Array.from({ length: 10 }, () => shell), { name: "read_file", status: "success" }]));
|
||||
expect(mixed.passed).toBe(false);
|
||||
expect(mixed.forbiddenTools).toEqual(["read_file"]);
|
||||
const stderr = `Running echo $PATH\n${marker}:/usr/bin\nRunning echo $PATH\n/usr/bin:/bin\n`;
|
||||
expect(checkShellPathOutputs(stderr, marker, 2)).toMatchObject({ passed: false, pathOutputs: 2, markedOutputs: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
const hasLiveConfig = Boolean(
|
||||
process.env.FX_AB_BASELINE_BIN &&
|
||||
process.env.FX_AB_CANDIDATE_BIN &&
|
||||
(process.env.AI_GATEWAY_API_KEY || process.env.VERCEL_OIDC_TOKEN),
|
||||
);
|
||||
|
||||
describe("shell PATH A/B live comparison", () => {
|
||||
(hasLiveConfig ? test : test.skip)(
|
||||
"compares startup-file PATH visibility and wall time between binaries",
|
||||
async () => {
|
||||
const workspace = mkdtempSync(join(tmpdir(), "fx-shell-path-ab-workspace-"));
|
||||
try {
|
||||
await runShellPathComparison(
|
||||
loadAbConfigFromEnv({
|
||||
...process.env,
|
||||
FX_AB_MODEL: process.env.FX_AB_MODEL ?? EVAL_MODEL,
|
||||
FX_AB_WORKSPACE_ROOT: process.env.FX_AB_WORKSPACE_ROOT ?? workspace,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
rmSync(workspace, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
30 * 60 * 1000,
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user