From 4d384dfe018045d329115ba41b7a0771f4594256 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Thu, 9 Apr 2026 19:41:54 -0700 Subject: [PATCH] feat: add --cookies-file flag for session persistence Add a --cookies-file CLI option that loads cookies from a JSON file at startup and saves them back on exit. This enables AI agents to maintain login sessions across multiple Lightpanda invocations. The cookie format matches CDP Network.Cookie (compatible with Puppeteer's page.cookies() export): [{"name":"sid","value":"abc","domain":".example.com","path":"/", "expires":1234567890,"secure":true,"httpOnly":true}] Closes #335 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Config.zig | 23 +++++++++ src/cookies.zig | 120 +++++++++++++++++++++++++++++++++++++++++++++ src/lightpanda.zig | 14 ++++++ 3 files changed, 157 insertions(+) create mode 100644 src/cookies.zig diff --git a/src/Config.zig b/src/Config.zig index 30692516..928b0952 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -177,6 +177,13 @@ pub fn httpCacheDir(self: *const Config) ?[]const u8 { }; } +pub fn cookiesFile(self: *const Config) ?[]const u8 { + return switch (self.mode) { + inline .serve, .fetch, .mcp => |opts| opts.common.cookies_file, + else => null, + }; +} + pub fn cdpTimeout(self: *const Config) usize { return switch (self.mode) { .serve => |opts| if (opts.timeout > 604_800) 604_800_000 else @as(usize, opts.timeout) * 1000, @@ -310,6 +317,7 @@ pub const Common = struct { user_agent_suffix: ?[]const u8 = null, user_agent: ?[]const u8 = null, http_cache_dir: ?[]const u8 = null, + cookies_file: ?[]const u8 = null, web_bot_auth_key_file: ?[]const u8 = null, web_bot_auth_keyid: ?[]const u8 = null, @@ -465,6 +473,12 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void { \\ Path to a directory to use as a Filesystem Cache for network resources. \\ Omitting this will result is no caching. \\ Defaults to no caching. + \\ + \\--cookies-file + \\ Path to a JSON file for cookie persistence. Cookies are loaded + \\ from this file at startup and saved back on exit. + \\ Format: [{name, value, domain, path, expires, secure, httpOnly}] + \\ Defaults to no persistence. ; // MAX_HELP_LEN| @@ -1185,6 +1199,15 @@ fn parseCommonArg( return true; } + if (std.mem.eql(u8, "--cookies-file", opt)) { + const str = args.next() orelse { + log.fatal(.app, "missing argument value", .{ .arg = "--cookies-file" }); + return error.InvalidArgument; + }; + common.cookies_file = try allocator.dupe(u8, str); + return true; + } + return false; } diff --git a/src/cookies.zig b/src/cookies.zig new file mode 100644 index 00000000..9d6eb475 --- /dev/null +++ b/src/cookies.zig @@ -0,0 +1,120 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const Cookie = @import("browser/webapi/storage/Cookie.zig"); +const log = @import("log.zig"); + +/// Load cookies from a JSON file into the cookie jar. +/// The file format is an array of objects with: name, value, domain, path, +/// expires (optional, float), secure (optional, bool), httpOnly (optional, bool). +/// This matches the CDP Network.Cookie format used by Puppeteer and Playwright. +pub fn loadFromFile(jar: *Cookie.Jar, path: []const u8) !void { + const file = std.fs.cwd().openFile(path, .{}) catch |err| switch (err) { + error.FileNotFound => return, // No file yet, nothing to load + else => { + log.err(.app, "failed to open cookies file", .{ .path = path, .err = err }); + return err; + }, + }; + defer file.close(); + + const content = file.readToEndAlloc(jar.allocator, 1024 * 1024) catch |err| { + log.err(.app, "failed to read cookies file", .{ .path = path, .err = err }); + return err; + }; + defer jar.allocator.free(content); + + const parsed = std.json.parseFromSlice([]const JsonCookie, jar.allocator, content, .{ + .ignore_unknown_fields = true, + }) catch |err| { + log.err(.app, "failed to parse cookies JSON", .{ .path = path, .err = err }); + return err; + }; + defer parsed.deinit(); + + var loaded: usize = 0; + for (parsed.value) |jc| { + var arena = std.heap.ArenaAllocator.init(jar.allocator); + errdefer arena.deinit(); + const a = arena.allocator(); + + const cookie = Cookie{ + .arena = arena, + .name = try a.dupe(u8, jc.name), + .value = try a.dupe(u8, jc.value), + .domain = try a.dupe(u8, jc.domain), + .path = try a.dupe(u8, jc.path orelse "/"), + .expires = jc.expires, + .secure = jc.secure orelse false, + .http_only = jc.httpOnly orelse false, + .same_site = .none, + }; + + jar.add(cookie, std.time.timestamp()) catch |err| { + cookie.deinit(); + log.warn(.app, "skipping cookie", .{ .name = jc.name, .err = err }); + continue; + }; + loaded += 1; + } + + log.info(.app, "loaded cookies from file", .{ .path = path, .count = loaded }); +} + +/// Save all cookies from the jar to a JSON file. +pub fn saveToFile(jar: *Cookie.Jar, path: []const u8) !void { + jar.removeExpired(null); + + var file = try std.fs.cwd().createFile(path, .{}); + defer file.close(); + + var bw = std.io.bufferedWriter(file.writer()); + const writer = bw.writer(); + + try writer.writeByte('['); + for (jar.cookies.items, 0..) |c, i| { + if (i > 0) try writer.writeByte(','); + try writer.writeAll("\n "); + try std.json.stringify(JsonCookie{ + .name = c.name, + .value = c.value, + .domain = c.domain, + .path = c.path, + .expires = c.expires, + .secure = c.secure, + .httpOnly = c.http_only, + }, .{}, writer); + } + if (jar.cookies.items.len > 0) { + try writer.writeByte('\n'); + } + try writer.writeAll("]\n"); + try bw.flush(); + + log.info(.app, "saved cookies to file", .{ .path = path, .count = jar.cookies.items.len }); +} + +const JsonCookie = struct { + name: []const u8, + value: []const u8, + domain: []const u8, + path: ?[]const u8 = "/", + expires: ?f64 = null, + secure: ?bool = null, + httpOnly: ?bool = null, +}; diff --git a/src/lightpanda.zig b/src/lightpanda.zig index b0356e93..64308f91 100644 --- a/src/lightpanda.zig +++ b/src/lightpanda.zig @@ -40,6 +40,7 @@ pub const forms = @import("browser/forms.zig"); pub const actions = @import("browser/actions.zig"); pub const structured_data = @import("browser/structured_data.zig"); pub const mcp = @import("mcp.zig"); +pub const cookies = @import("cookies.zig"); pub const build_config = @import("build_config"); pub const crash_handler = @import("crash_handler.zig"); @@ -66,6 +67,19 @@ pub fn fetch(app: *App, url: [:0]const u8, opts: FetchOpts) !void { defer browser.deinit(); var session = try browser.newSession(notification); + + // Load cookies from file if --cookies-file was specified, save on exit. + if (app.config.cookiesFile()) |cookies_path| { + cookies.loadFromFile(&session.cookie_jar, cookies_path) catch |err| { + log.err(.app, "cookie load error", .{ .err = err }); + }; + defer { + cookies.saveToFile(&session.cookie_jar, cookies_path) catch |err| { + log.err(.app, "cookie save error", .{ .err = err }); + }; + } + } + const page = try session.createPage(); // // Comment this out to get a profile of the JS code in v8/profile.json.