From 7aabda939218a3ad32526c8e44f64fb4c8a45d67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Sat, 4 Apr 2026 08:14:43 +0200 Subject: [PATCH] agent: add recorder, self-healing, env substitution, and security fixes - Add Recorder for recording REPL sessions to .panda files, with --no-record flag and positional file arg support. Skips read-only commands (WAIT, TREE, MARKDOWN) per spec. - Record resolved LLM tool calls as Pandascript commands so the generated artifact is deterministic. - Add self-healing in --run mode: on command failure, prompt the LLM with the # INTENT context to resolve an alternative. - Add LOGIN and ACCEPT_COOKIES high-level commands (LLM-resolved). - Add multi-line EVAL """...""" support via ScriptIterator. - Add $VAR_NAME environment variable substitution in command arguments. - Escape JS strings in execType/execExtract to prevent injection. - Sanitize output file paths in EXTRACT to prevent path traversal. --- src/Config.zig | 18 +++ src/agent.zig | 1 + src/agent/Agent.zig | 217 +++++++++++++++++++++++++++++++--- src/agent/Command.zig | 94 +++++++++++++++ src/agent/CommandExecutor.zig | 125 ++++++++++++++++++-- src/agent/Recorder.zig | 70 +++++++++++ 6 files changed, 496 insertions(+), 29 deletions(-) create mode 100644 src/agent/Recorder.zig diff --git a/src/Config.zig b/src/Config.zig index bd077060..ec3a8f81 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -254,6 +254,8 @@ pub const Agent = struct { system_prompt: ?[:0]const u8 = null, repl: bool = true, script_file: ?[]const u8 = null, + record_file: ?[]const u8 = null, + no_record: bool = false, }; pub const DumpFormat = enum { @@ -982,14 +984,30 @@ fn parseAgentArgs( continue; } + if (std.mem.eql(u8, "--no-record", opt) or std.mem.eql(u8, "--no_record", opt)) { + result.no_record = true; + continue; + } + if (try parseCommonArg(allocator, opt, args, &result.common)) { continue; } + // Positional argument: recording file for REPL mode (e.g. `agent --repl my_workflow.panda`) + if (!std.mem.startsWith(u8, opt, "-")) { + result.record_file = opt; + continue; + } + log.fatal(.app, "unknown argument", .{ .mode = "agent", .arg = opt }); return error.UnkownOption; } + // If --no-record is set, clear the record file + if (result.no_record) { + result.record_file = null; + } + return result; } diff --git a/src/agent.zig b/src/agent.zig index fa38b7f0..32b2d9f6 100644 --- a/src/agent.zig +++ b/src/agent.zig @@ -3,3 +3,4 @@ pub const ToolExecutor = @import("agent/ToolExecutor.zig"); pub const Terminal = @import("agent/Terminal.zig"); pub const Command = @import("agent/Command.zig"); pub const CommandExecutor = @import("agent/CommandExecutor.zig"); +pub const Recorder = @import("agent/Recorder.zig"); diff --git a/src/agent/Agent.zig b/src/agent/Agent.zig index 456b729a..84236dbd 100644 --- a/src/agent/Agent.zig +++ b/src/agent/Agent.zig @@ -9,6 +9,7 @@ const ToolExecutor = @import("ToolExecutor.zig"); const Terminal = @import("Terminal.zig"); const Command = @import("Command.zig"); const CommandExecutor = @import("CommandExecutor.zig"); +const Recorder = @import("Recorder.zig"); const Self = @This(); @@ -22,17 +23,48 @@ const default_system_prompt = \\before clicking or filling forms. Be concise in your responses. ; +const self_heal_prompt_prefix = + \\A Pandascript command failed during replay. The original intent was: + \\ +; + +const self_heal_prompt_suffix = + \\ + \\The command that failed was: + \\ +; + +const self_heal_prompt_page_state = + \\ + \\Please analyze the current page state and execute the equivalent action. + \\Use the available tools to accomplish the original intent. +; + +const login_prompt = + \\Find the login form on the current page. Fill in the credentials using + \\environment variables (look for $LP_EMAIL or $LP_USERNAME for the username + \\field, and $LP_PASSWORD for the password field). Handle any cookie banners + \\or popups first, then submit the login form. +; + +const accept_cookies_prompt = + \\Find and dismiss the cookie consent banner on the current page. + \\Look for "Accept", "Accept All", "I agree", or similar buttons and click them. +; + allocator: std.mem.Allocator, ai_client: ?AiClient, tool_executor: *ToolExecutor, terminal: Terminal, cmd_executor: CommandExecutor, +recorder: Recorder, messages: std.ArrayListUnmanaged(zenai.provider.Message), message_arena: std.heap.ArenaAllocator, tools: []const zenai.provider.Tool, model: []const u8, system_prompt: []const u8, script_file: ?[]const u8, +record_file: ?[]const u8, const AiClient = union(Config.AiProvider) { anthropic: *zenai.anthropic.Client, @@ -51,7 +83,7 @@ const AiClient = union(Config.AiProvider) { pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Self { const is_script_mode = opts.script_file != null; - // API key is only required for REPL mode (LLM interaction) + // API key is only required for REPL mode and self-healing const api_key: ?[:0]const u8 = opts.api_key orelse getEnvApiKey(opts.provider) orelse if (!is_script_mode) { log.fatal(.app, "missing API key", .{ .hint = "Set the API key via --api-key or environment variable", @@ -94,12 +126,14 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Self .tool_executor = tool_executor, .terminal = Terminal.init(null), .cmd_executor = undefined, + .recorder = Recorder.init(opts.record_file), .messages = .empty, .message_arena = std.heap.ArenaAllocator.init(allocator), .tools = tools, .model = opts.model orelse defaultModel(opts.provider), .system_prompt = opts.system_prompt orelse default_system_prompt, .script_file = opts.script_file, + .record_file = opts.record_file, }; self.cmd_executor = CommandExecutor.init(allocator, tool_executor, &self.terminal); @@ -108,6 +142,7 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Self } pub fn deinit(self: *Self) void { + self.recorder.deinit(); self.message_arena.deinit(); self.messages.deinit(self.allocator); self.tool_executor.deinit(); @@ -153,6 +188,20 @@ fn runRepl(self: *Self) void { switch (cmd) { .exit => break, .comment => continue, + .login => { + self.recorder.recordComment("# INTENT: LOGIN"); + self.processUserMessage(login_prompt) catch |err| { + const msg = std.fmt.allocPrint(self.allocator, "LOGIN failed: {s}", .{@errorName(err)}) catch "LOGIN failed"; + self.terminal.printError(msg); + }; + }, + .accept_cookies => { + self.recorder.recordComment("# INTENT: ACCEPT_COOKIES"); + self.processUserMessage(accept_cookies_prompt) catch |err| { + const msg = std.fmt.allocPrint(self.allocator, "ACCEPT_COOKIES failed: {s}", .{@errorName(err)}) catch "ACCEPT_COOKIES failed"; + self.terminal.printError(msg); + }; + }, .natural_language => { // "quit" as a convenience alias if (std.mem.eql(u8, line, "quit")) break; @@ -162,7 +211,10 @@ fn runRepl(self: *Self) void { self.terminal.printError(msg); }; }, - else => self.cmd_executor.execute(cmd), + else => { + self.cmd_executor.execute(cmd); + self.recorder.record(line); + }, } } @@ -184,35 +236,83 @@ fn runScript(self: *Self, path: []const u8) void { }; defer self.allocator.free(content); - const info = std.fmt.allocPrint(self.allocator, "Running script: {s}", .{path}) catch null; - self.terminal.printInfo(info orelse "Running script..."); - if (info) |i| self.allocator.free(i); + const script_info = std.fmt.allocPrint(self.allocator, "Running script: {s}", .{path}) catch null; + self.terminal.printInfo(script_info orelse "Running script..."); + if (script_info) |i| self.allocator.free(i); - var line_num: u32 = 0; - var lines = std.mem.splitScalar(u8, content, '\n'); - while (lines.next()) |line| { - line_num += 1; - const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace); - if (trimmed.len == 0) continue; + var script_arena = std.heap.ArenaAllocator.init(self.allocator); + defer script_arena.deinit(); - const cmd = Command.parse(trimmed); - switch (cmd) { + var iter = Command.ScriptIterator.init(content, script_arena.allocator()); + var last_intent: ?[]const u8 = null; + + while (iter.next()) |entry| { + switch (entry.command) { .exit => { self.terminal.printInfo("EXIT — stopping script."); return; }, - .comment => continue, + .comment => { + // Track # INTENT: comments for self-healing + if (std.mem.startsWith(u8, entry.raw_line, "# INTENT:")) { + last_intent = std.mem.trim(u8, entry.raw_line["# INTENT:".len..], &std.ascii.whitespace); + } + continue; + }, .natural_language => { - const msg = std.fmt.allocPrint(self.allocator, "line {d}: unrecognized command: {s}", .{ line_num, trimmed }) catch "unrecognized command"; + const msg = std.fmt.allocPrint(self.allocator, "line {d}: unrecognized command: {s}", .{ entry.line_num, entry.raw_line }) catch "unrecognized command"; self.terminal.printError(msg); return; }, + .login, .accept_cookies => { + // High-level commands require LLM + if (self.ai_client == null) { + const msg = std.fmt.allocPrint(self.allocator, "line {d}: {s} requires an API key for LLM resolution", .{ + entry.line_num, + entry.raw_line, + }) catch "LLM required"; + self.terminal.printError(msg); + return; + } + const prompt = if (entry.command == .login) login_prompt else accept_cookies_prompt; + self.processUserMessage(prompt) catch |err| { + const msg = std.fmt.allocPrint(self.allocator, "line {d}: {s} failed: {s}", .{ + entry.line_num, + entry.raw_line, + @errorName(err), + }) catch "command failed"; + self.terminal.printError(msg); + return; + }; + }, else => { - const line_info = std.fmt.allocPrint(self.allocator, "[{d}] {s}", .{ line_num, trimmed }) catch null; - self.terminal.printInfo(line_info orelse trimmed); + const line_info = std.fmt.allocPrint(self.allocator, "[{d}] {s}", .{ entry.line_num, entry.raw_line }) catch null; + self.terminal.printInfo(line_info orelse entry.raw_line); if (line_info) |li| self.allocator.free(li); - self.cmd_executor.execute(cmd); + // Execute with result checking for self-healing + var cmd_arena = std.heap.ArenaAllocator.init(self.allocator); + defer cmd_arena.deinit(); + + const result = self.cmd_executor.executeWithResult(cmd_arena.allocator(), entry.command); + self.terminal.printAssistant(result.output); + std.debug.print("\n", .{}); + + if (result.failed) { + // Attempt self-healing via LLM + if (self.ai_client != null) { + self.terminal.printInfo("Command failed, attempting self-healing..."); + if (self.attemptSelfHeal(last_intent, entry.raw_line)) { + continue; + } + } + const msg = std.fmt.allocPrint(self.allocator, "line {d}: command failed: {s}", .{ + entry.line_num, + entry.raw_line, + }) catch "command failed"; + self.terminal.printError(msg); + return; + } }, } } @@ -220,6 +320,25 @@ fn runScript(self: *Self, path: []const u8) void { self.terminal.printInfo("Script completed."); } +/// Attempt to self-heal a failed command by asking the LLM to resolve it. +fn attemptSelfHeal(self: *Self, intent: ?[]const u8, failed_command: []const u8) bool { + var heal_arena = std.heap.ArenaAllocator.init(self.allocator); + defer heal_arena.deinit(); + const ha = heal_arena.allocator(); + + // Build the self-healing prompt + const prompt = std.fmt.allocPrint(ha, "{s}{s}{s}{s}{s}", .{ + self_heal_prompt_prefix, + intent orelse "(no recorded intent)", + self_heal_prompt_suffix, + failed_command, + self_heal_prompt_page_state, + }) catch return false; + + self.processUserMessage(prompt) catch return false; + return true; +} + fn processUserMessage(self: *Self, user_input: []const u8) !void { const ma = self.message_arena.allocator(); @@ -278,6 +397,11 @@ fn processUserMessage(self: *Self, user_input: []const u8) !void { const tool_result = self.tool_executor.call(tool_arena.allocator(), tc.name, tc.arguments) catch "Error: tool execution failed"; self.terminal.printToolResult(tc.name, tool_result); + // Record resolved tool call as Pandascript command + if (!std.mem.startsWith(u8, tool_result, "Error:")) { + self.recordToolCall(tool_arena.allocator(), tc.name, tc.arguments); + } + try tool_results.append(ma, .{ .id = try ma.dupe(u8, tc.id), .name = try ma.dupe(u8, tc.name), @@ -312,6 +436,63 @@ fn processUserMessage(self: *Self, user_input: []const u8) !void { } } +/// Convert a tool call (name + JSON arguments) into a Pandascript command and record it. +fn recordToolCall(self: *Self, arena: std.mem.Allocator, tool_name: []const u8, arguments: []const u8) void { + const parsed = std.json.parseFromSlice(std.json.Value, arena, arguments, .{}) catch return; + const obj = switch (parsed.value) { + .object => |o| o, + else => return, + }; + + const panda_cmd: ?[]const u8 = if (std.mem.eql(u8, tool_name, "goto") or std.mem.eql(u8, tool_name, "navigate")) blk: { + const url = switch (obj.get("url") orelse break :blk null) { + .string => |s| s, + else => break :blk null, + }; + break :blk std.fmt.allocPrint(arena, "GOTO {s}", .{url}) catch null; + } else if (std.mem.eql(u8, tool_name, "click")) blk: { + if (obj.get("selector")) |sel_val| { + const sel = switch (sel_val) { + .string => |s| s, + else => break :blk null, + }; + break :blk std.fmt.allocPrint(arena, "CLICK \"{s}\"", .{sel}) catch null; + } + if (obj.get("backendNodeId")) |_| { + // Can't meaningfully record a node ID as Pandascript + break :blk null; + } + break :blk null; + } else if (std.mem.eql(u8, tool_name, "fill")) blk: { + const sel = switch (obj.get("selector") orelse break :blk null) { + .string => |s| s, + else => break :blk null, + }; + const val = switch (obj.get("value") orelse break :blk null) { + .string => |s| s, + else => break :blk null, + }; + break :blk std.fmt.allocPrint(arena, "TYPE \"{s}\" \"{s}\"", .{ sel, val }) catch null; + } else if (std.mem.eql(u8, tool_name, "waitForSelector")) blk: { + // WAIT is read-only, not recorded — Recorder will skip it anyway + break :blk null; + } else if (std.mem.eql(u8, tool_name, "evaluate") or std.mem.eql(u8, tool_name, "eval")) blk: { + const script = switch (obj.get("script") orelse break :blk null) { + .string => |s| s, + else => break :blk null, + }; + // Use multi-line format if the script contains newlines + if (std.mem.indexOfScalar(u8, script, '\n') != null) { + break :blk std.fmt.allocPrint(arena, "EVAL \"\"\"\n{s}\n\"\"\"", .{script}) catch null; + } + break :blk std.fmt.allocPrint(arena, "EVAL \"{s}\"", .{script}) catch null; + } else null; + + if (panda_cmd) |cmd| { + self.recorder.record(cmd); + } +} + fn dupeToolCalls(self: *Self, calls: []const zenai.provider.ToolCall) ![]const zenai.provider.ToolCall { const ma = self.message_arena.allocator(); const duped = try ma.alloc(zenai.provider.ToolCall, calls.len); diff --git a/src/agent/Command.zig b/src/agent/Command.zig index ed2a5fe0..3554c74f 100644 --- a/src/agent/Command.zig +++ b/src/agent/Command.zig @@ -19,6 +19,8 @@ pub const Command = union(enum) { markdown: void, extract: ExtractArgs, eval_js: []const u8, + login: void, + accept_cookies: void, exit: void, comment: void, natural_language: []const u8, @@ -26,6 +28,7 @@ pub const Command = union(enum) { /// Parse a line of REPL input into a Pandascript command. /// Unrecognized input is returned as `.natural_language`. +/// For multi-line EVAL blocks in scripts, use `ScriptParser`. pub fn parse(line: []const u8) Command { const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace); if (trimmed.len == 0) return .{ .natural_language = trimmed }; @@ -91,6 +94,14 @@ pub fn parse(line: []const u8) Command { return .{ .eval_js = arg }; } + if (eqlIgnoreCase(cmd_word, "LOGIN")) { + return .{ .login = {} }; + } + + if (eqlIgnoreCase(cmd_word, "ACCEPT_COOKIES") or eqlIgnoreCase(cmd_word, "ACCEPT-COOKIES")) { + return .{ .accept_cookies = {} }; + } + if (eqlIgnoreCase(cmd_word, "EXIT")) { return .{ .exit = {} }; } @@ -98,6 +109,89 @@ pub fn parse(line: []const u8) Command { return .{ .natural_language = trimmed }; } +/// Iterator for parsing a script file, handling multi-line EVAL """ ... """ blocks. +pub const ScriptIterator = struct { + lines: std.mem.SplitIterator(u8, .scalar), + line_num: u32, + allocator: std.mem.Allocator, + + pub fn init(content: []const u8, allocator: std.mem.Allocator) ScriptIterator { + return .{ + .lines = std.mem.splitScalar(u8, content, '\n'), + .line_num = 0, + .allocator = allocator, + }; + } + + pub const Entry = struct { + line_num: u32, + raw_line: []const u8, + command: Command, + }; + + /// Returns the next command from the script, or null at EOF. + /// Multi-line EVAL blocks are assembled into a single eval_js command. + pub fn next(self: *ScriptIterator) ?Entry { + while (self.lines.next()) |line| { + self.line_num += 1; + const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace); + if (trimmed.len == 0) continue; + + // Check for EVAL """ multi-line block + if (isEvalTripleQuote(trimmed)) { + const start_line = self.line_num; + if (self.collectEvalBlock()) |js| { + return .{ + .line_num = start_line, + .raw_line = trimmed, + .command = .{ .eval_js = js }, + }; + } else { + return .{ + .line_num = start_line, + .raw_line = trimmed, + .command = .{ .natural_language = "unterminated EVAL block" }, + }; + } + } + + return .{ + .line_num = self.line_num, + .raw_line = trimmed, + .command = parse(trimmed), + }; + } + return null; + } + + fn isEvalTripleQuote(line: []const u8) bool { + const cmd_end = std.mem.indexOfAny(u8, line, &std.ascii.whitespace) orelse line.len; + const cmd_word = line[0..cmd_end]; + if (!eqlIgnoreCase(cmd_word, "EVAL")) return false; + const rest = std.mem.trim(u8, line[cmd_end..], &std.ascii.whitespace); + return std.mem.startsWith(u8, rest, "\"\"\""); + } + + /// Collect lines until closing """, return the JS content. + fn collectEvalBlock(self: *ScriptIterator) ?[]const u8 { + var parts: std.ArrayListUnmanaged(u8) = .empty; + while (self.lines.next()) |line| { + self.line_num += 1; + const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace); + if (std.mem.eql(u8, trimmed, "\"\"\"")) { + return parts.toOwnedSlice(self.allocator) catch null; + } + if (parts.items.len > 0) { + parts.append(self.allocator, '\n') catch return null; + } + parts.appendSlice(self.allocator, line) catch return null; + } + // Unterminated + parts.deinit(self.allocator); + return null; + } +}; + const QuotedResult = struct { value: []const u8, remainder: []const u8, diff --git a/src/agent/CommandExecutor.zig b/src/agent/CommandExecutor.zig index 4135216a..7f4b7367 100644 --- a/src/agent/CommandExecutor.zig +++ b/src/agent/CommandExecutor.zig @@ -17,13 +17,15 @@ pub fn init(allocator: std.mem.Allocator, tool_executor: *ToolExecutor, terminal }; } -pub fn execute(self: *Self, cmd: Command.Command) void { - var arena = std.heap.ArenaAllocator.init(self.allocator); - defer arena.deinit(); - const a = arena.allocator(); +pub const ExecResult = struct { + output: []const u8, + failed: bool, +}; +/// Execute a command and return the result with success/failure status. +pub fn executeWithResult(self: *Self, a: std.mem.Allocator, cmd: Command.Command) ExecResult { const result = switch (cmd) { - .goto => |url| self.tool_executor.call(a, "goto", buildJson(a, .{ .url = url })) catch "Error: goto failed", + .goto => |url| self.execGoto(a, url), .click => |target| self.execClick(a, target), .type_cmd => |args| self.execType(a, args), .wait => |selector| self.tool_executor.call(a, "waitForSelector", buildJson(a, .{ .selector = selector })) catch "Error: wait failed", @@ -31,14 +33,32 @@ pub fn execute(self: *Self, cmd: Command.Command) void { .markdown => self.tool_executor.call(a, "markdown", "") catch "Error: markdown failed", .extract => |args| self.execExtract(a, args), .eval_js => |script| self.tool_executor.call(a, "evaluate", buildJson(a, .{ .script = script })) catch "Error: eval failed", - .exit, .natural_language, .comment => unreachable, + .exit, .natural_language, .comment, .login, .accept_cookies => unreachable, }; - self.terminal.printAssistant(result); + return .{ + .output = result, + .failed = std.mem.startsWith(u8, result, "Error:"), + }; +} + +pub fn execute(self: *Self, cmd: Command.Command) void { + var arena = std.heap.ArenaAllocator.init(self.allocator); + defer arena.deinit(); + + const result = self.executeWithResult(arena.allocator(), cmd); + + self.terminal.printAssistant(result.output); std.debug.print("\n", .{}); } -fn execClick(self: *Self, arena: std.mem.Allocator, target: []const u8) []const u8 { +fn execGoto(self: *Self, arena: std.mem.Allocator, raw_url: []const u8) []const u8 { + const url = substituteEnvVars(arena, raw_url); + return self.tool_executor.call(arena, "goto", buildJson(arena, .{ .url = url })) catch "Error: goto failed"; +} + +fn execClick(self: *Self, arena: std.mem.Allocator, raw_target: []const u8) []const u8 { + const target = substituteEnvVars(arena, raw_target); // Try as CSS selector via interactiveElements + click // First get interactive elements to find the target const elements_result = self.tool_executor.call(arena, "interactiveElements", "") catch @@ -55,6 +75,9 @@ fn execClick(self: *Self, arena: std.mem.Allocator, target: []const u8) []const } fn execType(self: *Self, arena: std.mem.Allocator, args: Command.TypeArgs) []const u8 { + const selector = escapeJs(arena, substituteEnvVars(arena, args.selector)); + const value = escapeJs(arena, substituteEnvVars(arena, args.value)); + // Use JavaScript to set the value on the element matching the selector const script = std.fmt.allocPrint(arena, \\(function() {{ @@ -64,20 +87,26 @@ fn execType(self: *Self, arena: std.mem.Allocator, args: Command.TypeArgs) []con \\ el.dispatchEvent(new Event("input", {{bubbles: true}})); \\ return "Typed into " + el.tagName; \\}})() - , .{ args.selector, args.value }) catch return "Error: failed to build type script"; + , .{ selector, value }) catch return "Error: failed to build type script"; return self.tool_executor.call(arena, "evaluate", buildJson(arena, .{ .script = script })) catch "Error: type failed"; } fn execExtract(self: *Self, arena: std.mem.Allocator, args: Command.ExtractArgs) []const u8 { + const selector = escapeJs(arena, substituteEnvVars(arena, args.selector)); + const script = std.fmt.allocPrint(arena, \\JSON.stringify(Array.from(document.querySelectorAll("{s}")).map(el => el.textContent.trim())) - , .{args.selector}) catch return "Error: failed to build extract script"; + , .{selector}) catch return "Error: failed to build extract script"; const result = self.tool_executor.call(arena, "evaluate", buildJson(arena, .{ .script = script })) catch return "Error: extract failed"; - if (args.file) |file| { + if (args.file) |raw_file| { + const file = sanitizePath(raw_file) orelse { + self.terminal.printError("Invalid output path: must be relative and not traverse above working directory"); + return result; + }; std.fs.cwd().writeFile(.{ .sub_path = file, .data = result, @@ -92,6 +121,80 @@ fn execExtract(self: *Self, arena: std.mem.Allocator, args: Command.ExtractArgs) return result; } +/// Substitute $VAR_NAME references with values from the environment. +fn substituteEnvVars(arena: std.mem.Allocator, input: []const u8) []const u8 { + // Quick scan: if no $ present, return as-is + if (std.mem.indexOfScalar(u8, input, '$') == null) return input; + + var result: std.ArrayListUnmanaged(u8) = .empty; + var i: usize = 0; + while (i < input.len) { + if (input[i] == '$') { + // Find the end of the variable name (alphanumeric + underscore) + const var_start = i + 1; + var var_end = var_start; + while (var_end < input.len and (std.ascii.isAlphanumeric(input[var_end]) or input[var_end] == '_')) { + var_end += 1; + } + if (var_end > var_start) { + const var_name = input[var_start..var_end]; + // We need a null-terminated string for getenv + const var_name_z = arena.dupeZ(u8, var_name) catch return input; + if (std.posix.getenv(var_name_z)) |env_val| { + result.appendSlice(arena, env_val) catch return input; + } else { + // Keep the original $VAR if not found + result.appendSlice(arena, input[i..var_end]) catch return input; + } + i = var_end; + } else { + result.append(arena, '$') catch return input; + i += 1; + } + } else { + result.append(arena, input[i]) catch return input; + i += 1; + } + } + return result.toOwnedSlice(arena) catch input; +} + +/// Escape a string for safe interpolation inside a JS double-quoted string literal. +fn escapeJs(arena: std.mem.Allocator, input: []const u8) []const u8 { + // Quick scan: if nothing to escape, return as-is + const needs_escape = for (input) |ch| { + if (ch == '"' or ch == '\\' or ch == '\n' or ch == '\r' or ch == '\t') break true; + } else false; + if (!needs_escape) return input; + + var out: std.ArrayListUnmanaged(u8) = .empty; + for (input) |ch| { + switch (ch) { + '\\' => out.appendSlice(arena, "\\\\") catch return input, + '"' => out.appendSlice(arena, "\\\"") catch return input, + '\n' => out.appendSlice(arena, "\\n") catch return input, + '\r' => out.appendSlice(arena, "\\r") catch return input, + '\t' => out.appendSlice(arena, "\\t") catch return input, + else => out.append(arena, ch) catch return input, + } + } + return out.toOwnedSlice(arena) catch input; +} + +/// Validate that a file path is safe: relative, no traversal above cwd. +fn sanitizePath(path: []const u8) ?[]const u8 { + // Reject absolute paths + if (path.len > 0 and path[0] == '/') return null; + + // Reject paths containing ".." components + var iter = std.mem.splitScalar(u8, path, '/'); + while (iter.next()) |component| { + if (std.mem.eql(u8, component, "..")) return null; + } + + return path; +} + fn findNodeIdByText(arena: std.mem.Allocator, elements_json: []const u8, target: []const u8) ?u32 { _ = arena; // Simple text search in the JSON result for the target text diff --git a/src/agent/Recorder.zig b/src/agent/Recorder.zig new file mode 100644 index 00000000..3113e1f5 --- /dev/null +++ b/src/agent/Recorder.zig @@ -0,0 +1,70 @@ +const std = @import("std"); +const Command = @import("Command.zig"); + +const Self = @This(); + +file: ?std.fs.File, + +/// Commands that are read-only / ephemeral and should NOT be recorded. +pub fn init(path: ?[]const u8) Self { + const file: ?std.fs.File = if (path) |p| + std.fs.cwd().createFile(p, .{ .truncate = false }) catch |err| blk: { + std.debug.print("Warning: could not open recording file: {s}\n", .{@errorName(err)}); + break :blk null; + } + else + null; + + // Seek to end for appending + if (file) |f| { + f.seekFromEnd(0) catch {}; + } + + return .{ .file = file }; +} + +pub fn deinit(self: *Self) void { + if (self.file) |f| f.close(); +} + +/// Record a successfully executed command line to the .panda file. +/// Skips read-only commands (WAIT, TREE, MARKDOWN). +pub fn record(self: *Self, line: []const u8) void { + const f = self.file orelse return; + + // Check if this command should be skipped + const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace); + if (trimmed.len == 0) return; + if (trimmed[0] == '#') return; + + const cmd_end = std.mem.indexOfAny(u8, trimmed, &std.ascii.whitespace) orelse trimmed.len; + const cmd_word = trimmed[0..cmd_end]; + + if (isNonRecordedCommand(cmd_word)) return; + + f.writeAll(trimmed) catch return; + f.writeAll("\n") catch return; +} + +/// Record a comment line (e.g. # INTENT: ...). +pub fn recordComment(self: *Self, comment: []const u8) void { + const f = self.file orelse return; + f.writeAll(comment) catch return; + f.writeAll("\n") catch return; +} + +fn isNonRecordedCommand(cmd_word: []const u8) bool { + const non_recorded = [_][]const u8{ "WAIT", "TREE", "MARKDOWN", "MD" }; + inline for (non_recorded) |skip| { + if (eqlIgnoreCase(cmd_word, skip)) return true; + } + return false; +} + +fn eqlIgnoreCase(a: []const u8, comptime upper: []const u8) bool { + if (a.len != upper.len) return false; + for (a, upper) |ac, uc| { + if (std.ascii.toUpper(ac) != uc) return false; + } + return true; +}