mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-09-17 08:27:11 -04:00
tools: declare image support at the call site, not in each consumer
Callers pass CallOpts.inline_image; execScreenshot rejects a path-less call before navigating or rendering, which removes the per-consumer guards and covers the model-driven tool path that had none. MCP image content is a protocol type, the screenshot recording rule joins the recorder's replayRequires predicate, and the viewport-to-Opts mapping, node-scope ladder and save-path helpers are shared instead of copied.
This commit is contained in:
1 parent
56c6a0a7ab
commit
193f15558c
10 files changed
+105
-124
No files matched your search
+2
-5
@@ -1488,7 +1488,7 @@ fn runCommand(self: *Agent, arena: std.mem.Allocator, cmd: Command) browser_tool
|
||||
.tool_call => |t| t,
|
||||
else => return .{ .text = "internal: command has no tool mapping", .is_error = true },
|
||||
};
|
||||
const result = browser_tools.call(arena, self.session, &self.node_registry, tc.name(), tc.args) catch |err| return .{
|
||||
return browser_tools.call(arena, self.session, &self.node_registry, tc.name(), tc.args, .{}) catch |err| .{
|
||||
.text = switch (err) {
|
||||
error.OutOfMemory => "out of memory",
|
||||
error.FrameNotLoaded => "no page loaded — run /goto <url> first",
|
||||
@@ -1496,9 +1496,6 @@ fn runCommand(self: *Agent, arena: std.mem.Allocator, cmd: Command) browser_tool
|
||||
},
|
||||
.is_error = true,
|
||||
};
|
||||
// Tool results reach the model as text; an inline image has nowhere to go.
|
||||
if (result.image != null) return .{ .text = "screenshot needs `path` here; the inline image is MCP-only", .is_error = true };
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Data output (/extract, /evaluate, /markdown, /tree, …) → plain stdout on
|
||||
@@ -1925,7 +1922,7 @@ fn handleToolCall(ctx: *anyopaque, allocator: std.mem.Allocator, tool_name: []co
|
||||
self.terminal.spinner.setTool(tool_name, args_str);
|
||||
defer self.terminal.spinner.setThinking();
|
||||
|
||||
const outcome: zenai.provider.Client.ToolHandler.Result = if (browser_tools.call(allocator, self.session, &self.node_registry, tool_name, arguments)) |result|
|
||||
const outcome: zenai.provider.Client.ToolHandler.Result = if (browser_tools.call(allocator, self.session, &self.node_registry, tool_name, arguments, .{})) |result|
|
||||
.{ .content = capToolOutput(allocator, tool_name, result.text), .is_error = result.is_error }
|
||||
else |err|
|
||||
.{ .content = std.fmt.allocPrint(allocator, "Error: {s}", .{browser_tools.errorMessage(err)}) catch "Error: tool execution failed", .is_error = true };
|
||||
|
||||
@@ -23,6 +23,7 @@ const Base64Writer = @import("../Base64Writer.zig");
|
||||
const isAllWhitespace = @import("../string.zig").isAllWhitespace;
|
||||
|
||||
const Frame = @import("Frame.zig");
|
||||
const Viewport = @import("Viewport.zig");
|
||||
const markdown = @import("markdown.zig");
|
||||
|
||||
const Node = @import("webapi/Node.zig");
|
||||
@@ -44,6 +45,15 @@ pub const Opts = struct {
|
||||
width: f32,
|
||||
height: f32,
|
||||
};
|
||||
|
||||
/// Height 0 renders the whole content instead of one viewport.
|
||||
pub fn fromViewport(viewport: Viewport, full_page: bool) Opts {
|
||||
return .{
|
||||
.width = viewport.width,
|
||||
.height = if (full_page) 0 else viewport.height,
|
||||
.scale = viewport.scale,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
pub fn png(arena: Allocator, node: *Node, opts: Opts, writer: *std.Io.Writer, frame: *Frame) !u32 {
|
||||
|
||||
+65
-59
@@ -215,6 +215,14 @@ pub const save_script_rules =
|
||||
/// `..` segment. Operator-controlled symlinks already inside CWD are out
|
||||
/// of scope — the threat we close here is "client supplies an arbitrary
|
||||
/// path string".
|
||||
pub const unsafe_path_message = "path must be relative and must not contain '..' segments";
|
||||
|
||||
/// The cwd is the server's, not one the user picked, so report where a file
|
||||
/// really went.
|
||||
pub fn absolutePath(arena: std.mem.Allocator, path: []const u8) []const u8 {
|
||||
return std.Io.Dir.cwd().realPathFileAlloc(lp.io, path, arena) catch path;
|
||||
}
|
||||
|
||||
pub fn isPathSafe(path: []const u8) bool {
|
||||
if (path.len == 0) return false;
|
||||
if (std.fs.path.isAbsolute(path)) return false;
|
||||
@@ -302,16 +310,23 @@ pub const Tool = enum {
|
||||
};
|
||||
}
|
||||
|
||||
/// Tool requires a target element (selector or backendNodeId) at
|
||||
/// runtime even though the JSON schema marks both as optional. Used by
|
||||
/// the recorder to skip lines that can't be replayed.
|
||||
pub fn needsLocator(self: Tool) bool {
|
||||
/// Args a replay needs even though the schema marks them optional; the
|
||||
/// recorder skips a line missing one. Exhaustive so a new tool must choose.
|
||||
pub fn replayRequires(self: Tool) []const []const u8 {
|
||||
return switch (self) {
|
||||
.click, .fill, .hover, .selectOption, .setChecked => true,
|
||||
.goto, .search, .markdown, .html, .screenshot, .links, .evaluate, .extract, .tree, .nodeDetails, .interactiveElements, .structuredData, .detectForms, .scroll, .waitForSelector, .waitForScript, .waitForState, .press, .findElement, .consoleLogs, .getUrl, .getCookies, .getEnv => false,
|
||||
.click, .fill, .hover, .selectOption, .setChecked => &.{"selector"},
|
||||
.screenshot => &.{"path"},
|
||||
.goto, .search, .markdown, .html, .links, .evaluate, .extract, .tree, .nodeDetails, .interactiveElements, .structuredData, .detectForms, .scroll, .waitForSelector, .waitForScript, .waitForState, .press, .findElement, .consoleLogs, .getUrl, .getCookies, .getEnv => &.{},
|
||||
};
|
||||
}
|
||||
|
||||
pub fn needsLocator(self: Tool) bool {
|
||||
for (self.replayRequires()) |field| {
|
||||
if (std.mem.eql(u8, field, "selector")) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Result is data the caller probably wants on stdout (extracted JSON,
|
||||
/// markdown, evaluate return value) rather than a status line on stderr.
|
||||
pub fn producesData(self: Tool) bool {
|
||||
@@ -401,13 +416,13 @@ pub const Tool = enum {
|
||||
),
|
||||
},
|
||||
.screenshot => .{
|
||||
.description = "Render the page, or one node, as a PNG: the text layout Lightpanda computes, not a pixel-accurate browser rendering (no images, fonts or CSS colours). With `path`, writes the file and returns its location; without it, returns the image inline (MCP only). Use it to see spatial layout; read content with `markdown`/`tree`.",
|
||||
.description = "Render the page, or one node, as a PNG: the text layout Lightpanda computes, not a pixel-accurate browser rendering (no images, fonts or CSS colours). With `path`, writes the file and returns its location; without it, returns the image inline where the client can display one. Use it to see spatial layout; read content with `markdown`/`tree`.",
|
||||
.summary = "Screenshot of the page or a node",
|
||||
.input_schema = minify(
|
||||
\\{
|
||||
\\ "type": "object",
|
||||
\\ "properties": {
|
||||
\\ "path": { "type": "string", "description": "Optional relative path (no '..' segments) to write the PNG to. Created or overwritten. Required outside MCP." },
|
||||
\\ "path": { "type": "string", "description": "Optional relative path (no '..' segments) to write the PNG to. Created or overwritten. Without it the image is returned inline, which needs a client that can display images." },
|
||||
\\ "selector": { "type": "string", "description": "Optional CSS selector. When set, render only that element." },
|
||||
\\ "backendNodeId": { "type": "integer", "description": "Optional backend node ID. When set, render only that node. 0 is treated as omitted." },
|
||||
\\ "fullPage": { "type": "boolean", "description": "Render the whole content height instead of one viewport. Defaults to false." },
|
||||
@@ -722,6 +737,7 @@ pub const Tool = enum {
|
||||
};
|
||||
|
||||
pub fn minify(comptime json: []const u8) []const u8 {
|
||||
// Cumulative: tool_defs evaluates every schema in one comptime scope.
|
||||
@setEvalBranchQuota(100_000);
|
||||
return comptime blk: {
|
||||
var buf: [json.len]u8 = undefined;
|
||||
@@ -809,7 +825,7 @@ pub fn errorMessage(err: ToolError) []const u8 {
|
||||
pub const ToolResult = struct {
|
||||
text: []const u8,
|
||||
is_error: bool = false,
|
||||
/// A rendered PNG the caller streams out itself (MCP image content).
|
||||
/// Only set when the caller passed `CallOpts.inline_image`.
|
||||
image: ?lp.screenshot.Prepared = null,
|
||||
};
|
||||
|
||||
@@ -838,12 +854,18 @@ const ActionTarget = union(enum) {
|
||||
|
||||
const NodeAndPage = struct { node: *DOMNode, page: *lp.Frame, target: ActionTarget };
|
||||
|
||||
/// What the caller can do with a result beyond its text.
|
||||
pub const CallOpts = struct {
|
||||
inline_image: bool = false,
|
||||
};
|
||||
|
||||
pub fn call(
|
||||
arena: std.mem.Allocator,
|
||||
session: *lp.Session,
|
||||
registry: *CDPNode.Registry,
|
||||
tool_name: []const u8,
|
||||
arguments: ?std.json.Value,
|
||||
opts: CallOpts,
|
||||
) ToolError!ToolResult {
|
||||
// In-band so an LLM that invented a tool name (e.g. OpenAI's internal
|
||||
// `multi_tool_use.parallel` wrapper) learns the name is wrong instead of
|
||||
@@ -862,7 +884,7 @@ pub fn call(
|
||||
};
|
||||
const substituted = try substituteStringArgs(arena, tool, normalized);
|
||||
|
||||
return dispatch(arena, session, registry, tool, substituted) catch |err| {
|
||||
return dispatch(arena, session, registry, tool, substituted, opts) catch |err| {
|
||||
if (err == error.NavigationFailed) {
|
||||
if (formatNavigationError(arena, session)) |text|
|
||||
return .{ .text = text, .is_error = true };
|
||||
@@ -877,13 +899,14 @@ fn dispatch(
|
||||
registry: *CDPNode.Registry,
|
||||
tool: Tool,
|
||||
substituted: ?std.json.Value,
|
||||
opts: CallOpts,
|
||||
) ToolError!ToolResult {
|
||||
return switch (tool) {
|
||||
.goto => .{ .text = try execGoto(arena, session, registry, substituted) },
|
||||
.search => execSearch(arena, substituted),
|
||||
.markdown => .{ .text = try execMarkdown(arena, session, registry, substituted) },
|
||||
.html => .{ .text = try execHtml(arena, session, registry, substituted) },
|
||||
.screenshot => try execScreenshot(arena, session, registry, substituted),
|
||||
.screenshot => try execScreenshot(arena, session, registry, substituted, opts.inline_image),
|
||||
.links => .{ .text = try execLinks(arena, session, registry, substituted) },
|
||||
.tree => .{ .text = try execTree(arena, session, registry, substituted) },
|
||||
.nodeDetails => .{ .text = try execNodeDetails(arena, session, registry, substituted) },
|
||||
@@ -1289,21 +1312,21 @@ fn execMarkdown(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNo
|
||||
const args = try parseArgsOrDefault(Params, arena, arguments);
|
||||
const page = try ensurePage(session, registry, args.url, args.timeout);
|
||||
|
||||
const opts: lp.markdown.Opts = .{ .max_bytes = args.maxBytes };
|
||||
const node = try resolveScope(session, registry, page, args.selector, args.backendNodeId);
|
||||
|
||||
var aw: std.Io.Writer.Allocating = .init(arena);
|
||||
if (args.selector) |sel| {
|
||||
const resolved = try resolveBySelector(session, sel);
|
||||
lp.markdown.dump(resolved.node, opts, &aw.writer, resolved.page) catch return ToolError.InternalError;
|
||||
} else if (args.backendNodeId) |nid| {
|
||||
const resolved = try resolveNodeAndPage(session, registry, nid);
|
||||
lp.markdown.dump(resolved.node, opts, &aw.writer, resolved.page) catch return ToolError.InternalError;
|
||||
} else {
|
||||
lp.markdown.dump(page.document.asNode(), opts, &aw.writer, page) catch return ToolError.InternalError;
|
||||
}
|
||||
lp.markdown.dump(node, .{ .max_bytes = args.maxBytes }, &aw.writer, page) catch return ToolError.InternalError;
|
||||
return aw.written();
|
||||
}
|
||||
|
||||
/// The node a read tool works on: the selector match, the registry node, or
|
||||
/// the whole document. All three live in the current frame.
|
||||
fn resolveScope(session: *lp.Session, registry: *CDPNode.Registry, page: *lp.Frame, selector: ?[]const u8, node_id: ?CDPNode.Id) ToolError!*DOMNode {
|
||||
if (selector) |sel| return (try resolveBySelector(session, sel)).node;
|
||||
if (node_id) |nid| return (try resolveNodeAndPage(session, registry, nid)).node;
|
||||
return page.document.asNode();
|
||||
}
|
||||
|
||||
const HtmlParams = struct {
|
||||
selector: ?[]const u8 = null,
|
||||
backendNodeId: ?CDPNode.Id = null,
|
||||
@@ -1319,19 +1342,16 @@ fn execHtml(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode.R
|
||||
|
||||
const opts: lp.dump.Opts = .{ .strip = args.strip, .max_bytes = args.maxBytes };
|
||||
var aw: std.Io.Writer.Allocating = .init(arena);
|
||||
if (args.selector) |sel| {
|
||||
const resolved = try resolveBySelector(session, sel);
|
||||
lp.dump.deep(resolved.node, opts, &aw.writer, resolved.page) catch return ToolError.InternalError;
|
||||
} else if (args.backendNodeId) |nid| {
|
||||
const resolved = try resolveNodeAndPage(session, registry, nid);
|
||||
lp.dump.deep(resolved.node, opts, &aw.writer, resolved.page) catch return ToolError.InternalError;
|
||||
} else {
|
||||
if (args.selector == null and args.backendNodeId == null) {
|
||||
lp.dump.root(page.document, opts, &aw.writer, page) catch return ToolError.InternalError;
|
||||
} else {
|
||||
const node = try resolveScope(session, registry, page, args.selector, args.backendNodeId);
|
||||
lp.dump.deep(node, opts, &aw.writer, page) catch return ToolError.InternalError;
|
||||
}
|
||||
return aw.written();
|
||||
}
|
||||
|
||||
fn execScreenshot(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode.Registry, arguments: ?std.json.Value) ToolError!ToolResult {
|
||||
fn execScreenshot(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode.Registry, arguments: ?std.json.Value, inline_image: bool) ToolError!ToolResult {
|
||||
const Params = struct {
|
||||
path: ?[]const u8 = null,
|
||||
selector: ?[]const u8 = null,
|
||||
@@ -1342,41 +1362,27 @@ fn execScreenshot(arena: std.mem.Allocator, session: *lp.Session, registry: *CDP
|
||||
};
|
||||
const args = try parseArgsOrDefault(Params, arena, arguments);
|
||||
if (args.path) |path| {
|
||||
if (!isPathSafe(path)) return .{ .text = "path must be relative and must not contain '..' segments", .is_error = true };
|
||||
if (!isPathSafe(path)) return .{ .text = unsafe_path_message, .is_error = true };
|
||||
} else if (!inline_image) {
|
||||
return .{ .text = "pass `path`: this client cannot display an inline image", .is_error = true };
|
||||
}
|
||||
const page = try ensurePage(session, registry, args.url, args.timeout);
|
||||
const node = try resolveScope(session, registry, page, args.selector, args.backendNodeId);
|
||||
const prepared = lp.screenshot.prepare(arena, node, .fromViewport(page._page.getViewport(), args.fullPage), page) catch
|
||||
return ToolError.InternalError;
|
||||
const width = prepared.opts.width;
|
||||
|
||||
var node = page.document.asNode();
|
||||
var frame = page;
|
||||
if (args.selector) |sel| {
|
||||
const resolved = try resolveBySelector(session, sel);
|
||||
node = resolved.node;
|
||||
frame = resolved.page;
|
||||
} else if (args.backendNodeId) |nid| {
|
||||
const resolved = try resolveNodeAndPage(session, registry, nid);
|
||||
node = resolved.node;
|
||||
frame = resolved.page;
|
||||
if (args.path) |path| {
|
||||
const height = writePng(&prepared, path) catch |err| return .{
|
||||
.text = std.fmt.allocPrint(arena, "could not write {s}: {s}", .{ path, @errorName(err) }) catch return ToolError.OutOfMemory,
|
||||
.is_error = true,
|
||||
};
|
||||
return .{ .text = std.fmt.allocPrint(arena, "Saved {d}x{d} PNG to {s}", .{ width, height, absolutePath(arena, path) }) catch return ToolError.OutOfMemory };
|
||||
}
|
||||
|
||||
const viewport = page._page.getViewport();
|
||||
const prepared = lp.screenshot.prepare(arena, node, .{
|
||||
.width = viewport.width,
|
||||
.height = if (args.fullPage) 0 else viewport.height,
|
||||
.scale = viewport.scale,
|
||||
}, frame) catch return ToolError.InternalError;
|
||||
|
||||
const path = args.path orelse return .{
|
||||
.text = std.fmt.allocPrint(arena, "PNG, {d}px wide", .{viewport.width}) catch return ToolError.OutOfMemory,
|
||||
return .{
|
||||
.text = std.fmt.allocPrint(arena, "PNG, {d}px wide", .{width}) catch return ToolError.OutOfMemory,
|
||||
.image = prepared,
|
||||
};
|
||||
|
||||
const height = writePng(&prepared, path) catch |err| return .{
|
||||
.text = std.fmt.allocPrint(arena, "could not write {s}: {s}", .{ path, @errorName(err) }) catch return ToolError.OutOfMemory,
|
||||
.is_error = true,
|
||||
};
|
||||
// Absolute path: the cwd is the server's, not one the user picked.
|
||||
const where = std.Io.Dir.cwd().realPathFileAlloc(lp.io, path, arena) catch path;
|
||||
return .{ .text = std.fmt.allocPrint(arena, "Saved {d}x{d} PNG to {s}", .{ viewport.width, height, where }) catch return ToolError.OutOfMemory };
|
||||
}
|
||||
|
||||
fn writePng(prepared: *const lp.screenshot.Prepared, path: []const u8) !u32 {
|
||||
@@ -2455,7 +2461,7 @@ test "call: unknown tool name surfaces in-band" {
|
||||
|
||||
// Session/registry are never touched on this branch; the name check is
|
||||
// the first thing `call` does.
|
||||
const r = try call(arena.allocator(), undefined, undefined, "multi_tool_use.parallel", null);
|
||||
const r = try call(arena.allocator(), undefined, undefined, "multi_tool_use.parallel", null, .{});
|
||||
try std.testing.expect(r.is_error);
|
||||
try std.testing.expectEqualStrings("Unknown tool: multi_tool_use.parallel", r.text);
|
||||
}
|
||||
|
||||
@@ -993,11 +993,7 @@ fn captureScreenshot(cmd: *CDP.Command) !void {
|
||||
const frame = bc.mainFrame() orelse return error.FrameNotLoaded;
|
||||
const viewport = cmd.cdp.browser.getViewport();
|
||||
|
||||
var opts: lp.screenshot.Opts = .{
|
||||
.scale = viewport.scale,
|
||||
.width = viewport.width,
|
||||
.height = if (params.captureBeyondViewport orelse false) 0 else viewport.height,
|
||||
};
|
||||
var opts: lp.screenshot.Opts = .fromViewport(viewport, params.captureBeyondViewport orelse false);
|
||||
if (params.clip) |clip| {
|
||||
opts.clip = .{
|
||||
.x = @floatCast(clip.x),
|
||||
|
||||
+2
-6
@@ -439,9 +439,7 @@ fn writeResults(app: *App, opts: FetchOpts, pages: []const Session.PageHandle, e
|
||||
}
|
||||
|
||||
fn prepareShot(arena: std.mem.Allocator, frame: *Frame, opts: FetchOpts) !screenshot.Prepared {
|
||||
return screenshot.prepare(arena, try dumpRoot(frame, opts.selector), .{
|
||||
.width = frame._page.getViewport().width,
|
||||
}, frame);
|
||||
return screenshot.prepare(arena, try dumpRoot(frame, opts.selector), .fromViewport(frame._page.getViewport(), true), frame);
|
||||
}
|
||||
|
||||
fn dumpRoot(frame: *Frame, selector: ?[]const u8) !*Node {
|
||||
@@ -462,9 +460,7 @@ fn dumpContent(app: *App, mode: Config.DumpFormat, opts: FetchOpts, frame: *Fram
|
||||
.png => {
|
||||
var arena: std.heap.ArenaAllocator = .init(app.allocator);
|
||||
defer arena.deinit();
|
||||
_ = try screenshot.png(arena.allocator(), root, .{
|
||||
.width = frame._page.getViewport().width,
|
||||
}, writer, frame);
|
||||
_ = try screenshot.png(arena.allocator(), root, .fromViewport(frame._page.getViewport(), true), writer, frame);
|
||||
},
|
||||
.semantic_tree, .semantic_tree_text => {
|
||||
var registry = CDPNode.Registry.init(app.allocator);
|
||||
|
||||
@@ -167,6 +167,12 @@ pub const CallParams = struct {
|
||||
arguments: ?std.json.Value = null,
|
||||
};
|
||||
|
||||
pub const ImageContent = struct {
|
||||
type: []const u8 = "image",
|
||||
data: @import("../browser/screenshot.zig").Prepared,
|
||||
mimeType: []const u8 = "image/png",
|
||||
};
|
||||
|
||||
pub fn TextContent(comptime T: type) type {
|
||||
return struct {
|
||||
type: []const u8 = "text",
|
||||
|
||||
+8
-38
@@ -156,7 +156,7 @@ fn dispatchBrowserTool(
|
||||
};
|
||||
|
||||
const active = server.active_session;
|
||||
const result = browser_tools.call(arena, active.session, &active.node_registry, name, arguments) catch |err| {
|
||||
const result = browser_tools.call(arena, active.session, &active.node_registry, name, arguments, .{ .inline_image = true }) catch |err| {
|
||||
// evaluate/extract surface failures in-band so the LLM can self-correct;
|
||||
// other tools' operational failures are protocol-level.
|
||||
if (surfacesErrorInBand(tool)) {
|
||||
@@ -173,41 +173,14 @@ fn dispatchBrowserTool(
|
||||
};
|
||||
|
||||
if (result.image) |image| {
|
||||
return server.sendResult(id, ImageResult{ .image = image, .text = result.text });
|
||||
return server.sendResult(id, .{
|
||||
.content = .{ protocol.ImageContent{ .data = image }, protocol.TextContent([]const u8){ .text = result.text } },
|
||||
.isError = result.is_error,
|
||||
});
|
||||
}
|
||||
try sendToolResultText(server, id, result.text, result.is_error);
|
||||
}
|
||||
|
||||
/// MCP image content: the PNG streams straight into the message as base64.
|
||||
const ImageResult = struct {
|
||||
image: lp.screenshot.Prepared,
|
||||
text: []const u8,
|
||||
|
||||
pub fn jsonStringify(self: @This(), jw: anytype) !void {
|
||||
try jw.beginObject();
|
||||
try jw.objectField("content");
|
||||
try jw.beginArray();
|
||||
try jw.beginObject();
|
||||
try jw.objectField("type");
|
||||
try jw.write("image");
|
||||
try jw.objectField("data");
|
||||
try jw.write(self.image);
|
||||
try jw.objectField("mimeType");
|
||||
try jw.write("image/png");
|
||||
try jw.endObject();
|
||||
try jw.beginObject();
|
||||
try jw.objectField("type");
|
||||
try jw.write("text");
|
||||
try jw.objectField("text");
|
||||
try jw.write(self.text);
|
||||
try jw.endObject();
|
||||
try jw.endArray();
|
||||
try jw.objectField("isError");
|
||||
try jw.write(false);
|
||||
try jw.endObject();
|
||||
}
|
||||
};
|
||||
|
||||
fn surfacesErrorInBand(tool: BrowserTool) bool {
|
||||
return tool == .evaluate or tool == .extract;
|
||||
}
|
||||
@@ -219,7 +192,7 @@ fn handleSave(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arg
|
||||
};
|
||||
|
||||
if (!browser_tools.isPathSafe(args.path)) {
|
||||
return sendErrorContent(server, id, "path must be relative and must not contain '..' segments");
|
||||
return sendErrorContent(server, id, browser_tools.unsafe_path_message);
|
||||
}
|
||||
|
||||
// The client never sees resolved secrets, but scrub any literal LP_* value
|
||||
@@ -233,8 +206,7 @@ fn handleSave(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arg
|
||||
return sendErrorContent(server, id, msg);
|
||||
};
|
||||
|
||||
// Absolute path: the cwd is the client-launched server's, not one the user picked.
|
||||
const where = std.Io.Dir.cwd().realPathFileAlloc(lp.io, args.path, arena) catch args.path;
|
||||
const where = browser_tools.absolutePath(arena, args.path);
|
||||
const lines = std.mem.count(u8, script, "\n") + 1;
|
||||
const msg = std.fmt.allocPrint(arena, "saved {d} line(s) to {s}", .{ lines, where }) catch
|
||||
return sendErrorContent(server, id, "out of memory");
|
||||
@@ -1498,9 +1470,7 @@ test "MCP - screenshot: inline image, file, unsafe path" {
|
||||
defer std.Io.Dir.cwd().deleteFile(lp.io, path) catch {};
|
||||
|
||||
out.clearRetainingCapacity();
|
||||
const to_file =
|
||||
\\{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"screenshot","arguments":{"path":"mcp-screenshot-test.png","selector":"#hoverTarget"}}}
|
||||
;
|
||||
const to_file = "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"screenshot\",\"arguments\":{\"path\":\"" ++ path ++ "\",\"selector\":\"#hoverTarget\"}}}";
|
||||
try router.handleMessage(server, testing.arena_allocator, to_file);
|
||||
try testing.expect(std.mem.indexOf(u8, out.written(), "Saved 1920x") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, out.written(), "\"type\":\"image\"") == null);
|
||||
|
||||
@@ -714,14 +714,13 @@ fn callTool(
|
||||
self.session.browser.env.isolate.enter();
|
||||
defer self.session.browser.env.isolate.exit();
|
||||
|
||||
const result = browser_tools.call(arena, self.session, self.registry, @tagName(tool), args) catch |err| switch (err) {
|
||||
const result = browser_tools.call(arena, self.session, self.registry, @tagName(tool), args, .{}) catch |err| switch (err) {
|
||||
error.OutOfMemory => return error.OutOfMemory,
|
||||
error.FrameNotLoaded => return .{ .fail = "no page loaded - run page.goto(url) first" },
|
||||
else => return .{ .fail = std.fmt.allocPrint(arena, "{s} failed: {s}", .{ @tagName(tool), @errorName(err) }) catch return error.OutOfMemory },
|
||||
};
|
||||
|
||||
if (result.is_error) return .{ .fail = result.text };
|
||||
if (result.image != null) return .{ .fail = "screenshot needs `path` in a script" };
|
||||
return .{ .ok = result.text };
|
||||
}
|
||||
|
||||
|
||||
@@ -104,19 +104,20 @@ pub const Command = union(enum) {
|
||||
}
|
||||
|
||||
/// Skip the line when the recorded form would not round-trip:
|
||||
/// - no `selector` AND (tool needs one OR only locator is the
|
||||
/// ephemeral `backendNodeId`);
|
||||
/// - an arg the replay requires (`Tool.replayRequires`) is missing;
|
||||
/// - the only locator is the ephemeral `backendNodeId`;
|
||||
/// - a string field can't be quoted unambiguously.
|
||||
fn isRecorded(self: ToolCall) bool {
|
||||
if (!self.tool.isRecorded()) return false;
|
||||
const s = self.schema();
|
||||
const args = self.args orelse return s.required.len == 0 and !self.tool.needsLocator() and self.tool != .screenshot;
|
||||
if (args != .object) return !self.tool.needsLocator();
|
||||
// An inline screenshot (no `path`) only exists as MCP image content.
|
||||
if (self.tool == .screenshot and !args.object.contains("path")) return false;
|
||||
const required = self.tool.replayRequires();
|
||||
const args = self.args orelse return s.required.len == 0 and required.len == 0;
|
||||
if (args != .object) return required.len == 0;
|
||||
|
||||
const has_selector = args.object.contains("selector");
|
||||
if (!has_selector and (self.tool.needsLocator() or args.object.contains("backendNodeId"))) return false;
|
||||
if (!args.object.contains("selector") and args.object.contains("backendNodeId")) return false;
|
||||
for (required) |field| {
|
||||
if (!args.object.contains(field)) return false;
|
||||
}
|
||||
|
||||
const positional = s.isBarePositional(args.object);
|
||||
|
||||
|
||||
@@ -198,7 +198,7 @@ fn note(tool: browser_tools.Tool) []const u8 {
|
||||
.waitForSelector => "`waitFor*` default timeout 5000 ms.",
|
||||
.waitForScript => "Re-evaluates page JS until truthy.",
|
||||
.waitForState => "",
|
||||
.screenshot => "Needs `path`; writes a PNG of the text layout.",
|
||||
.screenshot => "`path` is required: writes a PNG of the text layout.",
|
||||
.press => "Selector first! `page.press(\"Enter\")` binds \"Enter\" to `selector` and fails — use `page.press(null, \"Enter\")` or `page.press({ key: \"Enter\" })`.",
|
||||
.click, .fill, .scroll, .hover, .selectOption, .setChecked => "",
|
||||
.search, .markdown, .html, .links, .tree, .nodeDetails, .interactiveElements, .structuredData, .detectForms, .findElement, .consoleLogs, .getUrl, .getCookies, .getEnv => "",
|
||||
|
||||
Reference in new issue
Block a user