diff --git a/docs/agent-tutorial.md b/docs/agent-tutorial.md index 8134a127..c1d0aefa 100644 --- a/docs/agent-tutorial.md +++ b/docs/agent-tutorial.md @@ -15,7 +15,7 @@ One session against Hacker News: 1. Log in with your account. 2. Confirm the login by reading the username out of the header. -3. Record the whole flow to a `.js` file. +3. Save the whole flow to a `.js` file. 4. Run it offline, with no LLM. 5. Add local JavaScript logic around `extract(...)` results. 6. Save the same flow as a script from an external agent over MCP. @@ -280,18 +280,20 @@ The schema is parsed in Zig before the page-side walker runs, so a typo like a stray comma surfaces here as `Error: invalid /extract schema JSON` instead of a confusing V8 stack trace. -## 4. Recording the session +## 4. Saving the session -The same flow, but recorded to a file. Quit the REPL, then: +The same flow, but exported to a file. In the same REPL, retype the +sequence — login (`/goto`, two `/fill`s, `/click`, `/waitForSelector`), +then the front-page hop and structured pull (`/goto`, multi-line +`/extract`) — then save it: -```console -./lightpanda agent -i hn_login.js +``` +> /save hn_login.js ``` -`-i ` opens an interactive REPL that appends state-mutating -commands to ``. Retype the same sequence — login (`/goto`, two -`/fill`s, `/click`, `/waitForSelector`), then the front-page hop and -structured pull (`/goto`, multi-line `/extract`) — then `/quit`. +In the basic REPL (`--no-llm`) `/save` transcribes the session +deterministically; with an LLM it synthesizes an equivalent idiomatic +script. Either way `/quit` when you're done. Inspect the result: @@ -300,7 +302,7 @@ cat hn_login.js ``` You should see the seven mutating commands and nothing else — no -`/tree`, no `/markdown`, no read-only lookups. The recorder filters on a +`/tree`, no `/markdown`, no read-only lookups. `/save` filters on a per-tool flag (`ToolDef.recorded`) so read-only inspection never pollutes the script; `/extract` *is* recorded (it changes what the script can read on replay even though it doesn't mutate the page). @@ -326,9 +328,10 @@ prompt is kept as a `//` comment above those actions. ./lightpanda agent hn_login.js ``` -No `--provider`, no LLM, no token spend. The recorded script runs top +No `--provider`, no LLM, no token spend. The saved script runs top to bottom against a fresh browser. This is the form you want for -regression tests and CI. +regression tests and CI. From inside the REPL, `/load hn_login.js` +runs the same script against the current session. A JavaScript script does not print its final expression automatically. The recorded `extract(...)` call returns a local JavaScript value, so @@ -355,11 +358,10 @@ Run it again and stdout is clean JSON: ./lightpanda agent hn_login.js > stories.json ``` -`/login` and `/acceptCookies` are REPL-only LLM triggers. A pure -recording from `-i` never contains them; the recorder captures the -resulting browser tool calls instead. Lines that are neither slash -commands nor comments are also REPL-only conveniences, not script -syntax. +`/login` and `/acceptCookies` are REPL-only LLM triggers. A script +saved with `/save` never contains them; `/save` captures the resulting +browser tool calls instead. Lines that are neither slash commands nor +comments are also REPL-only conveniences, not script syntax. ## 6. Local JavaScript logic @@ -441,7 +443,7 @@ The `save` tool's description carries the same guidance the REPL's `/save` gives its LLM (prefer builtins, drop dead-ends, keep `$LP_*` placeholders), and any literal `LP_*` value is scrubbed back to its placeholder before the file is written. The output uses the same -JavaScript format as `-i hn_login.js` from section 4 and runs +JavaScript format as `/save hn_login.js` from section 4 and runs unmodified: ```console diff --git a/docs/agent.md b/docs/agent.md index 853e5178..ea3c17f6 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -35,12 +35,9 @@ etc.) without giving Lightpanda its own API key. # Basic REPL (no LLM, slash commands only) ./lightpanda agent --no-llm -# Run a recorded script +# Run a saved script, then exit ./lightpanda agent session.js -# Replay then continue interactively, appending new commands to the file -./lightpanda agent -i session.js - # One-shot: ask a question, capture the answer on stdout ./lightpanda agent --task "what is on the front page of hn?" ``` @@ -251,20 +248,19 @@ the script, and goes away when that Session does. There is no cross-session persistence; if you need that, use `localStorage` (which is now origin-scoped and persists across navigations within a session). -### Recording +### Saving and loading -Interactive sessions can write back to a `.js` file: - -```console -./lightpanda agent -i session.js -``` +From the REPL, `/save [file.js]` writes the session back to a `.js` file +and `/load ` runs a script from disk against the current session. State-mutating commands (`/goto`, `/click`, `/fill`, `/scroll`, `/hover`, `/selectOption`, `/setChecked`, `/waitForSelector`, `/press`, `/eval`, -`/extract`) are appended; read-only commands (`/tree`, `/markdown`, +`/extract`) are saved; read-only commands (`/tree`, `/markdown`, `/links`, `/findElement`, …) and the natural-language turns that produced -them are not. Natural-language turns are recorded as `// ` comments -above the resulting JavaScript calls so the script stays readable. +them are not. Natural-language turns are saved as `// ` comments +above the resulting JavaScript calls so the script stays readable. In the +basic REPL (`--no-llm`) `/save` transcribes the session deterministically; +with an LLM it synthesizes an equivalent idiomatic script. ### JavaScript Script Running @@ -298,8 +294,10 @@ See [agent-script.md](agent-script.md) for the full script format reference. JSON schema), `/provider [name]` and `/model [name]` change the active provider/model — Tab after the space completes from detected providers and the provider's fetched model list, and bare `/provider`/`/model` print the - current selection — `/quit` exits the REPL, `/verbosity ` - tunes the log level. These are REPL-only and never recorded. + current selection — `/save [file.js]` writes the session to a script and + `/load ` runs one from disk (Tab completes file paths), `/quit` exits + the REPL, `/verbosity ` tunes the log level. These are + REPL-only and never recorded. ``` > /goto https://example.com > /findElement role=button name=Submit diff --git a/src/Config.zig b/src/Config.zig index 797e9a41..6a10feac 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -228,7 +228,6 @@ const Commands = cli.Builder(.{ .{ .name = "model", .type = ?[:0]const u8 }, .{ .name = "base_url", .type = ?[:0]const u8 }, .{ .name = "system_prompt", .type = ?[:0]const u8 }, - .{ .name = "interactive", .short = 'i', .type = bool }, .{ .name = "task", .type = ?[]const u8 }, .{ .name = "attach", .short = 'a', .type = []const u8, .multiple = true }, .{ .name = "verbosity", .type = ?AgentVerbosity }, diff --git a/src/agent/Agent.zig b/src/agent/Agent.zig index c53b5ee0..e3956265 100644 --- a/src/agent/Agent.zig +++ b/src/agent/Agent.zig @@ -28,13 +28,13 @@ const Config = lp.Config; const Command = lp.Command; const Schema = lp.Schema; const Recorder = lp.Recorder; +const ScriptRuntime = lp.Runtime; const Credentials = zenai.provider.Credentials; const App = @import("../App.zig"); const CDPNode = @import("../cdp/Node.zig"); const Terminal = @import("Terminal.zig"); const SlashCommand = @import("SlashCommand.zig"); -const ScriptRuntime = @import("ScriptRuntime.zig"); const settings = @import("settings.zig"); const truncateUtf8 = @import("../string.zig").truncateUtf8; @@ -101,8 +101,7 @@ browser: lp.Browser, session: *lp.Session, node_registry: CDPNode.Registry, terminal: Terminal, -recorder: ?Recorder, -save_buffer: Recorder.Memory, +save_buffer: Recorder, save_path: ?[]u8, script_runtime_mutex: std.Thread.Mutex = .{}, active_script_runtime: ?*ScriptRuntime = null, @@ -111,7 +110,6 @@ message_arena: std.heap.ArenaAllocator, model: []u8, system_prompt: []const u8, script_file: ?[]const u8, -interactive: bool, one_shot_task: ?[]const u8, one_shot_attachments: ?[]const []const u8, cancel_requested: std.atomic.Value(bool) = .init(false), @@ -154,12 +152,6 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent }); return error.ConflictingFlags; } - if (opts.task != null and opts.interactive) { - log.fatal(.app, "conflicting flags", .{ - .hint = "--task is one-shot and exits; drop --interactive or drop --task", - }); - return error.ConflictingFlags; - } if (opts.no_llm and opts.provider != null) { log.warn(.app, "ignoring --provider", .{ .reason = "--no-llm takes precedence" }); } @@ -168,7 +160,7 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent } const is_one_shot = opts.task != null; - const will_repl = !is_one_shot and (opts.interactive or opts.script_file == null); + const will_repl = !is_one_shot and opts.script_file == null; // Basic-mode REPL (no LLM) must be opted into via --no-llm. Without it, // the REPL accepts natural language and an absent API key would only @@ -220,10 +212,6 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent const history_path: ?[:0]const u8 = if (will_repl) ".lp-history" else null; - // `-i ` means "run then grow this file"; a script path alone is - // a pure script run and must not be mutated. - const recorder_path: ?[]const u8 = if (opts.interactive) opts.script_file else null; - self.* = .{ .allocator = allocator, .ai_client = null, @@ -236,7 +224,6 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent .session = undefined, .node_registry = .init(allocator), .terminal = .init(allocator, history_path, Config.agentVerbosity(opts), will_repl), - .recorder = null, .save_buffer = .init(allocator), .save_path = null, .messages = .empty, @@ -244,7 +231,6 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent .model = model, .system_prompt = opts.system_prompt orelse default_system_prompt, .script_file = opts.script_file, - .interactive = opts.interactive, .one_shot_task = opts.task, .one_shot_attachments = if (opts.attach.items.len == 0) null else opts.attach.items, .available_providers = available_providers, @@ -276,21 +262,11 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent if (self.model_credentials != null) _ = completionModels(self, allocator); } - if (recorder_path) |p| { - if (Recorder.init(allocator, std.fs.cwd(), p)) |r| { - self.recorder = r; - self.terminal.printInfo("recording to {s}", .{r.path}); - } else |err| { - self.terminal.printError("recording disabled: {s}", .{@errorName(err)}); - } - } - return self; } pub fn deinit(self: *Agent) void { self.terminal.uninstallLogSink(); - if (self.recorder) |*r| r.deinit(); self.save_buffer.deinit(); if (self.save_path) |p| self.allocator.free(p); self.terminal.deinit(); @@ -404,8 +380,7 @@ pub fn run(self: *Agent) bool { return ok; } if (self.script_file) |path| { - const script_ok = self.runScript(path); - if (!self.interactive) return script_ok; + return self.runScript(path); } self.runRepl(); return true; @@ -509,7 +484,6 @@ fn runRepl(self: *Agent) void { self.terminal.printError("{s}", .{result.text}); } else { self.printData(result.text); - if (self.recorder) |*r| r.recordRaw(line); self.recordSaveRaw(line); } continue :repl; @@ -559,7 +533,6 @@ fn runRepl(self: *Agent) void { self.terminal.endTool(); self.printCommandResult(cmd, result); if (!result.is_error) { - if (self.recorder) |*r| r.record(cmd); self.recordSaveCommand(cmd); } self.recordSlashToolCall(trimmed, tc.name(), tc.args, result) catch |err| { @@ -581,6 +554,7 @@ fn handleMeta(self: *Agent, arena: std.mem.Allocator, meta: *const SlashCommand. .help => self.printSlashHelp(arena, rest), .verbosity => self.handleVerbosity(rest), .save => self.handleSave(arena, rest), + .load => self.handleLoad(rest), .model => self.handleModel(arena, rest), .provider => self.handleProvider(arena, rest), } @@ -600,6 +574,15 @@ fn handleVerbosity(self: *Agent, rest: []const u8) void { self.terminal.printInfo("verbosity: {s}", .{@tagName(level)}); } +fn handleLoad(self: *Agent, rest: []const u8) void { + const path = std.mem.trim(u8, rest, &std.ascii.whitespace); + if (path.len == 0) { + self.terminal.printError("usage: /load ", .{}); + return; + } + _ = self.runScript(path); +} + const api_keys_hint = settings.api_keys_hint; const llm_setup_hint = "drop --no-llm and set an API key (" ++ api_keys_hint ++ ")"; @@ -1050,6 +1033,10 @@ fn printSlashHelp(self: *Agent, arena: std.mem.Allocator, target: []const u8) vo "/save [filename.js] [prompt] — save the session to [filename.js] (a random session-*.js if omitted). With an LLM, synthesizes an idiomatic script from the session and the optional prompt; with --no-llm, dumps the recorded actions verbatim.", .{}, ), + .load => self.terminal.printInfo( + "/load — read a script from disk and run it against the current session; Tab completes file paths", + .{}, + ), .model => self.terminal.printInfo( "/model [name] — change the model; Tab completes the provider's models, bare /model shows the current one", .{}, @@ -1117,9 +1104,25 @@ fn printData(self: *Agent, text: []const u8) void { self.terminal.printAssistant(Terminal.reindentJson(arena.allocator(), text) orelse text); } -fn runScript(self: *Agent, path: []const u8) bool { - self.terminal.printInfo("Running script: {s}", .{path}); +/// Tracks whether a `/load`-run script has emitted any `console.*` output, +/// which decides how `runScript` ends: a script that printed nothing freezes +/// the spinner into a `/goto`-style bullet; one that printed leaves its +/// output as the result. +const ScriptOutput = struct { + terminal: *Terminal, + emitted: bool = false, + /// `Runtime.ConsoleObserver` callback: on the first line, clear the live + /// spinner so output starts clean instead of colliding with the indicator. + fn observe(context: *anyopaque) void { + const self: *ScriptOutput = @ptrCast(@alignCast(context)); + if (self.emitted) return; + self.emitted = true; + self.terminal.endTool(); + } +}; + +fn runScript(self: *Agent, path: []const u8) bool { var script_arena: std.heap.ArenaAllocator = .init(self.allocator); defer script_arena.deinit(); @@ -1145,7 +1148,13 @@ fn runScript(self: *Agent, path: []const u8) bool { self.cancel_requested.store(false, .release); } - if (runtime.runSource(content, path) catch |err| { + var output: ScriptOutput = .{ .terminal = &self.terminal }; + runtime.console_observer = .{ .context = @ptrCast(&output), .notify = ScriptOutput.observe }; + self.terminal.beginTool("script", path); + const result = runtime.runSource(content, path); + self.terminal.endTool(); + + if (result catch |err| { self.terminal.printError("Script failed: {s}", .{@errorName(err)}); return false; }) |message| { @@ -1153,7 +1162,10 @@ fn runScript(self: *Agent, path: []const u8) bool { return false; } - self.terminal.printInfo("Script completed.", .{}); + // A script that printed nothing leaves no trace, so freeze the spinner + // into a green bullet (like /goto); one that printed already showed its + // result. + if (!output.emitted) self.terminal.printScriptDone("script", path); return true; } @@ -1345,14 +1357,7 @@ fn processUserMessage(self: *Agent, input: TurnInput) !?[]const u8 { if (result.cancelled) return self.drainCancellation(msg_baseline); - const file_recorder: ?*Recorder = blk: { - if (self.recorder) |*r| { - if (r.isActive()) break :blk r; - } - break :blk null; - }; - const record_to_memory = input.capture_for_save; - if (file_recorder != null or record_to_memory) { + if (input.capture_for_save) { // When the LLM tries multiple `extract` schemas in one turn, only the // last successful one is the answer — earlier probes are noise. var last_extract_idx: ?usize = null; @@ -1372,19 +1377,10 @@ fn processUserMessage(self: *Agent, input: TurnInput) !?[]const u8 { const cmd = Command.fromToolCall(tool, args); if (!cmd.isRecorded()) continue; if (!recorded_any) { - if (input.record_comment) |c| { - if (file_recorder) |r| r.recordComment(c); - if (record_to_memory) self.recordSaveComment(c); - } + if (input.record_comment) |c| self.recordSaveComment(c); recorded_any = true; } - if (file_recorder) |r| r.record(cmd); - if (record_to_memory) self.recordSaveCommand(cmd); - } - if (file_recorder) |r| { - if (!r.isActive()) { - self.terminal.printError("recording disabled (write failed); see logs", .{}); - } + self.recordSaveCommand(cmd); } } @@ -1545,7 +1541,7 @@ pub fn listModels(allocator: std.mem.Allocator, opts: Config.Agent) !void { }); return error.ConflictingFlags; } - if (opts.task != null or opts.interactive or opts.script_file != null) { + if (opts.task != null or opts.script_file != null) { log.fatal(.app, "list-models is exclusive", .{ .hint = "--list-models only takes --provider/--model/--base-url", }); diff --git a/src/agent/SlashCommand.zig b/src/agent/SlashCommand.zig index 42de9af2..57a6ff73 100644 --- a/src/agent/SlashCommand.zig +++ b/src/agent/SlashCommand.zig @@ -47,7 +47,7 @@ pub const MetaCommand = struct { /// Dispatched by `Agent.handleMeta` via an exhaustive switch so adding /// a new meta command is a compile error until it's wired up there too. - const Tag = enum { help, quit, verbosity, save, model, provider }; + const Tag = enum { help, quit, verbosity, save, load, model, provider }; }; pub const meta_commands = [_]MetaCommand{ @@ -55,6 +55,7 @@ pub const meta_commands = [_]MetaCommand{ .{ .tag = .quit, .name = "quit", .hint = "", .values = &.{}, .description = "Exit the REPL" }, .{ .tag = .verbosity, .name = "verbosity", .hint = "", .values = &.{ "low", "medium", "high" }, .description = "Set agent verbosity" }, .{ .tag = .save, .name = "save", .hint = "[filename.js] [prompt]", .values = &.{}, .description = "Save this session to a file" }, + .{ .tag = .load, .name = "load", .hint = "", .values = &.{}, .description = "Load and run a script from disk" }, .{ .tag = .model, .name = "model", .hint = "[name]", .values = &.{}, .description = "Change the model" }, .{ .tag = .provider, .name = "provider", .hint = "[name]", .values = &.{}, .description = "Change the provider" }, }; diff --git a/src/agent/Terminal.zig b/src/agent/Terminal.zig index 0cfbeb8f..bada83cf 100644 --- a/src/agent/Terminal.zig +++ b/src/agent/Terminal.zig @@ -344,6 +344,11 @@ fn addMetaValueCompletions( if (std.mem.indexOfAny(u8, body, &std.ascii.whitespace) != null) return; const prefix = input[0 .. input.len - body.len]; + if (meta.tag == .load) { + addPathCompletions(cenv, input, body, prefix, buf); + return; + } + // `/provider` / `/model` candidates are resolved at runtime, not in `meta.values`. if (self.completion_source) |src| switch (meta.tag) { .provider, .model => { @@ -362,6 +367,32 @@ fn addMetaValueCompletions( for (meta.values) |v| addPrefixedCompletion(cenv, buf, input, prefix, v, "", body); } +/// Completes `/load`'s argument against the filesystem. The directory part of +/// the partial path is kept verbatim in each candidate; the trailing basename +/// is matched against directory entries, and directories get a `/` suffix. +fn addPathCompletions( + cenv: ?*c.ic_completion_env_t, + input: []const u8, + body: []const u8, + prefix: []const u8, + buf: *[completion_buf_len:0]u8, +) void { + const slash = std.mem.lastIndexOfScalar(u8, body, '/'); + const dir_part = if (slash) |i| body[0 .. i + 1] else ""; + const open_path = if (dir_part.len == 0) "." else dir_part; + + var dir = std.fs.cwd().openDir(open_path, .{ .iterate = true }) catch return; + defer dir.close(); + + var name_buf: [completion_buf_len]u8 = undefined; + var it = dir.iterate(); + while (it.next() catch return) |entry| { + const suffix: []const u8 = if (entry.kind == .directory) "/" else ""; + const full = std.fmt.bufPrint(&name_buf, "{s}{s}", .{ dir_part, entry.name }) catch continue; + addPrefixedCompletion(cenv, buf, input, prefix, full, suffix, body); + } +} + /// Completes `$LP_*` against the live process environment. fn addEnvVarCompletions( cenv: ?*c.ic_completion_env_t, @@ -513,9 +544,31 @@ fn renderMetaHint(self: *Terminal, meta: *const SlashCommand.MetaCommand, body: return writeHints(if (ends_ws) "" else " ", &frags); } if (ends_ws) return null; + if (meta.tag == .load) return ghostPathFirstMatch(body); return ghostFirstMatch(meta.values, body, ""); } +/// Ghosts the first filesystem entry that completes the partial path `body`, +/// appending `/` when the match is a directory. +fn ghostPathFirstMatch(body: []const u8) [*c]const u8 { + const slash = std.mem.lastIndexOfScalar(u8, body, '/'); + const dir_part = if (slash) |i| body[0 .. i + 1] else ""; + const base = body[dir_part.len..]; + const open_path = if (dir_part.len == 0) "." else dir_part; + + var dir = std.fs.cwd().openDir(open_path, .{ .iterate = true }) catch return null; + defer dir.close(); + + var it = dir.iterate(); + while (it.next() catch return null) |entry| { + if (!std.ascii.startsWithIgnoreCase(entry.name, base)) continue; + const suffix: []const u8 = if (entry.kind == .directory) "/" else ""; + const text = std.fmt.bufPrintZ(&hint_buf, "{s}{s}", .{ entry.name[base.len..], suffix }) catch return null; + return text.ptr; + } + return null; +} + /// Ghosts `lead` + the suffix of the first `names` entry that prefix-matches /// `body`. fn ghostFirstMatch(names: []const []const u8, body: []const u8, lead: []const u8) [*c]const u8 { @@ -1090,6 +1143,21 @@ pub fn printToolOutcome(self: *Terminal, name: []const u8, text: []const u8, is_ std.debug.print("{s}{s}[result: {s}]{s} {s}{s}\n", .{ ansi.dim, color, name, ansi.reset, truncated, ellipsis }); } +/// Freeze the script spinner into a green bullet for a `/load` run that +/// produced no output — mirrors a `/goto` outcome line, swapping the braille +/// glyph for a `●`. Only fires when the spinner was shown (REPL + TTY); +/// otherwise the run leaves just its own output. +pub fn printScriptDone(self: *Terminal, name: []const u8, args: []const u8) void { + if (!self.spinner.isEnabled()) return; + var buf: [256]u8 = undefined; + const line = std.fmt.bufPrint( + &buf, + ansi.green ++ "●" ++ ansi.reset ++ " " ++ ansi.dim ++ "[{s} {s}]" ++ ansi.reset ++ "\n", + .{ name, args }, + ) catch return; + _ = std.posix.write(std.posix.STDERR_FILENO, line) catch {}; +} + /// Re-indents `text` as two-space JSON, or null when it isn't a JSON object/array. /// The `{`/`[` sniff skips the parse for the common plain-text case — `text` may /// be up to 1 MiB. diff --git a/src/help.zon b/src/help.zon index faa0e3c8..6b673d7c 100644 --- a/src/help.zon +++ b/src/help.zon @@ -133,16 +133,13 @@ \\ {0s} agent --provider anthropic --model claude-sonnet-4-6 \\ {0s} agent --provider ollama --model qwen3.5:9b \\ {0s} agent --no-llm (basic slash-command-only REPL) - \\ {0s} agent script.js (run a recorded script) - \\ {0s} agent -i script.js (run then drop into REPL, - \\ appending new commands to the file) + \\ {0s} agent script.js (run a saved script, then exit) \\ \\Arguments: - \\[SCRIPT] Optional path to a .js script. - \\ Without -i: runs the script (no LLM calls). - \\ With -i: runs if present, then enters the REPL - \\ and appends new commands to the file (creating - \\ it if it does not yet exist). + \\[SCRIPT] Optional path to a .js script. Runs the script + \\ (no LLM calls) and exits. With no script and no + \\ --task, the REPL starts; from there /load runs a + \\ script and /save exports the session to a file. \\ Caution: .js files can contain eval(...) calls \\ that run arbitrary JavaScript in the page. Only run \\ scripts you trust, the same way you would a shell @@ -183,16 +180,9 @@ \\ \\--system-prompt Override the default system prompt. \\ - \\-i, --interactive After running the positional script (if any), - \\ drop into the REPL with the browser state - \\ preserved. When a positional script is present, - \\ any new commands entered in the REPL are appended - \\ to that file. - \\ Conflicts with --task. - \\ \\--task One-shot mode: run a single user turn, print the \\ final answer to stdout, and exit. Conflicts with - \\ the positional script and with --interactive. + \\ the positional script. \\ \\-a, --attach Feed a local file to the model alongside --task. \\ Repeatable, one file per flag. Text files are diff --git a/src/lightpanda.zig b/src/lightpanda.zig index 9f64e3a1..10852eab 100644 --- a/src/lightpanda.zig +++ b/src/lightpanda.zig @@ -49,11 +49,11 @@ pub const mcp = @import("mcp.zig"); pub const Agent = @import("agent/Agent.zig"); pub const Command = @import("script/command.zig").Command; pub const Recorder = @import("script/Recorder.zig"); +pub const Runtime = @import("script/Runtime.zig"); pub const Schema = @import("script/Schema.zig"); pub const cookies = @import("cookies.zig"); pub const build_config = @import("build_config"); pub const crash_handler = @import("crash_handler.zig"); -pub const AgentScriptRuntime = @import("agent/ScriptRuntime.zig"); const IS_DEBUG = @import("builtin").mode == .Debug; diff --git a/src/script/Recorder.zig b/src/script/Recorder.zig index 72855347..85948fc6 100644 --- a/src/script/Recorder.zig +++ b/src/script/Recorder.zig @@ -16,101 +16,83 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +//! In-memory recorder backing the REPL `/save` command: it filters, formats, +//! and scrubs the session's commands into a JavaScript buffer, leaving +//! persistence timing to the caller. + const std = @import("std"); const lp = @import("lightpanda"); -const log = lp.log; -const testing = @import("../testing.zig"); const Command = @import("command.zig").Command; const Recorder = @This(); allocator: std.mem.Allocator, -/// Open append-mode handle while recording is active. Becomes null when a -/// write fails mid-session and the recorder self-disables; `isActive()` -/// reflects this. -file: ?std.fs.File, -/// Path of the active recording, owned by the Recorder. -path: []const u8, -/// Number of lines successfully appended since init. Bumped only on success -/// so callers see the actual file line count, not the attempt count. +/// Number of lines appended since the last reset. Bumped only on success. lines: u32, +/// Accumulated JavaScript, returned verbatim by `bytes()`. +content: std.Io.Writer.Allocating, /// Reused between writes so each line doesn't alloc/free. buf: std.Io.Writer.Allocating, -/// Reset per write — backs short-lived scrub allocations so the first -/// recorded command pays the page setup and the rest reuse the bump. +/// Reset per write — backs short-lived scrub allocations. arena: std.heap.ArenaAllocator, -/// Append-open `sub_path` under `dir`, inserting a leading newline if the -/// file is non-empty. -pub fn init(allocator: std.mem.Allocator, dir: std.fs.Dir, sub_path: []const u8) !Recorder { - const owned_path = try allocator.dupe(u8, sub_path); - errdefer allocator.free(owned_path); - const file = try openForAppend(dir, sub_path); +pub fn init(allocator: std.mem.Allocator) Recorder { return .{ .allocator = allocator, - .file = file, - .path = owned_path, .lines = 0, + .content = .init(allocator), .buf = .init(allocator), .arena = .init(allocator), }; } -fn openForAppend(dir: std.fs.Dir, sub_path: []const u8) !std.fs.File { - const f = try dir.createFile(sub_path, .{ .truncate = false }); - errdefer f.close(); - try f.seekFromEnd(0); - const pos = try f.getPos(); - if (pos > 0) try f.writeAll("\n"); - return f; -} - pub fn deinit(self: *Recorder) void { + self.content.deinit(); self.buf.deinit(); self.arena.deinit(); - if (self.file) |f| f.close(); - self.allocator.free(self.path); } -pub fn isActive(self: *const Recorder) bool { - return self.file != null; +pub fn bytes(self: *Recorder) []const u8 { + return self.content.written(); } -pub fn record(self: *Recorder, cmd: Command) void { - if (self.file == null) return; +pub fn reset(self: *Recorder) void { + self.lines = 0; + self.content.clearRetainingCapacity(); + self.buf.clearRetainingCapacity(); + _ = self.arena.reset(.retain_capacity); +} + +pub fn record(self: *Recorder, cmd: Command) !void { if (!cmd.isRecorded()) return; - self.tryRecord(cmd) catch |err| self.disable(err); -} - -fn tryRecord(self: *Recorder, cmd: Command) !void { self.buf.clearRetainingCapacity(); _ = self.arena.reset(.retain_capacity); try cmd.formatJs(self.arena.allocator(), &self.buf.writer); try self.buf.writer.writeByte('\n'); - try self.writeScrubbed(); + try self.appendScrubbed(); } -pub fn recordComment(self: *Recorder, comment: []const u8) void { - if (self.file == null) return; - self.tryRecordComment(comment) catch |err| self.disable(err); +pub fn recordComment(self: *Recorder, comment: []const u8) !void { + self.buf.clearRetainingCapacity(); + try writeCommentLines(&self.buf.writer, comment); + try self.appendScrubbed(); } -pub fn recordRaw(self: *Recorder, line: []const u8) void { - if (self.file == null) return; - self.tryRecordRaw(line) catch |err| self.disable(err); -} - -fn tryRecordRaw(self: *Recorder, line: []const u8) !void { +pub fn recordRaw(self: *Recorder, line: []const u8) !void { self.buf.clearRetainingCapacity(); try self.buf.writer.writeAll(line); try self.buf.writer.writeByte('\n'); - try self.writeScrubbed(); + try self.appendScrubbed(); } -fn tryRecordComment(self: *Recorder, comment: []const u8) !void { - self.buf.clearRetainingCapacity(); - try writeCommentLines(&self.buf.writer, comment); - try self.writeScrubbed(); +fn appendScrubbed(self: *Recorder) !void { + // Reverse-substitute any LP_* env-var values that snuck in as literals + // (e.g. an agent that retyped a username it saw via getUrl) so the saved + // script stays portable instead of leaking the resolved secret. + _ = self.arena.reset(.retain_capacity); + const scrubbed = try lp.tools.reverseSubstituteEnvVars(self.arena.allocator(), self.buf.written()); + try self.content.writer.writeAll(scrubbed); + self.lines += @intCast(std.mem.count(u8, scrubbed, "\n")); } /// Emit each line of `comment` as its own `// ` line, stripping lone CRs. @@ -127,349 +109,49 @@ fn writeCommentLines(w: *std.Io.Writer, comment: []const u8) !void { } } -fn writeScrubbed(self: *Recorder) !void { - // Reverse-substitute any LP_* env-var values that snuck in as literals - // (e.g. an agent that retyped a username it saw via getUrl) so the - // recording stays portable instead of leaking the resolved secret. - // Propagate scrub OOM so the recorder disables itself rather than - // silently writing the unscrubbed buffer. - _ = self.arena.reset(.retain_capacity); - const scrubbed = try lp.tools.reverseSubstituteEnvVars(self.arena.allocator(), self.buf.written()); - - try self.file.?.writeAll(scrubbed); - self.lines += @intCast(std.mem.count(u8, scrubbed, "\n")); -} - -/// Any failure along the record path — buffer-write OOM, scrub OOM, or file -/// write — flips the recorder to inactive so subsequent calls become silent -/// no-ops and `isActive()` reflects the stopped state. -fn disable(self: *Recorder, err: anyerror) void { - log.warn(.app, "recording disabled", .{ .err = @errorName(err) }); - if (self.file) |f| { - f.close(); - self.file = null; - } -} - -/// In-memory recorder used by the REPL `/save` command. It intentionally -/// shares the same command filter/formatter/scrubber as file recording, but -/// leaves persistence timing to the caller. -pub const Memory = struct { - allocator: std.mem.Allocator, - lines: u32, - content: std.Io.Writer.Allocating, - buf: std.Io.Writer.Allocating, - arena: std.heap.ArenaAllocator, - - pub fn init(allocator: std.mem.Allocator) Memory { - return .{ - .allocator = allocator, - .lines = 0, - .content = .init(allocator), - .buf = .init(allocator), - .arena = .init(allocator), - }; - } - - pub fn deinit(self: *Memory) void { - self.content.deinit(); - self.buf.deinit(); - self.arena.deinit(); - } - - pub fn bytes(self: *Memory) []const u8 { - return self.content.written(); - } - - pub fn reset(self: *Memory) void { - self.lines = 0; - self.content.clearRetainingCapacity(); - self.buf.clearRetainingCapacity(); - _ = self.arena.reset(.retain_capacity); - } - - pub fn record(self: *Memory, cmd: Command) !void { - if (!cmd.isRecorded()) return; - self.buf.clearRetainingCapacity(); - _ = self.arena.reset(.retain_capacity); - try cmd.formatJs(self.arena.allocator(), &self.buf.writer); - try self.buf.writer.writeByte('\n'); - try self.appendScrubbed(); - } - - pub fn recordComment(self: *Memory, comment: []const u8) !void { - self.buf.clearRetainingCapacity(); - try writeCommentLines(&self.buf.writer, comment); - try self.appendScrubbed(); - } - - pub fn recordRaw(self: *Memory, line: []const u8) !void { - self.buf.clearRetainingCapacity(); - try self.buf.writer.writeAll(line); - try self.buf.writer.writeByte('\n'); - try self.appendScrubbed(); - } - - fn appendScrubbed(self: *Memory) !void { - _ = self.arena.reset(.retain_capacity); - const scrubbed = try lp.tools.reverseSubstituteEnvVars(self.arena.allocator(), self.buf.written()); - try self.content.writer.writeAll(scrubbed); - self.lines += @intCast(std.mem.count(u8, scrubbed, "\n")); - } -}; - fn parseLine(arena: std.mem.Allocator, line: []const u8) Command { return Command.parse(arena, line) catch unreachable; } -test "record writes state-mutating commands" { - var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); - defer arena.deinit(); - const aa = arena.allocator(); - - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - - var recorder = try Recorder.init(std.testing.allocator, tmp.dir, "test.js"); - defer recorder.deinit(); - - recorder.record(parseLine(aa, "/goto https://example.com")); - recorder.record(parseLine(aa, "/click selector='Login'")); - recorder.record(parseLine(aa, "/tree")); - recorder.record(parseLine(aa, "/waitForSelector '.dashboard'")); - recorder.record(parseLine(aa, "/markdown")); - recorder.record(parseLine(aa, "/scroll y=200")); - recorder.record(parseLine(aa, "/hover selector='#menu'")); - recorder.record(parseLine(aa, "/selectOption selector='#country' value='France'")); - recorder.record(parseLine(aa, "/setChecked selector='#agree'")); - recorder.record(parseLine(aa, "/setChecked selector='#newsletter' checked=false")); - recorder.record(parseLine(aa, "/extract '{\"title\":\".title\"}'")); - recorder.recordComment("LOGIN"); - - const file = tmp.dir.openFile("test.js", .{}) catch unreachable; - defer file.close(); - var buf: [512]u8 = undefined; - const n = file.readAll(&buf) catch unreachable; - const content = buf[0..n]; - - try std.testing.expect(std.mem.indexOf(u8, content, "goto(\"https://example.com\");\n") != null); - try std.testing.expect(std.mem.indexOf(u8, content, "click({ selector: \"Login\" });\n") != null); - try std.testing.expect(std.mem.indexOf(u8, content, "waitForSelector(\".dashboard\");\n") != null); - try std.testing.expect(std.mem.indexOf(u8, content, "scroll({ y: 200 });\n") != null); - try std.testing.expect(std.mem.indexOf(u8, content, "hover({ selector: \"#menu\" });\n") != null); - try std.testing.expect(std.mem.indexOf(u8, content, "selectOption({ selector: \"#country\", value: \"France\" });\n") != null); - try std.testing.expect(std.mem.indexOf(u8, content, "setChecked({ selector: \"#agree\" });\n") != null); - try std.testing.expect(std.mem.indexOf(u8, content, "setChecked({ selector: \"#newsletter\", checked: false });\n") != null); - try std.testing.expect(std.mem.indexOf(u8, content, "extract({ title: \".title\" });\n") != null); - try std.testing.expect(std.mem.indexOf(u8, content, "\n// LOGIN\n") != null); - // Read-only tools (tree, markdown) are gated out by isRecorded(). - try std.testing.expect(std.mem.indexOf(u8, content, "tree(") == null); - try std.testing.expect(std.mem.indexOf(u8, content, "markdown(") == null); -} - -test "recordRaw writes the JS line verbatim" { - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - - var recorder = try Recorder.init(std.testing.allocator, tmp.dir, "raw.js"); - defer recorder.deinit(); - - recorder.recordRaw("document.title"); - recorder.recordRaw("window.scrollTo(0, 100)"); - - const file = tmp.dir.openFile("raw.js", .{}) catch unreachable; - defer file.close(); - var buf: [256]u8 = undefined; - const n = file.readAll(&buf) catch unreachable; - - try std.testing.expectEqualStrings("document.title\nwindow.scrollTo(0, 100)\n", buf[0..n]); -} - -test "record skips empty and comment lines" { - var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); - defer arena.deinit(); - const aa = arena.allocator(); - - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - - var recorder = try Recorder.init(std.testing.allocator, tmp.dir, "test2.js"); - defer recorder.deinit(); - - recorder.record(parseLine(aa, "")); - recorder.record(parseLine(aa, " ")); - recorder.record(parseLine(aa, "# this is a comment")); - recorder.record(parseLine(aa, "/goto https://example.com")); - - const file = tmp.dir.openFile("test2.js", .{}) catch unreachable; - defer file.close(); - var buf: [256]u8 = undefined; - const n = file.readAll(&buf) catch unreachable; - const content = buf[0..n]; - - try std.testing.expectEqualStrings("goto(\"https://example.com\");\n", content); -} - -test "lines counter tracks successful appends" { - var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); - defer arena.deinit(); - const aa = arena.allocator(); - - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - - var recorder = try Recorder.init(std.testing.allocator, tmp.dir, "count.js"); - defer recorder.deinit(); - - recorder.record(parseLine(aa, "/goto https://example.com")); // +1 - recorder.record(parseLine(aa, "/tree")); // skipped — not isRecorded() - recorder.record(parseLine(aa, "/click selector='Login'")); // +1 - recorder.recordComment("a note"); // +1 - - try std.testing.expectEqual(@as(u32, 3), recorder.lines); -} - -test "init appends to an existing file without truncating" { - var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); - defer arena.deinit(); - const aa = arena.allocator(); - - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - - // Seed a file with a prior line. - { - const seed = tmp.dir.createFile("script.js", .{}) catch unreachable; - defer seed.close(); - _ = seed.writeAll("goto(\"https://example.com\");\n") catch unreachable; - } - - var recorder = try Recorder.init(std.testing.allocator, tmp.dir, "script.js"); - defer recorder.deinit(); - recorder.record(parseLine(aa, "/click selector='Login'")); - - try std.testing.expect(recorder.isActive()); - try std.testing.expectEqualStrings("script.js", recorder.path); - - const file = tmp.dir.openFile("script.js", .{}) catch unreachable; - defer file.close(); - var buf: [256]u8 = undefined; - const n = file.readAll(&buf) catch unreachable; - const content = buf[0..n]; - - try std.testing.expect(std.mem.indexOf(u8, content, "goto(\"https://example.com\");\n") != null); - try std.testing.expect(std.mem.indexOf(u8, content, "click({ selector: \"Login\" });\n") != null); - // The prior line must precede the appended line. - const prior = std.mem.indexOf(u8, content, "goto").?; - const appended = std.mem.indexOf(u8, content, "click").?; - try std.testing.expect(prior < appended); -} - extern fn setenv(name: [*:0]u8, value: [*:0]u8, override: c_int) c_int; extern fn unsetenv(name: [*:0]u8) c_int; -test "recordComment scrubs literal LP_* values back to placeholders" { - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - - const var_name = "LP_RECORDER_COMMENT_TEST"; - const var_value = "topsecret"; - _ = setenv(@constCast(var_name), @constCast(var_value), 1); - defer _ = unsetenv(@constCast(var_name)); - - var recorder = try Recorder.init(std.testing.allocator, tmp.dir, "scrub.js"); - defer recorder.deinit(); - - recorder.recordComment("a user noted that their password is topsecret"); - - const file = tmp.dir.openFile("scrub.js", .{}) catch unreachable; - defer file.close(); - var buf: [256]u8 = undefined; - const n = file.readAll(&buf) catch unreachable; - try std.testing.expectEqualStrings( - "// a user noted that their password is $LP_RECORDER_COMMENT_TEST\n", - buf[0..n], - ); -} - -test "recordComment splits embedded newlines into separate comment lines" { - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - - var recorder = try Recorder.init(std.testing.allocator, tmp.dir, "multi.js"); - defer recorder.deinit(); - - // An attacker-controlled comment trying to smuggle a command must not - // produce an executable line on replay. - recorder.recordComment("note\n/goto https://attacker\r\nmore"); - - const file = tmp.dir.openFile("multi.js", .{}) catch unreachable; - defer file.close(); - var buf: [256]u8 = undefined; - const n = file.readAll(&buf) catch unreachable; - try std.testing.expectEqualStrings( - "// note\n// /goto https://attacker\n// more\n", - buf[0..n], - ); -} - -test "record disables recorder on write failure" { - const filter: testing.LogFilter = .init(&.{.app}); - defer filter.deinit(); - - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - - // Open the file read-only so writeAll fails with `error.NotOpenForWriting`. - // Struct literal (not `init`) because only this test needs to inject a - // read-only handle to exercise the failure path. - const file = blk: { - _ = tmp.dir.createFile("ro.js", .{}) catch unreachable; - break :blk tmp.dir.openFile("ro.js", .{ .mode = .read_only }) catch unreachable; - }; - - var recorder: Recorder = .{ - .allocator = std.testing.allocator, - .file = file, - .path = try std.testing.allocator.dupe(u8, "test.js"), - .lines = 0, - .buf = .init(std.testing.allocator), - .arena = .init(std.testing.allocator), - }; - defer recorder.deinit(); - - var test_arena: std.heap.ArenaAllocator = .init(std.testing.allocator); - defer test_arena.deinit(); - const aa = test_arena.allocator(); - - try std.testing.expect(recorder.isActive()); - recorder.record(parseLine(aa, "/goto https://example.com")); - try std.testing.expect(!recorder.isActive()); - try std.testing.expectEqual(@as(u32, 0), recorder.lines); - - // Subsequent calls are silent no-ops, not silent successes. - recorder.record(parseLine(aa, "/click selector='Login'")); - recorder.recordComment("note"); - try std.testing.expectEqual(@as(u32, 0), recorder.lines); -} - -test "init creates the file if missing" { +test "record filters state-mutating commands and comments" { var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena.deinit(); const aa = arena.allocator(); - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - - var recorder: Recorder = try .init(std.testing.allocator, tmp.dir, "fresh.js"); + var recorder: Recorder = .init(std.testing.allocator); defer recorder.deinit(); - recorder.record(parseLine(aa, "/goto https://example.com")); - const file = tmp.dir.openFile("fresh.js", .{}) catch unreachable; - defer file.close(); - var buf: [128]u8 = undefined; - const n = file.readAll(&buf) catch unreachable; - try std.testing.expectEqualStrings("goto(\"https://example.com\");\n", buf[0..n]); + try recorder.record(parseLine(aa, "/goto https://example.com")); + try recorder.record(parseLine(aa, "/tree")); + try recorder.record(parseLine(aa, "/click selector='Login'")); + try recorder.recordComment("search for login"); + + try std.testing.expectEqualStrings( + "goto(\"https://example.com\");\nclick({ selector: \"Login\" });\n// search for login\n", + recorder.bytes(), + ); + try std.testing.expectEqual(@as(u32, 3), recorder.lines); + + recorder.reset(); + try std.testing.expectEqualStrings("", recorder.bytes()); + try std.testing.expectEqual(@as(u32, 0), recorder.lines); + + try recorder.record(parseLine(aa, "/scroll y=200")); + try std.testing.expectEqualStrings("scroll({ y: 200 });\n", recorder.bytes()); + try std.testing.expectEqual(@as(u32, 1), recorder.lines); +} + +test "recordRaw writes the JS line verbatim" { + var recorder: Recorder = .init(std.testing.allocator); + defer recorder.deinit(); + + try recorder.recordRaw("document.title"); + try recorder.recordRaw("window.scrollTo(0, 100)"); + + try std.testing.expectEqualStrings("document.title\nwindow.scrollTo(0, 100)\n", recorder.bytes()); } test "record emits multi-line extract as JavaScript" { @@ -477,54 +159,50 @@ test "record emits multi-line extract as JavaScript" { defer arena.deinit(); const aa = arena.allocator(); - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - - var recorder = try Recorder.init(std.testing.allocator, tmp.dir, "triple.js"); + var recorder: Recorder = .init(std.testing.allocator); defer recorder.deinit(); const cmd_str = "/extract '{\n \"title\": \"span.title\",\n \"desc\": \"p.description\"\n}'"; - recorder.record(parseLine(aa, cmd_str)); + try recorder.record(parseLine(aa, cmd_str)); - const file = tmp.dir.openFile("triple.js", .{}) catch unreachable; - defer file.close(); - var buf: [512]u8 = undefined; - const n = file.readAll(&buf) catch unreachable; try std.testing.expectEqualStrings( "extract({ title: \"span.title\", desc: \"p.description\" });\n", - buf[0..n], + recorder.bytes(), ); } -test "memory recorder mirrors file recorder filtering" { - var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); - defer arena.deinit(); - const aa = arena.allocator(); +test "recordComment splits embedded newlines into separate comment lines" { + var recorder: Recorder = .init(std.testing.allocator); + defer recorder.deinit(); - var memory: Memory = .init(std.testing.allocator); - defer memory.deinit(); - - try memory.record(parseLine(aa, "/goto https://example.com")); - try memory.record(parseLine(aa, "/tree")); - try memory.record(parseLine(aa, "/click selector='Login'")); - try memory.recordComment("search for login"); + // An attacker-controlled comment trying to smuggle a command must not + // produce an executable line on replay. + try recorder.recordComment("note\n/goto https://attacker\r\nmore"); try std.testing.expectEqualStrings( - "goto(\"https://example.com\");\nclick({ selector: \"Login\" });\n// search for login\n", - memory.bytes(), + "// note\n// /goto https://attacker\n// more\n", + recorder.bytes(), ); - try std.testing.expectEqual(@as(u32, 3), memory.lines); - - memory.reset(); - try std.testing.expectEqualStrings("", memory.bytes()); - try std.testing.expectEqual(@as(u32, 0), memory.lines); - - try memory.record(parseLine(aa, "/scroll y=200")); - try std.testing.expectEqualStrings("scroll({ y: 200 });\n", memory.bytes()); - try std.testing.expectEqual(@as(u32, 1), memory.lines); } -test "memory recorder scrubs literal LP_* values in JavaScript calls" { +test "recordComment scrubs literal LP_* values back to placeholders" { + const var_name = "LP_RECORDER_COMMENT_TEST"; + const var_value = "topsecret"; + _ = setenv(@constCast(var_name), @constCast(var_value), 1); + defer _ = unsetenv(@constCast(var_name)); + + var recorder: Recorder = .init(std.testing.allocator); + defer recorder.deinit(); + + try recorder.recordComment("a user noted that their password is topsecret"); + + try std.testing.expectEqualStrings( + "// a user noted that their password is $LP_RECORDER_COMMENT_TEST\n", + recorder.bytes(), + ); +} + +test "record scrubs literal LP_* values in JavaScript calls" { var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena.deinit(); const aa = arena.allocator(); @@ -534,12 +212,12 @@ test "memory recorder scrubs literal LP_* values in JavaScript calls" { _ = setenv(@constCast(var_name), @constCast(var_value), 1); defer _ = unsetenv(@constCast(var_name)); - var memory: Memory = .init(std.testing.allocator); - defer memory.deinit(); + var recorder: Recorder = .init(std.testing.allocator); + defer recorder.deinit(); - try memory.record(parseLine(aa, "/fill selector='#user' value='secret-user'")); + try recorder.record(parseLine(aa, "/fill selector='#user' value='secret-user'")); try std.testing.expectEqualStrings( "fill({ selector: \"#user\", value: \"$LP_RECORDER_COMMAND_TEST\" });\n", - memory.bytes(), + recorder.bytes(), ); } diff --git a/src/agent/ScriptRuntime.zig b/src/script/Runtime.zig similarity index 90% rename from src/agent/ScriptRuntime.zig rename to src/script/Runtime.zig index 5611d658..cd65c0af 100644 --- a/src/agent/ScriptRuntime.zig +++ b/src/script/Runtime.zig @@ -25,7 +25,7 @@ const CDPNode = @import("../cdp/Node.zig"); const v8 = lp.js.v8; -const ScriptRuntime = @This(); +const Runtime = @This(); allocator: std.mem.Allocator, app: *lp.App, @@ -37,6 +37,10 @@ has_context: bool, call_arena: std.heap.ArenaAllocator, primitive_data: [recorded_tool_count]PrimitiveData, console_data: [std.enums.values(ConsoleMethod).len]ConsoleData, +/// Notified before each `console.*` line is written. The REPL uses it to +/// clear the live spinner so script output starts on a clean line instead +/// of colliding with the indicator; the line still goes to stdout/stderr. +console_observer: ?ConsoleObserver = null, /// The runtime installs exactly the recorded browser tools as script /// primitives — the same set the recorder writes — so every recorded call @@ -51,7 +55,7 @@ const recorded_tool_count = blk: { }; const PrimitiveData = struct { - runtime: *ScriptRuntime, + runtime: *Runtime, tool: BrowserTool, }; @@ -71,10 +75,15 @@ const ConsoleMethod = enum { }; const ConsoleData = struct { - runtime: *ScriptRuntime, + runtime: *Runtime, method: ConsoleMethod, }; +pub const ConsoleObserver = struct { + context: *anyopaque, + notify: *const fn (context: *anyopaque) void, +}; + pub const InitError = error{ OutOfMemory, RuntimeInitFailed, @@ -90,8 +99,8 @@ pub fn init( app: *lp.App, session: *lp.Session, registry: *CDPNode.Registry, -) InitError!*ScriptRuntime { - const self = try allocator.create(ScriptRuntime); +) InitError!*Runtime { + const self = try allocator.create(Runtime); errdefer allocator.destroy(self); self.* = .{ @@ -119,7 +128,7 @@ pub fn init( return self; } -pub fn deinit(self: *ScriptRuntime) void { +pub fn deinit(self: *Runtime) void { self.resetContext(); self.env.deinit(); self.call_arena.deinit(); @@ -127,15 +136,15 @@ pub fn deinit(self: *ScriptRuntime) void { allocator.destroy(self); } -pub fn terminate(self: *ScriptRuntime) void { +pub fn terminate(self: *Runtime) void { self.env.terminate(); } -pub fn cancelTerminate(self: *ScriptRuntime) void { +pub fn cancelTerminate(self: *Runtime) void { self.env.cancelTerminate(); } -fn createContext(self: *ScriptRuntime) InitError!void { +fn createContext(self: *Runtime) InitError!void { var hs: lp.js.HandleScope = undefined; hs.init(self.env.isolate); defer hs.deinit(); @@ -159,7 +168,7 @@ fn createContext(self: *ScriptRuntime) InitError!void { try self.installConsole(context, global); } -fn resetContext(self: *ScriptRuntime) void { +fn resetContext(self: *Runtime) void { if (!self.has_context) return; v8.v8__Global__Reset(&self.context); self.env.isolate.notifyContextDisposed(); @@ -167,7 +176,7 @@ fn resetContext(self: *ScriptRuntime) void { } fn installPrimitive( - self: *ScriptRuntime, + self: *Runtime, context: *const v8.Context, global: *const v8.Object, name: []const u8, @@ -188,7 +197,7 @@ fn installPrimitive( } fn installConsole( - self: *ScriptRuntime, + self: *Runtime, context: *const v8.Context, global: *const v8.Object, ) InitError!void { @@ -207,7 +216,7 @@ fn installConsole( } fn setObjectProperty( - self: *ScriptRuntime, + self: *Runtime, context: *const v8.Context, object: *const v8.Object, name: []const u8, @@ -227,7 +236,7 @@ fn setObjectProperty( /// Run script source in the agent context. Returns null on success; on a JS /// compile/runtime exception returns a formatted error allocated in this /// runtime's call arena and valid until deinit or the next run. -pub fn runSource(self: *ScriptRuntime, source: []const u8, name: []const u8) RunError!?[]const u8 { +pub fn runSource(self: *Runtime, source: []const u8, name: []const u8) RunError!?[]const u8 { _ = self.call_arena.reset(.retain_capacity); var hs: lp.js.HandleScope = undefined; @@ -286,7 +295,7 @@ fn consoleCallback(info_handle: ?*const v8.FunctionCallbackInfo) callconv(.c) vo data.runtime.invokeConsole(data.method, info); } -fn invoke(self: *ScriptRuntime, tool: BrowserTool, info: *const v8.FunctionCallbackInfo) void { +fn invoke(self: *Runtime, tool: BrowserTool, info: *const v8.FunctionCallbackInfo) void { // Owned, not shared: marshalling runs JS (`toJSON`) that can re-enter a // primitive; a shared arena would let the nested call reset ours mid-flight. var arena_state: std.heap.ArenaAllocator = .init(self.allocator); @@ -322,7 +331,7 @@ fn invoke(self: *ScriptRuntime, tool: BrowserTool, info: *const v8.FunctionCallb } } -fn invokeConsole(self: *ScriptRuntime, method: ConsoleMethod, info: *const v8.FunctionCallbackInfo) void { +fn invokeConsole(self: *Runtime, method: ConsoleMethod, info: *const v8.FunctionCallbackInfo) void { // Owned arena (see `invoke`): an argument's `toString` can re-enter a // primitive mid-loop and must not reset the buffer we're accumulating. var arena_state: std.heap.ArenaAllocator = .init(self.allocator); @@ -343,7 +352,8 @@ fn invokeConsole(self: *ScriptRuntime, method: ConsoleMethod, info: *const v8.Fu self.writeConsoleLine(method, aw.written()); } -fn writeConsoleLine(_: *ScriptRuntime, method: ConsoleMethod, line: []const u8) void { +fn writeConsoleLine(self: *Runtime, method: ConsoleMethod, line: []const u8) void { + if (self.console_observer) |obs| obs.notify(obs.context); var buf: [4096]u8 = undefined; var file = if (method.writesStderr()) std.fs.File.stderr() else std.fs.File.stdout(); var writer = file.writer(&buf); @@ -357,7 +367,7 @@ const PrimitiveResult = union(enum) { }; fn callTool( - self: *ScriptRuntime, + self: *Runtime, arena: std.mem.Allocator, tool: BrowserTool, args: ?std.json.Value, @@ -382,7 +392,7 @@ const BuildArgsError = error{ }; fn buildArgs( - self: *ScriptRuntime, + self: *Runtime, arena: std.mem.Allocator, context: *const v8.Context, tool: BrowserTool, @@ -409,7 +419,7 @@ fn buildArgs( } fn singleStringOrObject( - self: *ScriptRuntime, + self: *Runtime, arena: std.mem.Allocator, context: *const v8.Context, info: *const v8.FunctionCallbackInfo, @@ -426,7 +436,7 @@ fn singleStringOrObject( } fn singleObject( - self: *ScriptRuntime, + self: *Runtime, arena: std.mem.Allocator, context: *const v8.Context, info: *const v8.FunctionCallbackInfo, @@ -439,7 +449,7 @@ fn singleObject( } fn extractArgs( - self: *ScriptRuntime, + self: *Runtime, arena: std.mem.Allocator, context: *const v8.Context, info: *const v8.FunctionCallbackInfo, @@ -476,7 +486,7 @@ fn normalizeExtractSchemaString(arena: std.mem.Allocator, schema: []const u8) er } fn argJson( - self: *ScriptRuntime, + self: *Runtime, arena: std.mem.Allocator, context: *const v8.Context, info: *const v8.FunctionCallbackInfo, @@ -487,7 +497,7 @@ fn argJson( } fn valueToJson( - self: *ScriptRuntime, + self: *Runtime, arena: std.mem.Allocator, context: *const v8.Context, value: *const v8.Value, @@ -504,7 +514,7 @@ fn objectWith(arena: std.mem.Allocator, key: []const u8, value: std.json.Value) return .{ .object = obj }; } -fn normalizeExtractReturnJson(_: *ScriptRuntime, arena: std.mem.Allocator, value: []const u8) error{OutOfMemory}![]const u8 { +fn normalizeExtractReturnJson(_: *Runtime, arena: std.mem.Allocator, value: []const u8) error{OutOfMemory}![]const u8 { if (value.len == 0) return value; const parsed = std.json.parseFromSliceLeaky(std.json.Value, arena, value, .{}) catch |err| switch (err) { @@ -519,11 +529,11 @@ fn normalizeExtractReturnJson(_: *ScriptRuntime, arena: std.mem.Allocator, value return try std.json.Stringify.valueAlloc(arena, entry.value_ptr.*, .{}); } -fn setReturnString(self: *ScriptRuntime, info: *const v8.FunctionCallbackInfo, value: []const u8) void { +fn setReturnString(self: *Runtime, info: *const v8.FunctionCallbackInfo, value: []const u8) void { self.setReturnValue(info, @ptrCast(self.env.isolate.initStringHandle(value))); } -fn setReturnJson(self: *ScriptRuntime, context: *const v8.Context, info: *const v8.FunctionCallbackInfo, value: []const u8) void { +fn setReturnJson(self: *Runtime, context: *const v8.Context, info: *const v8.FunctionCallbackInfo, value: []const u8) void { if (value.len == 0) { self.setReturnValue(info, self.env.isolate.initUndefined()); return; @@ -536,22 +546,22 @@ fn setReturnJson(self: *ScriptRuntime, context: *const v8.Context, info: *const self.setReturnValue(info, parsed); } -fn setReturnValue(_: *ScriptRuntime, info: *const v8.FunctionCallbackInfo, value: *const v8.Value) void { +fn setReturnValue(_: *Runtime, info: *const v8.FunctionCallbackInfo, value: *const v8.Value) void { var rv: v8.ReturnValue = undefined; v8.v8__FunctionCallbackInfo__GetReturnValue(info, &rv); v8.v8__ReturnValue__Set(rv, value); } -fn throwError(self: *ScriptRuntime, message: []const u8) void { +fn throwError(self: *Runtime, message: []const u8) void { _ = v8.v8__Isolate__ThrowException(self.env.isolate.handle, self.env.isolate.createError(message)); } -fn throwTypeError(self: *ScriptRuntime, message: []const u8) void { +fn throwTypeError(self: *Runtime, message: []const u8) void { _ = v8.v8__Isolate__ThrowException(self.env.isolate.handle, self.env.isolate.createTypeError(message)); } fn formatCaught( - self: *ScriptRuntime, + self: *Runtime, context: *const v8.Context, try_catch: *const v8.TryCatch, fallback: []const u8, @@ -579,7 +589,7 @@ fn formatCaught( } fn valueToString( - self: *ScriptRuntime, + self: *Runtime, arena: std.mem.Allocator, context: *const v8.Context, value: *const v8.Value, @@ -589,7 +599,7 @@ fn valueToString( } fn stringToOwned( - self: *ScriptRuntime, + self: *Runtime, arena: std.mem.Allocator, string: *const v8.String, ) error{OutOfMemory}![]const u8 { @@ -605,20 +615,20 @@ fn stringToOwned( return buf[0..written]; } -fn dupeError(self: *ScriptRuntime, message: []const u8) RunError![]const u8 { +fn dupeError(self: *Runtime, message: []const u8) RunError![]const u8 { return self.call_arena.allocator().dupe(u8, message) catch error.OutOfMemory; } const testing = @import("../testing.zig"); -fn runTestScript(runtime: *ScriptRuntime, source: []const u8) !void { +fn runTestScript(runtime: *Runtime, source: []const u8) !void { if (try runtime.runSource(source, "agent-runtime-test.js")) |message| { std.debug.print("agent script failed:\n{s}\n", .{message}); return error.AgentScriptFailed; } } -fn terminateRuntimeSoon(runtime: *ScriptRuntime) void { +fn terminateRuntimeSoon(runtime: *Runtime) void { std.Thread.sleep(10 * std.time.ns_per_ms); runtime.terminate(); } @@ -630,7 +640,7 @@ test "agent script runtime: goto and eval dispatch through browser tools" { var registry = CDPNode.Registry.init(testing.allocator); defer registry.deinit(); - const runtime = try ScriptRuntime.init(testing.allocator, testing.test_app, testing.test_session, ®istry); + const runtime = try Runtime.init(testing.allocator, testing.test_app, testing.test_session, ®istry); defer runtime.deinit(); try runTestScript(runtime, @@ -651,7 +661,7 @@ test "agent script runtime: extract returns a JavaScript object" { var registry = CDPNode.Registry.init(testing.allocator); defer registry.deinit(); - const runtime = try ScriptRuntime.init(testing.allocator, testing.test_app, testing.test_session, ®istry); + const runtime = try Runtime.init(testing.allocator, testing.test_app, testing.test_session, ®istry); defer runtime.deinit(); try runTestScript(runtime, @@ -705,7 +715,7 @@ test "agent script runtime: strict-mode scripts can call primitives" { var registry = CDPNode.Registry.init(testing.allocator); defer registry.deinit(); - const runtime = try ScriptRuntime.init(testing.allocator, testing.test_app, testing.test_session, ®istry); + const runtime = try Runtime.init(testing.allocator, testing.test_app, testing.test_session, ®istry); defer runtime.deinit(); try runTestScript(runtime, @@ -723,7 +733,7 @@ test "agent script runtime: promise microtasks run to completion" { var registry = CDPNode.Registry.init(testing.allocator); defer registry.deinit(); - const runtime = try ScriptRuntime.init(testing.allocator, testing.test_app, testing.test_session, ®istry); + const runtime = try Runtime.init(testing.allocator, testing.test_app, testing.test_session, ®istry); defer runtime.deinit(); try runTestScript(runtime, @@ -744,7 +754,7 @@ test "agent script runtime: primitives re-entered from argument callbacks stay i var registry = CDPNode.Registry.init(testing.allocator); defer registry.deinit(); - const runtime = try ScriptRuntime.init(testing.allocator, testing.test_app, testing.test_session, ®istry); + const runtime = try Runtime.init(testing.allocator, testing.test_app, testing.test_session, ®istry); defer runtime.deinit(); try runTestScript(runtime, @@ -766,7 +776,7 @@ test "agent script runtime: terminate interrupts local JavaScript" { var registry = CDPNode.Registry.init(testing.allocator); defer registry.deinit(); - const runtime = try ScriptRuntime.init(testing.allocator, testing.test_app, testing.test_session, ®istry); + const runtime = try Runtime.init(testing.allocator, testing.test_app, testing.test_session, ®istry); defer runtime.deinit(); const thread = try std.Thread.spawn(.{}, terminateRuntimeSoon, .{runtime}); @@ -784,7 +794,7 @@ test "agent script runtime: agent variables persist and page globals are isolate var registry = CDPNode.Registry.init(testing.allocator); defer registry.deinit(); - const runtime = try ScriptRuntime.init(testing.allocator, testing.test_app, testing.test_session, ®istry); + const runtime = try Runtime.init(testing.allocator, testing.test_app, testing.test_session, ®istry); defer runtime.deinit(); try runTestScript(runtime, @@ -809,7 +819,7 @@ test "agent script runtime: page eval cannot see agent primitives or bindings" { var registry = CDPNode.Registry.init(testing.allocator); defer registry.deinit(); - const runtime = try ScriptRuntime.init(testing.allocator, testing.test_app, testing.test_session, ®istry); + const runtime = try Runtime.init(testing.allocator, testing.test_app, testing.test_session, ®istry); defer runtime.deinit(); try runTestScript(runtime, @@ -827,7 +837,7 @@ test "agent script runtime: console is available in agent context" { var registry = CDPNode.Registry.init(testing.allocator); defer registry.deinit(); - const runtime = try ScriptRuntime.init(testing.allocator, testing.test_app, testing.test_session, ®istry); + const runtime = try Runtime.init(testing.allocator, testing.test_app, testing.test_session, ®istry); defer runtime.deinit(); try runTestScript(runtime, @@ -844,7 +854,7 @@ test "agent script runtime: tool errors throw and stop execution" { var registry = CDPNode.Registry.init(testing.allocator); defer registry.deinit(); - const runtime = try ScriptRuntime.init(testing.allocator, testing.test_app, testing.test_session, ®istry); + const runtime = try Runtime.init(testing.allocator, testing.test_app, testing.test_session, ®istry); defer runtime.deinit(); const message = (try runtime.runSource(