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) <noreply@anthropic.com>
This commit is contained in:
Matt Van Horn
2026-04-09 19:41:54 -07:00
committed by Pierre Tachoire
parent 87a48dea28
commit 4d384dfe01
3 changed files with 157 additions and 0 deletions

View File

@@ -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;
}

120
src/cookies.zig Normal file
View File

@@ -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 <https://www.gnu.org/licenses/>.
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,
};

View File

@@ -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.