diff --git a/src/agent/Agent.zig b/src/agent/Agent.zig index 5e1da4d1..5b7114cd 100644 --- a/src/agent/Agent.zig +++ b/src/agent/Agent.zig @@ -274,8 +274,7 @@ fn runScript(self: *Self, path: []const u8) bool { defer cmd_arena.deinit(); const result = self.cmd_executor.executeWithResult(cmd_arena.allocator(), entry.command); - self.terminal.printAssistant(result.output); - std.debug.print("\n", .{}); + self.cmd_executor.printResult(entry.command, result); if (result.failed) { if (self.self_heal and self.ai_client != null) { @@ -378,7 +377,7 @@ fn processUserMessage(self: *Self, user_input: []const u8, record_comment: []con if (result.text) |text| { std.debug.print("\n", .{}); self.terminal.printAssistant(text); - std.debug.print("\n\n", .{}); + std.debug.print("\n", .{}); } else { self.terminal.printInfo("(no response from model)"); } diff --git a/src/agent/Command.zig b/src/agent/Command.zig index 1f5950a3..462d3e00 100644 --- a/src/agent/Command.zig +++ b/src/agent/Command.zig @@ -5,11 +5,6 @@ pub const TypeArgs = struct { value: []const u8, }; -pub const ExtractArgs = struct { - selector: []const u8, - file: ?[]const u8, -}; - pub const ScrollArgs = struct { x: i32 = 0, y: i32 = 0, @@ -36,7 +31,7 @@ pub const Command = union(enum) { check: CheckArgs, tree: void, markdown: void, - extract: ExtractArgs, + extract: []const u8, eval_js: []const u8, login: void, accept_cookies: void, @@ -52,6 +47,16 @@ pub const Command = union(enum) { }; } + /// True if running this command produces output the user typically wants to + /// capture (and so should land on stdout). False for action commands whose + /// only output is an acknowledgment. + pub fn producesData(self: Command) bool { + return switch (self) { + .extract, .eval_js, .markdown, .tree => true, + else => false, + }; + } + pub fn format(self: Command, writer: *std.Io.Writer) std.Io.Writer.Error!void { switch (self) { .goto => |url| try writer.print("GOTO {s}", .{url}), @@ -67,10 +72,7 @@ pub const Command = union(enum) { try writer.print("CHECK '{s}' false", .{args.selector}), .tree => try writer.writeAll("TREE"), .markdown => try writer.writeAll("MARKDOWN"), - .extract => |args| { - try writer.print("EXTRACT '{s}'", .{args.selector}); - if (args.file) |f| try writer.print(" > {s}", .{f}); - }, + .extract => |selector| try writer.print("EXTRACT '{s}'", .{selector}), .eval_js => |script| { if (std.mem.indexOfScalar(u8, script, '\n') != null) { try writer.print("EVAL '''\n{s}\n'''", .{script}); @@ -181,18 +183,9 @@ pub fn parse(line: []const u8) Command { } if (std.ascii.eqlIgnoreCase(cmd_word, "EXTRACT")) { - const selector = extractQuoted(rest) orelse { - if (rest.len == 0) return .{ .natural_language = trimmed }; - return .{ .extract = .{ .selector = rest, .file = null } }; - }; - // Look for > filename after the quoted selector - const after_quote = extractQuotedWithRemainder(rest) orelse return .{ .extract = .{ .selector = selector, .file = null } }; - const after = std.mem.trim(u8, after_quote.remainder, &std.ascii.whitespace); - if (after.len > 0 and after[0] == '>') { - const file = std.mem.trim(u8, after[1..], &std.ascii.whitespace); - return .{ .extract = .{ .selector = selector, .file = if (file.len > 0) file else null } }; - } - return .{ .extract = .{ .selector = selector, .file = null } }; + if (rest.len == 0) return .{ .natural_language = trimmed }; + const selector = extractQuoted(rest) orelse rest; + return .{ .extract = selector }; } if (std.ascii.eqlIgnoreCase(cmd_word, "EVAL")) { @@ -449,16 +442,9 @@ test "parse MARKDOWN alias MD" { try std.testing.expect(parse("md") == .markdown); } -test "parse EXTRACT with file" { - const cmd = parse("EXTRACT \".title\" > titles.json"); - try std.testing.expectEqualStrings(".title", cmd.extract.selector); - try std.testing.expectEqualStrings("titles.json", cmd.extract.file.?); -} - -test "parse EXTRACT without file" { +test "parse EXTRACT" { const cmd = parse("EXTRACT \".title\""); - try std.testing.expectEqualStrings(".title", cmd.extract.selector); - try std.testing.expect(cmd.extract.file == null); + try std.testing.expectEqualStrings(".title", cmd.extract); } test "parse EVAL single line" { diff --git a/src/agent/CommandExecutor.zig b/src/agent/CommandExecutor.zig index ddca45dc..32d927ef 100644 --- a/src/agent/CommandExecutor.zig +++ b/src/agent/CommandExecutor.zig @@ -42,7 +42,7 @@ pub fn executeWithResult(self: *Self, a: std.mem.Allocator, cmd: Command.Command })), .tree => self.callTool(a, @tagName(Action.semanticTree), ""), .markdown => self.callTool(a, @tagName(Action.markdown), ""), - .extract => |args| self.execExtract(a, args), + .extract => |selector| self.execExtract(a, selector), .eval_js => |script| self.callTool(a, @tagName(Action.eval), buildJson(a, .{ .script = script })), .exit, .natural_language, .comment, .login, .accept_cookies => unreachable, }; @@ -53,9 +53,18 @@ pub fn execute(self: *Self, cmd: Command.Command) void { defer arena.deinit(); const result = self.executeWithResult(arena.allocator(), cmd); + self.printResult(cmd, result); +} - self.terminal.printAssistant(result.output); - std.debug.print("\n", .{}); +/// Route a command's output to stdout (for data-producing commands like +/// EXTRACT/EVAL/MARKDOWN/TREE) or stderr (for action commands like +/// GOTO/CLICK/...) so that shell-redirecting stdout captures only data. +pub fn printResult(self: *Self, cmd: Command.Command, result: ExecResult) void { + if (cmd.producesData()) { + self.terminal.printAssistant(result.output); + } else { + self.terminal.printActionResult(result.output); + } } fn callTool(self: *Self, arena: std.mem.Allocator, tool_name: []const u8, arguments_json: []const u8) ExecResult { @@ -76,33 +85,14 @@ fn execType(self: *Self, arena: std.mem.Allocator, args: Command.TypeArgs) ExecR return self.callTool(arena, @tagName(browser_tools.Action.fill), buildJson(arena, .{ .selector = selector, .value = value })); } -fn execExtract(self: *Self, arena: std.mem.Allocator, args: Command.ExtractArgs) ExecResult { - const selector = escapeJs(arena, substituteEnvVars(arena, args.selector)); +fn execExtract(self: *Self, arena: std.mem.Allocator, raw_selector: []const u8) ExecResult { + const selector = escapeJs(arena, substituteEnvVars(arena, raw_selector)); const script = std.fmt.allocPrint(arena, \\JSON.stringify(Array.from(document.querySelectorAll("{s}")).map(el => el.textContent.trim())) , .{selector}) catch return .{ .output = "failed to build extract script", .failed = true }; - const result = self.tool_executor.call(arena, @tagName(browser_tools.Action.eval), buildJson(arena, .{ .script = script })) catch |err| - return .{ .output = std.fmt.allocPrint(arena, "extract failed: {s}", .{@errorName(err)}) catch "extract failed", .failed = true }; - - 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 .{ .output = result, .failed = false }; - }; - std.fs.cwd().writeFile(.{ - .sub_path = file, - .data = result, - }) catch { - self.terminal.printError("Failed to write to file"); - return .{ .output = result, .failed = false }; - }; - const msg = std.fmt.allocPrint(arena, "Extracted to {s}", .{file}) catch "Extracted."; - return .{ .output = msg, .failed = false }; - } - - return .{ .output = result, .failed = false }; + return self.callTool(arena, @tagName(browser_tools.Action.eval), buildJson(arena, .{ .script = script })); } const substituteEnvVars = browser_tools.substituteEnvVars; @@ -127,17 +117,6 @@ fn escapeJs(arena: std.mem.Allocator, input: []const u8) []const u8 { return out.toOwnedSlice(arena) catch input; } -fn sanitizePath(path: []const u8) ?[]const u8 { - if (path.len > 0 and path[0] == '/') return null; - - var iter = std.mem.splitScalar(u8, path, '/'); - while (iter.next()) |component| { - if (std.mem.eql(u8, component, "..")) return null; - } - - return path; -} - fn buildJson(arena: std.mem.Allocator, value: anytype) []const u8 { var aw: std.Io.Writer.Allocating = .init(arena); std.json.Stringify.value(value, .{}, &aw.writer) catch return "{}"; @@ -169,20 +148,6 @@ test "escapeJs injection attempt" { try std.testing.expectEqualStrings("\\\"; alert(1); //", result); } -test "sanitizePath allows relative" { - try std.testing.expectEqualStrings("output.json", sanitizePath("output.json").?); - try std.testing.expectEqualStrings("dir/file.json", sanitizePath("dir/file.json").?); -} - -test "sanitizePath rejects absolute" { - try std.testing.expect(sanitizePath("/etc/passwd") == null); -} - -test "sanitizePath rejects traversal" { - try std.testing.expect(sanitizePath("../../../etc/passwd") == null); - try std.testing.expect(sanitizePath("foo/../../bar") == null); -} - test "substituteEnvVars no vars" { const result = substituteEnvVars(std.testing.allocator, "hello world"); try std.testing.expectEqualStrings("hello world", result); diff --git a/src/agent/Terminal.zig b/src/agent/Terminal.zig index cd5a225c..bfba12a7 100644 --- a/src/agent/Terminal.zig +++ b/src/agent/Terminal.zig @@ -29,7 +29,7 @@ const commands = [_]CommandInfo{ .{ .name = "TREE", .hint = "" }, .{ .name = "MARKDOWN", .hint = "" }, .{ .name = "MD", .hint = "" }, - .{ .name = "EXTRACT", .hint = " '' [> file]" }, + .{ .name = "EXTRACT", .hint = " ''" }, .{ .name = "EVAL", .hint = " '