From 41cd1cd73401ca6bc3bbd74113269e3c4669710f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Thu, 23 Jul 2026 14:48:42 +0200 Subject: [PATCH 01/21] agent: support subscription auth via Claude Code --- build.zig.zon | 4 +- src/agent/Agent.zig | 122 ++++++++++++++++++----- src/agent/auth/anthropic.zig | 64 +++++++++++++ src/agent/auth/auth.zig | 176 ++++++++++++++++++++++++++++++++++ src/agent/auth/models_dev.zig | 125 ++++++++++++++++++++++++ src/agent/settings.zig | 79 +++++++++++++-- src/browser/tools.zig | 2 +- 7 files changed, 538 insertions(+), 34 deletions(-) create mode 100644 src/agent/auth/anthropic.zig create mode 100644 src/agent/auth/auth.zig create mode 100644 src/agent/auth/models_dev.zig diff --git a/build.zig.zon b/build.zig.zon index 725fbed4d..00c2eeac8 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -36,8 +36,8 @@ .hash = "sqlite3-3.53.2-DMxLWuAOAAA_Px0arJOIOaP4AKEu5prbsQgPMA35W1zz", }, .zenai = .{ - .url = "git+https://github.com/lightpanda-io/zenai.git#00c793fdd2797ae5fc9700c189d8617f167ae0dc", - .hash = "zenai-0.0.0-iOY_VGOnBQDaWE_Gn8cwhh0XXE8RZ8ESE7QtcITF-P73", + .url = "git+https://github.com/lightpanda-io/zenai.git#fce390119e2a4bb5d33f55579c6f6a79dfb245f6", + .hash = "zenai-0.0.0-iOY_VHzrBQBkTrBexUzTVhDNWGLNA7Vh2eoPuMnlUVZS", }, .isocline = .{ .url = "git+https://github.com/arrufat/isocline?ref=lightpanda#832a9fe25f5f4458fcc47b5acc7c21db669c2f47", diff --git a/src/agent/Agent.zig b/src/agent/Agent.zig index c2481a919..d270e8a52 100644 --- a/src/agent/Agent.zig +++ b/src/agent/Agent.zig @@ -38,6 +38,8 @@ const Terminal = @import("Terminal.zig"); const ansi = @import("ansi.zig"); const SlashCommand = @import("SlashCommand.zig"); const settings = @import("settings.zig"); +const auth = @import("auth/auth.zig"); +const models_dev = @import("auth/models_dev.zig"); const picker = @import("picker.zig"); const save = @import("save.zig"); const welcome = @import("welcome.zig"); @@ -136,6 +138,12 @@ model_credentials: ?Credentials, /// Allocated credentials key (Vertex gcloud token) — other keys are unowned /// env pointers. The AI client references it: free only after client deinit. owned_key: ?[:0]const u8, +/// Active subscription (bearer) auth. Owns `model_credentials.key`, so it must +/// outlive the AI client; refreshed between turns by `refreshAuthIfNeeded`. +auth_session: ?auth.Session, +/// App data dir (owned by `App`, which outlives the agent), used to cache the +/// models.dev catalog. Null when no data dir is available. +app_dir: ?[]const u8, /// True when the no-LLM state is a persisted preference (remembered null /// provider or runtime `/provider null`), so `reportSaved` writes /// `provider = null`. A transient `--no-llm` run leaves it false so saving @@ -262,9 +270,10 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent const banner_before = will_repl and (!resolve or settings.hasDetectableKey(opts, remembered)); if (banner_before) welcome.print(resolve); - const resolved: ?settings.ResolvedProvider = if (resolve) try settings.resolveCredentials(allocator, opts, remembered, will_repl) else null; + var resolved: ?settings.ResolvedProvider = if (resolve) try settings.resolveCredentials(allocator, opts, remembered, will_repl) else null; // Before the ai_client errdefer, so on unwind the client goes first. errdefer if (resolved) |r| if (r.key_owned) allocator.free(r.credentials.key); + errdefer if (resolved) |*r| if (r.session) |*s| s.deinit(); if (will_repl and !banner_before and resolved != null) welcome.print(resolve); const llm: ?Credentials = if (resolved) |r| r.credentials else null; @@ -321,6 +330,8 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent .ai_client = null, .model_credentials = llm, .owned_key = if (resolved) |r| (if (r.key_owned) r.credentials.key else null) else null, + .auth_session = if (resolved) |r| r.session else null, + .app_dir = app.app_dir_path, .no_llm_persisted = remembered_no_llm, .model_base_url = opts.base_url, .model_completions = null, @@ -353,7 +364,7 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent try self.startSession(); - self.ai_client = if (llm) |l| try zenai.provider.Client.init(allocator, lp.io, l, .{ .base_url = opts.base_url, .retry_policy = .long_running, .bill_to = hfBillTo(l.provider), .environ = lp.environ() }) else null; + self.ai_client = if (llm) |l| try zenai.provider.Client.init(lp.io, allocator, l, .{ .base_url = opts.base_url, .retry_policy = .long_running, .bill_to = hfBillTo(l.provider), .environ = lp.environ() }) else null; errdefer if (self.ai_client) |c| c.deinit(allocator); if (self.ai_client) |c| c.setInterrupt(&self.http_interrupt); @@ -382,6 +393,7 @@ pub fn deinit(self: *Agent) void { self.browser.deinit(); self.notification.deinit(); if (self.ai_client) |ai_client| ai_client.deinit(self.allocator); + if (self.auth_session) |*s| s.deinit(); if (self.owned_key) |k| self.allocator.free(k); self.allocator.free(self.model); for (self.available_providers) |p| self.allocator.free(p); @@ -905,9 +917,11 @@ fn handleProvider(self: *Agent, _: std.mem.Allocator, rest: []const u8) void { self.terminal.printError("unknown provider: {s} (or 'null' to disable the LLM)", .{trimmed}); return; }; - // Re-selecting vertex falls through — that's the token-refresh path. + // Re-selecting vertex (token refresh) or a subscription provider (re-import + // after logging into its CLI) falls through instead of no-op'ing. const vertex_project = provider == .vertex and settings.vertexProjectMode(); - if (self.model_credentials) |current| if (provider == current.provider and !vertex_project) { + const subscription = auth.descriptorFor(provider) != null; + if (self.model_credentials) |current| if (provider == current.provider and !vertex_project and !subscription) { self.terminal.printInfo("provider: {s}", .{@tagName(provider)}); return; }; @@ -916,17 +930,33 @@ fn handleProvider(self: *Agent, _: std.mem.Allocator, rest: []const u8) void { self.terminal.printError("could not obtain a Vertex access token: {s} (details above)", .{@errorName(err)}); return; }; - self.setProvider(.{ .provider = .vertex, .key = token }, token) catch |err| { + self.setProvider(.{ .provider = .vertex, .key = token }, token, null) catch |err| { self.allocator.free(token); self.terminal.printError("failed to set provider: {s}", .{@errorName(err)}); }; return; } + // Subscription takes priority; on no importable credential, fall through to + // the API-key path. + if (subscription) { + if (auth.sessionFor(self.allocator, provider) catch null) |session| { + var owned = session; + self.setProvider(.{ .provider = provider, .key = owned.tokens.access_token, .auth = .bearer }, null, owned) catch |err| { + owned.deinit(); + self.terminal.printError("failed to set provider: {s}", .{@errorName(err)}); + }; + return; + } + } const key = zenai.provider.envApiKey(lp.environ(), provider) orelse { if (provider == .vertex) { self.terminal.printError("vertex needs VERTEX_API_KEY (express mode) or GOOGLE_CLOUD_PROJECT (project mode, token via gcloud)", .{}); return; } + if (subscription) { + self.terminal.printError("no API key or subscription for {s}; set {s} or log into Claude Code", .{ @tagName(provider), zenai.provider.envVarName(provider) }); + return; + } self.terminal.printError("no API key for {s}; set {s}", .{ @tagName(provider), zenai.provider.envVarName(provider) }); return; }; @@ -939,7 +969,7 @@ fn handleProvider(self: *Agent, _: std.mem.Allocator, rest: []const u8) void { self.terminal.printError("no llama.cpp server with a loaded model at {s}", .{self.model_base_url orelse zenai.provider.llama_cpp_default_base_url}); return; } - self.setProvider(.{ .provider = provider, .key = key }, null) catch |err| { + self.setProvider(.{ .provider = provider, .key = key }, null, null) catch |err| { self.terminal.printError("failed to set provider: {s}", .{@errorName(err)}); }; } @@ -966,9 +996,11 @@ fn hfBillTo(provider: Config.AiProvider) ?[]const u8 { } /// `owned_key` transfers ownership of an allocated `credentials.key` (Vertex -/// gcloud token) on success; on error the caller still owns it. -fn setProvider(self: *Agent, credentials: Credentials, owned_key: ?[:0]const u8) !void { - const new_client = try zenai.provider.Client.init(self.allocator, lp.io, credentials, .{ .base_url = self.model_base_url, .retry_policy = .long_running, .bill_to = hfBillTo(credentials.provider), .environ = lp.environ() }); +/// gcloud token) on success; on error the caller still owns it. `session` +/// likewise transfers a subscription session that owns `credentials.key`; the +/// previous session is freed only after the old client is gone. +fn setProvider(self: *Agent, credentials: Credentials, owned_key: ?[:0]const u8, session: ?auth.Session) !void { + const new_client = try zenai.provider.Client.init(lp.io, self.allocator, credentials, .{ .base_url = self.model_base_url, .retry_policy = .long_running, .bill_to = hfBillTo(credentials.provider), .environ = lp.environ() }); errdefer new_client.deinit(self.allocator); // A same-provider re-select (vertex token refresh) must not reset the model. @@ -976,7 +1008,9 @@ fn setProvider(self: *Agent, credentials: Credentials, owned_key: ?[:0]const u8) const new_model = try self.allocator.dupe(u8, if (same_provider) self.model else zenai.provider.defaultModel(credentials.provider)); if (self.ai_client) |client| client.deinit(self.allocator); if (self.owned_key) |k| self.allocator.free(k); + if (self.auth_session) |*s| s.deinit(); self.owned_key = owned_key; + self.auth_session = session; new_client.setInterrupt(&self.http_interrupt); self.ai_client = new_client; self.model_credentials = credentials; @@ -984,13 +1018,38 @@ fn setProvider(self: *Agent, credentials: Credentials, owned_key: ?[:0]const u8) self.model_completions = null; self.allocator.free(self.model); self.model = new_model; - self.terminal.printInfo("provider: {s}", .{@tagName(credentials.provider)}); + if (credentials.auth == .bearer) { + const label = if (auth.descriptorFor(credentials.provider)) |d| d.label else "subscription"; + self.terminal.printInfo("provider: {s} ({s})", .{ @tagName(credentials.provider), label }); + } else { + self.terminal.printInfo("provider: {s}", .{@tagName(credentials.provider)}); + } if (zenai.provider.defaultEffort(credentials.provider)) |e| if (e != self.effort) { self.effort = e; self.terminal.printInfo("effort: {s} ({s} default)", .{ @tagName(e), @tagName(credentials.provider) }); }; self.reportSaved("model", self.model); - _ = completionModels(self, self.allocator); + // Priming warms the completion cache; skip it for bearer, whose catalog is a + // multi-MB models.dev download best deferred to first `/model` use. + if (credentials.auth != .bearer) _ = completionModels(self, self.allocator); +} + +/// Keep a subscription (bearer) token current before a model request: when the +/// active session's token is near expiry, re-import from the source and repoint +/// the client and credentials at the fresh token. A no-op for API-key auth. +/// Runs between turns on the single agent thread, never during a request, so the +/// old buffer (held one cycle by `auth.Session`) is never dereferenced freed. +fn refreshAuthIfNeeded(self: *Agent) void { + if (self.auth_session) |*session| { + const new_token = session.ensureFresh() catch |err| { + self.terminal.printError("could not refresh the Claude subscription token: {s}", .{@errorName(err)}); + return; + }; + if (new_token) |tok| { + if (self.ai_client) |client| client.setApiKey(tok); + if (self.model_credentials) |*c| c.key = tok; + } + } } const PathAndMode = struct { path: []const u8, mode: save.Mode }; @@ -1157,6 +1216,7 @@ fn saveOneShot(self: *Agent) void { /// interactive `/save` and one-shot `--save`. fn synthesizeSaveTo(self: *Agent, arena: std.mem.Allocator, path: []const u8, mode: save.Mode, prompt: ?[]const u8) void { const provider_client = self.ai_client.?; + self.refreshAuthIfNeeded(); // Only update feeds the saved script back to the model; append stays // blind — the script is synthesized from this session alone and written @@ -1599,6 +1659,7 @@ fn processUserMessage(self: *Agent, input: TurnInput) !?[]const u8 { } const provider_client = self.ai_client orelse return error.NoAiClient; + self.refreshAuthIfNeeded(); self.terminal.spinner.start(); var result = provider_client.runTools( @@ -1839,13 +1900,24 @@ pub fn listModels(allocator: std.mem.Allocator, opts: Config.Agent) !void { }); return error.ConflictingFlags; } - const resolved = (try settings.resolveCredentials(allocator, opts, null, false)) orelse return error.MissingProvider; + var resolved = (try settings.resolveCredentials(allocator, opts, null, false)) orelse return error.MissingProvider; const llm = resolved.credentials; defer if (resolved.key_owned) allocator.free(llm.key); + defer if (resolved.session) |*s| s.deinit(); var arena: std.heap.ArenaAllocator = .init(allocator); defer arena.deinit(); - const ids = zenai.provider.listChatModelIds(allocator, lp.io, arena.allocator(), llm.provider, llm.key, opts.base_url, lp.environ()) catch |err| { + // A subscription (bearer) token can't list models via the provider; use the + // free models.dev catalog (uncached here — this is a one-shot CLI command). + if (llm.auth == .bearer) { + const sub_ids = models_dev.modelIds(arena.allocator(), @tagName(llm.provider), null); + var stdout_sub = std.Io.File.stdout().writerStreaming(lp.io, &.{}); + const ws = &stdout_sub.interface; + for (sub_ids) |id| try ws.print("{s}\n", .{id}); + try ws.flush(); + return; + } + const ids = zenai.provider.listChatModelIds(lp.io, allocator, arena.allocator(), llm.provider, llm.key, .{ .base_url = opts.base_url, .environ = lp.environ() }) catch |err| { if (llm.provider == .vertex and !settings.vertexProjectMode()) { std.debug.print("Vertex express mode cannot list models (the endpoint requires OAuth); set GOOGLE_CLOUD_PROJECT for project mode.\n", .{}); } @@ -1901,15 +1973,21 @@ fn completionModels(context: *anyopaque, _: std.mem.Allocator) []const []const u if (self.model_completions) |c| if (c.provider == llm.provider) return c.ids; _ = self.model_completion_arena.reset(.retain_capacity); - const ids = zenai.provider.listChatModelIds( - self.allocator, - lp.io, - self.model_completion_arena.allocator(), - llm.provider, - llm.key, - self.model_base_url, - lp.environ(), - ) catch &.{}; + const arena = self.model_completion_arena.allocator(); + // A subscription (bearer) token can't hit the provider's `/models` endpoint + // (it 401s), so list from the free, unauthenticated models.dev catalog + // instead — no API key needed. + const ids = if (llm.auth == .bearer) + models_dev.modelIds(arena, @tagName(llm.provider), self.app_dir) + else + zenai.provider.listChatModelIds( + lp.io, + self.allocator, + arena, + llm.provider, + llm.key, + .{ .base_url = self.model_base_url, .environ = lp.environ() }, + ) catch &.{}; self.model_completions = .{ .provider = llm.provider, .ids = ids }; return ids; } diff --git a/src/agent/auth/anthropic.zig b/src/agent/auth/anthropic.zig new file mode 100644 index 000000000..679437f7f --- /dev/null +++ b/src/agent/auth/anthropic.zig @@ -0,0 +1,64 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// 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 . + +//! Anthropic subscription-auth descriptor plus the Claude Code credential +//! importer. v1 reuses the OAuth token Claude Code already maintains at +//! `$HOME/.claude/.credentials.json`; the login/refresh-endpoint fields on the +//! descriptor are the seam for the deferred own-OAuth flow and are unused today. + +const std = @import("std"); +const lp = @import("lightpanda"); +const auth = @import("auth.zig"); + +pub const descriptor: auth.Descriptor = .{ + .provider = .anthropic, + .id = "anthropic", + .label = "Claude subscription", + .authorize_url = "https://claude.ai/oauth/authorize", + .token_url = "https://console.anthropic.com/v1/oauth/token", + .client_id = "9d1c250a-e61b-44d9-88ed-5944d1962f5e", + .redirect_uri = "https://console.anthropic.com/oauth/code/callback", + .scope = "org:create_api_key user:profile user:inference", + .importFn = importClaudeCode, +}; + +/// Read Claude Code's OAuth credentials from `$HOME/.claude/.credentials.json`. +/// Returns null when the file is absent, unreadable, or malformed (the caller +/// falls back to API-key auth). Tokens are duped into `allocator`. +pub fn importClaudeCode(allocator: std.mem.Allocator) !?auth.TokenSet { + const home = std.c.getenv("HOME") orelse return null; + const path = try std.fs.path.join(allocator, &.{ std.mem.span(home), ".claude", ".credentials.json" }); + defer allocator.free(path); + + const data = std.Io.Dir.cwd().readFileAlloc(lp.io, path, allocator, .limited(64 * 1024)) catch return null; + defer allocator.free(data); + + const Shape = struct { + claudeAiOauth: ?struct { + accessToken: []const u8, + refreshToken: []const u8 = "", + expiresAt: i64 = 0, + } = null, + }; + const parsed = std.json.parseFromSlice(Shape, allocator, data, .{ .ignore_unknown_fields = true }) catch return null; + defer parsed.deinit(); + + const oauth = parsed.value.claudeAiOauth orelse return null; + if (oauth.accessToken.len == 0) return null; + return try auth.TokenSet.dup(allocator, oauth.accessToken, oauth.refreshToken, oauth.expiresAt); +} diff --git a/src/agent/auth/auth.zig b/src/agent/auth/auth.zig new file mode 100644 index 000000000..e66c45da7 --- /dev/null +++ b/src/agent/auth/auth.zig @@ -0,0 +1,176 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// 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 . + +//! Provider-agnostic subscription (OAuth bearer) auth for the agent. v1 imports +//! the token a subscription CLI already maintains on disk — Claude Code for +//! `.anthropic` — and re-reads it on expiry, so the agent never calls the OAuth +//! token endpoint and cannot disturb that CLI's own login. The `Descriptor`'s +//! login-endpoint fields are the seam for a future own-OAuth login flow. + +const std = @import("std"); +const lp = @import("lightpanda"); +const Config = lp.Config; +const anthropic = @import("anthropic.zig"); + +/// Wall-clock ms since the Unix epoch. +pub fn nowMs() i64 { + return std.Io.Clock.now(.real, lp.io).toMilliseconds(); +} + +/// An OAuth credential set. `refresh_token`/`expires_at_ms` back expiry handling +/// (and the deferred refresh grant). +pub const TokenSet = struct { + access_token: [:0]const u8, + refresh_token: []const u8, + expires_at_ms: i64, + + pub fn dup(allocator: std.mem.Allocator, access: []const u8, refresh: []const u8, expires_at_ms: i64) !TokenSet { + const a = try allocator.dupeZ(u8, access); + errdefer allocator.free(a); + const r = try allocator.dupe(u8, refresh); + return .{ .access_token = a, .refresh_token = r, .expires_at_ms = expires_at_ms }; + } + + pub fn deinit(self: TokenSet, allocator: std.mem.Allocator) void { + allocator.free(self.access_token); + allocator.free(self.refresh_token); + } +}; + +/// Static per-provider OAuth configuration. Adding a provider is a data addition +/// here plus an `importFn`. The login-endpoint fields are the seam for the +/// deferred own-OAuth login flow and are unused in v1. +pub const Descriptor = struct { + provider: Config.AiProvider, + id: []const u8, + /// Human label for the credential, e.g. "Claude subscription". + label: []const u8, + authorize_url: []const u8, + token_url: []const u8, + client_id: []const u8, + redirect_uri: []const u8, + scope: []const u8, + /// Import a subscription token another CLI already maintains on disk. + importFn: ?*const fn (std.mem.Allocator) anyerror!?TokenSet = null, +}; + +pub const registry = [_]*const Descriptor{&anthropic.descriptor}; + +pub fn descriptorFor(provider: Config.AiProvider) ?*const Descriptor { + for (registry) |d| if (d.provider == provider) return d; + return null; +} + +/// Proactive-refresh margin: re-check the source once the token is within this +/// window of expiry, so a turn never starts on a token about to lapse. +const refresh_skew_ms: i64 = 5 * std.time.ms_per_min; + +/// A live subscription credential for one provider. Owns its `TokenSet`; the AI +/// client borrows `tokens.access_token`, so the session must outlive the client. +pub const Session = struct { + allocator: std.mem.Allocator, + descriptor: *const Descriptor, + tokens: TokenSet, + /// The immediately-prior token, kept alive one refresh cycle so a client + /// still pointing at it (until `setApiKey`) never dereferences freed memory. + previous: ?TokenSet = null, + + /// When the access token is within `refresh_skew_ms` of expiry, re-import + /// from the source and, if the source has a newer token, adopt it and return + /// the new access token (owned by the session). Returns null when nothing + /// changed. The caller must repoint its client with the returned token + /// before its next request; the old buffer stays valid until the following + /// `ensureFresh`/`deinit`. Errors `SubscriptionTokenExpired` when the token + /// has lapsed and the source has no fresher one. + pub fn ensureFresh(self: *Session) !?[:0]const u8 { + const now = nowMs(); + if (self.tokens.expires_at_ms - now > refresh_skew_ms) return null; + + const importFn = self.descriptor.importFn orelse return self.staleResult(now); + const fresh = (try importFn(self.allocator)) orelse return self.staleResult(now); + // The source (e.g. Claude Code) hasn't refreshed yet. + if (fresh.expires_at_ms <= self.tokens.expires_at_ms) { + fresh.deinit(self.allocator); + return self.staleResult(now); + } + + if (self.previous) |p| p.deinit(self.allocator); + self.previous = self.tokens; + self.tokens = fresh; + return self.tokens.access_token; + } + + fn staleResult(self: *Session, now: i64) error{SubscriptionTokenExpired}!?[:0]const u8 { + return if (self.tokens.expires_at_ms > now) null else error.SubscriptionTokenExpired; + } + + pub fn deinit(self: *Session) void { + self.tokens.deinit(self.allocator); + if (self.previous) |p| p.deinit(self.allocator); + self.previous = null; + } +}; + +/// Build a bearer session for `provider` from an available subscription +/// credential, or null when none is importable. +pub fn sessionFor(allocator: std.mem.Allocator, provider: Config.AiProvider) !?Session { + const desc = descriptorFor(provider) orelse return null; + const importFn = desc.importFn orelse return null; + const tokens = (try importFn(allocator)) orelse return null; + return .{ .allocator = allocator, .descriptor = desc, .tokens = tokens }; +} + +/// Process-lifetime memo for `subscriptionAvailable`: the credential file is +/// probed several times across a single startup resolution, and it doesn't +/// change under us there (runtime `/provider` re-import goes through `sessionFor`, +/// which always reads fresh). +var availability_cache: std.enums.EnumArray(Config.AiProvider, ?bool) = .initFill(null); + +/// Is a usable (present, not hard-expired) subscription token importable for +/// `provider`? Lets the picker offer a subscription without its API-key env var, +/// and not offer it when no credential exists. +pub fn subscriptionAvailable(provider: Config.AiProvider) bool { + if (availability_cache.get(provider)) |cached| return cached; + const result = probeSubscription(provider); + availability_cache.set(provider, result); + return result; +} + +fn probeSubscription(provider: Config.AiProvider) bool { + const desc = descriptorFor(provider) orelse return false; + const importFn = desc.importFn orelse return false; + var arena: std.heap.ArenaAllocator = .init(std.heap.page_allocator); + defer arena.deinit(); + const tokens = (importFn(arena.allocator()) catch return false) orelse return false; + // expires_at_ms == 0 means "unknown"; let a live request be the judge. + return tokens.expires_at_ms == 0 or tokens.expires_at_ms > nowMs(); +} + +test "TokenSet dup/deinit round-trips and is leak-free" { + const a = std.testing.allocator; + const t = try TokenSet.dup(a, "acc", "ref", 123); + defer t.deinit(a); + try std.testing.expectEqualStrings("acc", t.access_token); + try std.testing.expectEqualStrings("ref", t.refresh_token); + try std.testing.expectEqual(@as(i64, 123), t.expires_at_ms); +} + +test "descriptorFor resolves anthropic, null for a non-OAuth provider" { + try std.testing.expect(descriptorFor(.anthropic) != null); + try std.testing.expectEqual(@as(?*const Descriptor, null), descriptorFor(.openai)); +} diff --git a/src/agent/auth/models_dev.zig b/src/agent/auth/models_dev.zig new file mode 100644 index 000000000..2a44f4d2d --- /dev/null +++ b/src/agent/auth/models_dev.zig @@ -0,0 +1,125 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// 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 . + +//! Model catalog from models.dev — the free, unauthenticated registry opencode +//! uses. It lets a subscription (bearer) user list a provider's models without +//! an API key, and stays current upstream instead of drifting like a hardcoded +//! list. The full `api.json` is a few MB, so only each provider's id list is +//! cached on disk (`/models-dev-.json`) with a TTL; the big +//! payload is parsed transiently on refresh. + +const std = @import("std"); +const lp = @import("lightpanda"); +const auth = @import("auth.zig"); + +const api_url = "https://models.dev/api.json"; +const cache_ttl_ms: i64 = 24 * std.time.ms_per_hour; + +/// Chat-model ids for `provider_id` from models.dev, allocated in `arena`. Reads +/// a fresh on-disk cache when present, else fetches and refreshes it. Returns an +/// empty slice on any failure (offline, parse error, no cache) so callers +/// degrade to accepting a typed-in model name. +pub fn modelIds(arena: std.mem.Allocator, provider_id: []const u8, app_dir: ?[]const u8) []const []const u8 { + if (app_dir) |dir| { + if (readCache(arena, dir, provider_id)) |ids| return ids; + } + const catalog = fetch(arena) catch return &.{}; + const ids = parseProviderModels(arena, catalog, provider_id) catch return &.{}; + if (app_dir) |dir| writeCache(dir, provider_id, ids) catch {}; + return ids; +} + +const Cache = struct { + fetched_ms: i64, + ids: []const []const u8, +}; + +fn cachePath(arena: std.mem.Allocator, app_dir: []const u8, provider_id: []const u8) ![]const u8 { + const name = try std.fmt.allocPrint(arena, "models-dev-{s}.json", .{provider_id}); + return std.fs.path.join(arena, &.{ app_dir, name }); +} + +fn readCache(arena: std.mem.Allocator, app_dir: []const u8, provider_id: []const u8) ?[]const []const u8 { + const path = cachePath(arena, app_dir, provider_id) catch return null; + const data = std.Io.Dir.cwd().readFileAlloc(lp.io, path, arena, .limited(1024 * 1024)) catch return null; + const parsed = std.json.parseFromSliceLeaky(Cache, arena, data, .{ .ignore_unknown_fields = true }) catch return null; + if (auth.nowMs() - parsed.fetched_ms > cache_ttl_ms) return null; + if (parsed.ids.len == 0) return null; + return parsed.ids; +} + +fn writeCache(app_dir: []const u8, provider_id: []const u8, ids: []const []const u8) !void { + var arena: std.heap.ArenaAllocator = .init(std.heap.page_allocator); + defer arena.deinit(); + const a = arena.allocator(); + const path = try cachePath(a, app_dir, provider_id); + + var buf: std.Io.Writer.Allocating = .init(a); + try std.json.Stringify.value(Cache{ .fetched_ms = auth.nowMs(), .ids = ids }, .{}, &buf.writer); + try std.Io.Dir.cwd().writeFile(lp.io, .{ .sub_path = path, .data = buf.written() }); +} + +/// Model-id keys of `catalog[provider_id].models`. `ignore_unknown_fields` skips +/// every provider and model's metadata, so only the id strings are built — no +/// Value tree for the multi-MB payload. The parse runs in a scoped arena; the +/// ids are duped into `arena`. +fn parseProviderModels(arena: std.mem.Allocator, catalog: []const u8, provider_id: []const u8) ![]const []const u8 { + var parse_arena: std.heap.ArenaAllocator = .init(arena); + defer parse_arena.deinit(); + + const Empty = struct {}; + const Provider = struct { models: std.json.ArrayHashMap(Empty) = .{} }; + const parsed = try std.json.parseFromSliceLeaky(std.json.ArrayHashMap(Provider), parse_arena.allocator(), catalog, .{ .ignore_unknown_fields = true }); + + const provider = parsed.map.get(provider_id) orelse return error.ProviderMissing; + const keys = provider.models.map.keys(); + const ids = try arena.alloc([]const u8, keys.len); + for (keys, ids) |k, *dst| dst.* = try arena.dupe(u8, k); + return ids; +} + +fn fetch(arena: std.mem.Allocator) ![]u8 { + var client: std.http.Client = .{ .allocator = arena, .io = lp.io }; + defer client.deinit(); + + const uri = try std.Uri.parse(api_url); + var req = try client.request(.GET, uri, .{ .redirect_behavior = @enumFromInt(3) }); + defer req.deinit(); + try req.sendBodiless(); + + var redirect_buffer: [8 * 1024]u8 = undefined; + var response = try req.receiveHead(&redirect_buffer); + if (response.head.status != .ok) return error.HttpStatus; + + const decompress_buffer: []u8 = switch (response.head.content_encoding) { + .identity => &.{}, + .zstd => try arena.alloc(u8, std.compress.zstd.default_window_len), + .deflate, .gzip => try arena.alloc(u8, std.compress.flate.max_window_len), + .compress => return error.UnsupportedCompression, + }; + var transfer_buffer: [4096]u8 = undefined; + var decompress: std.http.Decompress = undefined; + const reader = response.readerDecompressing(&transfer_buffer, &decompress, decompress_buffer); + + var out: std.Io.Writer.Allocating = .init(arena); + _ = reader.streamRemaining(&out.writer) catch |err| switch (err) { + error.ReadFailed => return response.bodyErr().?, + else => |e| return e, + }; + return out.written(); +} diff --git a/src/agent/settings.zig b/src/agent/settings.zig index f819758dd..ff1bdf770 100644 --- a/src/agent/settings.zig +++ b/src/agent/settings.zig @@ -28,9 +28,10 @@ const lp = @import("lightpanda"); const Config = lp.Config; const picker = @import("picker.zig"); const string = @import("../string.zig"); +const auth = @import("auth/auth.zig"); const Credentials = zenai.provider.Credentials; -pub const api_keys_hint = "ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_API_KEY, HF_TOKEN, AI_GATEWAY_API_KEY, or MISTRAL_API_KEY (Vertex AI: VERTEX_API_KEY, or GOOGLE_CLOUD_PROJECT via gcloud)"; +pub const api_keys_hint = "ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_API_KEY, HF_TOKEN, AI_GATEWAY_API_KEY, or MISTRAL_API_KEY (Vertex AI: VERTEX_API_KEY, or GOOGLE_CLOUD_PROJECT via gcloud; Anthropic: a Claude subscription from Claude Code)"; /// Determine which provider to use and read its env key. Returns null /// only when no `--provider` was given AND no env key exists (the caller @@ -41,6 +42,10 @@ pub const ResolvedProvider = struct { /// Key allocated (Vertex gcloud token) rather than an env pointer; the /// caller frees it, only after the client that references it is gone. key_owned: bool = false, + /// Set for subscription (bearer) auth. Owns `credentials.key`, so it must + /// outlive the AI client; the caller takes ownership. When set, `key_owned` + /// stays false — the session frees the key, not the caller directly. + session: ?auth.Session = null, }; /// Probe a keyless local provider (Ollama, llama.cpp): its env key is a @@ -50,7 +55,7 @@ pub fn detectLocalProvider(allocator: std.mem.Allocator, tag: Config.AiProvider, const key = zenai.provider.envApiKey(lp.environ(), tag) orelse return null; var arena: std.heap.ArenaAllocator = .init(allocator); defer arena.deinit(); - const ids = zenai.provider.listChatModelIds(allocator, lp.io, arena.allocator(), tag, key, base_url, lp.environ()) catch return null; + const ids = zenai.provider.listChatModelIds(lp.io, allocator, arena.allocator(), tag, key, .{ .base_url = base_url, .environ = lp.environ() }) catch return null; if (ids.len == 0) return null; return .{ .provider = tag, .key = key }; } @@ -97,10 +102,11 @@ pub fn gcloudAccessToken(allocator: std.mem.Allocator) ![:0]const u8 { /// env-detected). Skips the Ollama probe so it isn't run twice at startup; the /// interactive picker only fires on detected keys, which this still catches. pub fn hasDetectableKey(opts: Config.Agent, remembered: ?Remembered) bool { - if (opts.provider) |p| return zenai.provider.envApiKey(lp.environ(), p) != null or (p == .vertex and vertexProjectMode()); + if (opts.provider) |p| return zenai.provider.envApiKey(lp.environ(), p) != null or (p == .vertex and vertexProjectMode()) or auth.subscriptionAvailable(p); if (remembered) |r| if (r.provider) |p| { if (zenai.provider.envApiKey(lp.environ(), p) != null) return true; if (p == .vertex and vertexProjectMode()) return true; + if (auth.subscriptionAvailable(p)) return true; }; var buf: [zenai.provider.default_candidates.len]Credentials = undefined; return availableProviders(&buf).len > 0; @@ -114,6 +120,9 @@ pub fn resolveCredentials(allocator: std.mem.Allocator, opts: Config.Agent, reme const token = try gcloudAccessToken(allocator); return .{ .credentials = .{ .provider = p, .key = token }, .source = .flag, .key_owned = true }; } + // A subscription takes priority over an API key; null = not a + // subscription provider (or none available), so fall through. + if (try subscriptionResolved(allocator, p, .flag)) |resolved| return resolved; const key = zenai.provider.envApiKey(lp.environ(), p) orelse { if (p == .vertex) { std.debug.print( @@ -137,8 +146,13 @@ pub fn resolveCredentials(allocator: std.mem.Allocator, opts: Config.Agent, reme if (gcloudAccessToken(allocator)) |token| { return .{ .credentials = .{ .provider = p, .key = token }, .source = .remembered, .key_owned = true }; } else |_| {} - } else if (zenai.provider.envApiKey(lp.environ(), p)) |key| { - return .{ .credentials = .{ .provider = p, .key = key }, .source = .remembered }; + } else { + // Subscription takes priority over an API key; both fall through to + // detection on miss. + if (try subscriptionResolved(allocator, p, .remembered)) |resolved| return resolved; + if (zenai.provider.envApiKey(lp.environ(), p)) |key| { + return .{ .credentials = .{ .provider = p, .key = key }, .source = .remembered }; + } } }; @@ -173,16 +187,41 @@ pub fn resolveCredentials(allocator: std.mem.Allocator, opts: Config.Agent, reme return try finishResolved(allocator, found[idx], .picked); } -/// Swaps the placeholder key of a detected project-mode Vertex for a real -/// gcloud token. +/// Swaps a placeholder credential for a live token: a gcloud token for +/// project-mode Vertex, or an imported subscription session for the Anthropic +/// bearer placeholder. fn finishResolved(allocator: std.mem.Allocator, credentials: Credentials, source: @FieldType(ResolvedProvider, "source")) !ResolvedProvider { if (credentials.provider == .vertex and vertexProjectMode()) { const token = try gcloudAccessToken(allocator); return .{ .credentials = .{ .provider = .vertex, .key = token }, .source = source, .key_owned = true }; } + if (credentials.auth == .bearer and credentials.key.len == 0) { + if (try subscriptionResolved(allocator, credentials.provider, source)) |resolved| return resolved; + return error.MissingApiKey; + } return .{ .credentials = credentials, .source = source }; } +/// Import a subscription and wrap it as a resolved bearer credential. The +/// returned `session` owns `credentials.key`; the caller must keep it alive as +/// long as the AI client and free it with `session.deinit`. Null when no +/// subscription is importable. +fn subscriptionResolved(allocator: std.mem.Allocator, provider: Config.AiProvider, source: @FieldType(ResolvedProvider, "source")) !?ResolvedProvider { + const session = (try auth.sessionFor(allocator, provider)) orelse return null; + // Name the credential in effect — a set-but-ignored API key would otherwise + // be a silent surprise. + if (zenai.provider.envApiKey(lp.environ(), provider) != null) { + std.debug.print("{s}: using your {s}; {s} is set but the subscription takes priority.\n", .{ @tagName(provider), session.descriptor.label, zenai.provider.envVarName(provider) }); + } else { + std.debug.print("{s}: using your {s}.\n", .{ @tagName(provider), session.descriptor.label }); + } + return .{ + .credentials = .{ .provider = provider, .key = session.tokens.access_token, .auth = .bearer }, + .source = source, + .session = session, + }; +} + pub const remembered_path = ".lp-agent.zon"; /// Last user-selected provider/model/effort/verbosity, persisted per-directory @@ -234,7 +273,20 @@ pub fn saveRemembered(remembered: Remembered) !void { /// Vertex project mode joins with a placeholder key — no subprocess during a /// scan; the gcloud token is fetched on selection (`finishResolved`). pub fn availableProviders(buf: []Credentials) []Credentials { - const found = zenai.provider.detectKeys(lp.environ(), buf, zenai.provider.default_candidates); + var found = zenai.provider.detectKeys(lp.environ(), buf, zenai.provider.default_candidates); + // A subscription takes priority over an API key: offer it as a bearer + // placeholder (replacing any API-key entry) that `finishResolved` swaps for a + // live token on selection, mirroring Vertex project mode below. + for (auth.registry) |desc| { + if (!auth.subscriptionAvailable(desc.provider)) continue; + const placeholder: Credentials = .{ .provider = desc.provider, .key = "", .auth = .bearer }; + if (indexOfProvider(found, desc.provider)) |i| { + found[i] = placeholder; + } else if (found.len < buf.len) { + buf[found.len] = placeholder; + found = buf[0 .. found.len + 1]; + } + } if (zenai.provider.useVertex(lp.environ()) and vertexProjectMode() and found.len < buf.len) { buf[found.len] = .{ .provider = .vertex, .key = "" }; return buf[0 .. found.len + 1]; @@ -242,6 +294,11 @@ pub fn availableProviders(buf: []Credentials) []Credentials { return found; } +fn indexOfProvider(creds: []const Credentials, provider: Config.AiProvider) ?usize { + for (creds, 0..) |c, i| if (c.provider == provider) return i; + return null; +} + pub fn resolveModelName(opts: Config.Agent, resolved: ?ResolvedProvider, remembered: ?Remembered) []const u8 { if (opts.model) |m| return m; if (resolved) |r| { @@ -300,9 +357,13 @@ pub fn reconcileModel( base_url: ?[:0]const u8, explicit: bool, ) !ReconciledModel { + // A subscription (bearer) token can't list models; trust the desired model + // as-is rather than 401 against `/models`. + if (llm.auth == .bearer) return .{ .use = try allocator.dupe(u8, desired) }; + var arena: std.heap.ArenaAllocator = .init(allocator); defer arena.deinit(); - const ids: []const []const u8 = zenai.provider.listChatModelIds(allocator, lp.io, arena.allocator(), llm.provider, llm.key, base_url, lp.environ()) catch &.{}; + const ids: []const []const u8 = zenai.provider.listChatModelIds(lp.io, allocator, arena.allocator(), llm.provider, llm.key, .{ .base_url = base_url, .environ = lp.environ() }) catch &.{}; if (ids.len == 0 or string.isOneOf(desired, ids)) return .{ .use = try allocator.dupe(u8, desired) }; if (!explicit) { diff --git a/src/browser/tools.zig b/src/browser/tools.zig index 6e295d8a1..8ae9d077e 100644 --- a/src/browser/tools.zig +++ b/src/browser/tools.zig @@ -982,7 +982,7 @@ fn tavilySearch( api_key: []const u8, query: []const u8, ) ![]const u8 { - var client: tavily.Client = .init(arena, lp.io, api_key, .{}); + var client: tavily.Client = .init(lp.io, arena, api_key, .{}); defer client.deinit(); var response = client.search(query, .{ .max_results = 10 }) catch |err| { From e99a5ab99bef22e3d64bc2d8649d6065db09fce0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 27 Jul 2026 11:24:01 +0200 Subject: [PATCH 02/21] agent: pivot subscription auth from Claude Code to Codex Replace the Anthropic Claude-Code-import flow with OpenAI Codex (ChatGPT subscription) OAuth: device-code login, a real token store (auth.json, 0600) with refresh-grant, and JWT ChatGPT-Account-Id extraction. Codex is now a distinct zenai provider, so the bearer-mode credential collapses to provider==.codex; account_id threads to the client via InitOptions. Unit-tested (build green): JWT account-id extraction, token store round-trip, OAuth request-body encoding, token-response parsing. The device-login / refresh network path is unverified pending a subscription. Pins zenai to the codex branch (6967324). --- build.zig.zon | 4 +- src/agent/Agent.zig | 56 +++---- src/agent/auth/anthropic.zig | 64 -------- src/agent/auth/auth.zig | 248 ++++++++++++++++++++-------- src/agent/auth/codex.zig | 304 +++++++++++++++++++++++++++++++++++ src/agent/settings.zig | 31 ++-- 6 files changed, 530 insertions(+), 177 deletions(-) delete mode 100644 src/agent/auth/anthropic.zig create mode 100644 src/agent/auth/codex.zig diff --git a/build.zig.zon b/build.zig.zon index 00c2eeac8..b1c719e60 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -36,8 +36,8 @@ .hash = "sqlite3-3.53.2-DMxLWuAOAAA_Px0arJOIOaP4AKEu5prbsQgPMA35W1zz", }, .zenai = .{ - .url = "git+https://github.com/lightpanda-io/zenai.git#fce390119e2a4bb5d33f55579c6f6a79dfb245f6", - .hash = "zenai-0.0.0-iOY_VHzrBQBkTrBexUzTVhDNWGLNA7Vh2eoPuMnlUVZS", + .url = "git+https://github.com/lightpanda-io/zenai.git?ref=codex#69673248321adeee537dfedcf9c13eed18928c30", + .hash = "zenai-0.0.0-iOY_VP0GBgCClHsRHVmHvSVfYVT33yty_Lb-Qafpb2Gm", }, .isocline = .{ .url = "git+https://github.com/arrufat/isocline?ref=lightpanda#832a9fe25f5f4458fcc47b5acc7c21db669c2f47", diff --git a/src/agent/Agent.zig b/src/agent/Agent.zig index d270e8a52..3dc8ff7b4 100644 --- a/src/agent/Agent.zig +++ b/src/agent/Agent.zig @@ -364,7 +364,7 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent try self.startSession(); - self.ai_client = if (llm) |l| try zenai.provider.Client.init(lp.io, allocator, l, .{ .base_url = opts.base_url, .retry_policy = .long_running, .bill_to = hfBillTo(l.provider), .environ = lp.environ() }) else null; + self.ai_client = if (llm) |l| try zenai.provider.Client.init(lp.io, allocator, l, .{ .base_url = opts.base_url, .retry_policy = .long_running, .bill_to = hfBillTo(l.provider), .environ = lp.environ(), .account_id = if (self.auth_session) |s| s.tokens.account_id else null }) else null; errdefer if (self.ai_client) |c| c.deinit(allocator); if (self.ai_client) |c| c.setInterrupt(&self.http_interrupt); @@ -936,27 +936,25 @@ fn handleProvider(self: *Agent, _: std.mem.Allocator, rest: []const u8) void { }; return; } - // Subscription takes priority; on no importable credential, fall through to - // the API-key path. - if (subscription) { - if (auth.sessionFor(self.allocator, provider) catch null) |session| { - var owned = session; - self.setProvider(.{ .provider = provider, .key = owned.tokens.access_token, .auth = .bearer }, null, owned) catch |err| { - owned.deinit(); - self.terminal.printError("failed to set provider: {s}", .{@errorName(err)}); - }; - return; - } + // Subscription provider: use the stored session, or run the interactive + // login (device-code flow) when there's none yet. + if (auth.descriptorFor(provider)) |desc| { + var owned = (auth.sessionFor(self.allocator, provider) catch null) orelse + (auth.login(self.allocator, desc) catch |err| { + self.terminal.printError("{s} login failed: {s}", .{ desc.label, @errorName(err) }); + return; + }); + self.setProvider(.{ .provider = provider, .key = owned.tokens.access_token }, null, owned) catch |err| { + owned.deinit(); + self.terminal.printError("failed to set provider: {s}", .{@errorName(err)}); + }; + return; } const key = zenai.provider.envApiKey(lp.environ(), provider) orelse { if (provider == .vertex) { self.terminal.printError("vertex needs VERTEX_API_KEY (express mode) or GOOGLE_CLOUD_PROJECT (project mode, token via gcloud)", .{}); return; } - if (subscription) { - self.terminal.printError("no API key or subscription for {s}; set {s} or log into Claude Code", .{ @tagName(provider), zenai.provider.envVarName(provider) }); - return; - } self.terminal.printError("no API key for {s}; set {s}", .{ @tagName(provider), zenai.provider.envVarName(provider) }); return; }; @@ -1000,7 +998,7 @@ fn hfBillTo(provider: Config.AiProvider) ?[]const u8 { /// likewise transfers a subscription session that owns `credentials.key`; the /// previous session is freed only after the old client is gone. fn setProvider(self: *Agent, credentials: Credentials, owned_key: ?[:0]const u8, session: ?auth.Session) !void { - const new_client = try zenai.provider.Client.init(lp.io, self.allocator, credentials, .{ .base_url = self.model_base_url, .retry_policy = .long_running, .bill_to = hfBillTo(credentials.provider), .environ = lp.environ() }); + const new_client = try zenai.provider.Client.init(lp.io, self.allocator, credentials, .{ .base_url = self.model_base_url, .retry_policy = .long_running, .bill_to = hfBillTo(credentials.provider), .environ = lp.environ(), .account_id = if (session) |s| s.tokens.account_id else null }); errdefer new_client.deinit(self.allocator); // A same-provider re-select (vertex token refresh) must not reset the model. @@ -1018,9 +1016,8 @@ fn setProvider(self: *Agent, credentials: Credentials, owned_key: ?[:0]const u8, self.model_completions = null; self.allocator.free(self.model); self.model = new_model; - if (credentials.auth == .bearer) { - const label = if (auth.descriptorFor(credentials.provider)) |d| d.label else "subscription"; - self.terminal.printInfo("provider: {s} ({s})", .{ @tagName(credentials.provider), label }); + if (auth.descriptorFor(credentials.provider)) |d| { + self.terminal.printInfo("provider: {s} ({s})", .{ @tagName(credentials.provider), d.label }); } else { self.terminal.printInfo("provider: {s}", .{@tagName(credentials.provider)}); } @@ -1029,9 +1026,9 @@ fn setProvider(self: *Agent, credentials: Credentials, owned_key: ?[:0]const u8, self.terminal.printInfo("effort: {s} ({s} default)", .{ @tagName(e), @tagName(credentials.provider) }); }; self.reportSaved("model", self.model); - // Priming warms the completion cache; skip it for bearer, whose catalog is a - // multi-MB models.dev download best deferred to first `/model` use. - if (credentials.auth != .bearer) _ = completionModels(self, self.allocator); + // Priming warms the completion cache; skip it for a subscription provider, + // whose catalog is a multi-MB models.dev download best deferred to first use. + if (auth.descriptorFor(credentials.provider) == null) _ = completionModels(self, self.allocator); } /// Keep a subscription (bearer) token current before a model request: when the @@ -1042,7 +1039,7 @@ fn setProvider(self: *Agent, credentials: Credentials, owned_key: ?[:0]const u8, fn refreshAuthIfNeeded(self: *Agent) void { if (self.auth_session) |*session| { const new_token = session.ensureFresh() catch |err| { - self.terminal.printError("could not refresh the Claude subscription token: {s}", .{@errorName(err)}); + self.terminal.printError("could not refresh the subscription token: {s}", .{@errorName(err)}); return; }; if (new_token) |tok| { @@ -1907,9 +1904,9 @@ pub fn listModels(allocator: std.mem.Allocator, opts: Config.Agent) !void { var arena: std.heap.ArenaAllocator = .init(allocator); defer arena.deinit(); - // A subscription (bearer) token can't list models via the provider; use the + // A subscription provider can't list models via the provider API; use the // free models.dev catalog (uncached here — this is a one-shot CLI command). - if (llm.auth == .bearer) { + if (auth.descriptorFor(llm.provider) != null) { const sub_ids = models_dev.modelIds(arena.allocator(), @tagName(llm.provider), null); var stdout_sub = std.Io.File.stdout().writerStreaming(lp.io, &.{}); const ws = &stdout_sub.interface; @@ -1974,10 +1971,9 @@ fn completionModels(context: *anyopaque, _: std.mem.Allocator) []const []const u _ = self.model_completion_arena.reset(.retain_capacity); const arena = self.model_completion_arena.allocator(); - // A subscription (bearer) token can't hit the provider's `/models` endpoint - // (it 401s), so list from the free, unauthenticated models.dev catalog - // instead — no API key needed. - const ids = if (llm.auth == .bearer) + // A subscription provider can't hit the provider's `/models` endpoint, so + // list from the free, unauthenticated models.dev catalog instead. + const ids = if (auth.descriptorFor(llm.provider) != null) models_dev.modelIds(arena, @tagName(llm.provider), self.app_dir) else zenai.provider.listChatModelIds( diff --git a/src/agent/auth/anthropic.zig b/src/agent/auth/anthropic.zig deleted file mode 100644 index 679437f7f..000000000 --- a/src/agent/auth/anthropic.zig +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) -// -// Francis Bouvier -// Pierre Tachoire -// -// 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 . - -//! Anthropic subscription-auth descriptor plus the Claude Code credential -//! importer. v1 reuses the OAuth token Claude Code already maintains at -//! `$HOME/.claude/.credentials.json`; the login/refresh-endpoint fields on the -//! descriptor are the seam for the deferred own-OAuth flow and are unused today. - -const std = @import("std"); -const lp = @import("lightpanda"); -const auth = @import("auth.zig"); - -pub const descriptor: auth.Descriptor = .{ - .provider = .anthropic, - .id = "anthropic", - .label = "Claude subscription", - .authorize_url = "https://claude.ai/oauth/authorize", - .token_url = "https://console.anthropic.com/v1/oauth/token", - .client_id = "9d1c250a-e61b-44d9-88ed-5944d1962f5e", - .redirect_uri = "https://console.anthropic.com/oauth/code/callback", - .scope = "org:create_api_key user:profile user:inference", - .importFn = importClaudeCode, -}; - -/// Read Claude Code's OAuth credentials from `$HOME/.claude/.credentials.json`. -/// Returns null when the file is absent, unreadable, or malformed (the caller -/// falls back to API-key auth). Tokens are duped into `allocator`. -pub fn importClaudeCode(allocator: std.mem.Allocator) !?auth.TokenSet { - const home = std.c.getenv("HOME") orelse return null; - const path = try std.fs.path.join(allocator, &.{ std.mem.span(home), ".claude", ".credentials.json" }); - defer allocator.free(path); - - const data = std.Io.Dir.cwd().readFileAlloc(lp.io, path, allocator, .limited(64 * 1024)) catch return null; - defer allocator.free(data); - - const Shape = struct { - claudeAiOauth: ?struct { - accessToken: []const u8, - refreshToken: []const u8 = "", - expiresAt: i64 = 0, - } = null, - }; - const parsed = std.json.parseFromSlice(Shape, allocator, data, .{ .ignore_unknown_fields = true }) catch return null; - defer parsed.deinit(); - - const oauth = parsed.value.claudeAiOauth orelse return null; - if (oauth.accessToken.len == 0) return null; - return try auth.TokenSet.dup(allocator, oauth.accessToken, oauth.refreshToken, oauth.expiresAt); -} diff --git a/src/agent/auth/auth.zig b/src/agent/auth/auth.zig index e66c45da7..0db9b28d6 100644 --- a/src/agent/auth/auth.zig +++ b/src/agent/auth/auth.zig @@ -16,72 +16,178 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -//! Provider-agnostic subscription (OAuth bearer) auth for the agent. v1 imports -//! the token a subscription CLI already maintains on disk — Claude Code for -//! `.anthropic` — and re-reads it on expiry, so the agent never calls the OAuth -//! token endpoint and cannot disturb that CLI's own login. The `Descriptor`'s -//! login-endpoint fields are the seam for a future own-OAuth login flow. +//! Provider-agnostic subscription (OAuth) auth for the agent. A `Descriptor` +//! carries the endpoints plus `loginFn`/`refreshFn` hooks (implemented per +//! provider, e.g. `codex.zig`); this module owns the on-disk token store (in the +//! app data dir) and the `Session` lifecycle (refresh-on-expiry with silent +//! persistence). The AI client borrows `Session.tokens.access_token`, so a +//! session must outlive it. const std = @import("std"); const lp = @import("lightpanda"); const Config = lp.Config; -const anthropic = @import("anthropic.zig"); +const codex = @import("codex.zig"); /// Wall-clock ms since the Unix epoch. pub fn nowMs() i64 { return std.Io.Clock.now(.real, lp.io).toMilliseconds(); } -/// An OAuth credential set. `refresh_token`/`expires_at_ms` back expiry handling -/// (and the deferred refresh grant). +/// Resolve the app data dir (mirrors `App.getAppDataDir("lightpanda")`) into +/// `arena`. Null when neither XDG_DATA_HOME nor HOME is set. +fn dataDir(arena: std.mem.Allocator) ?[]const u8 { + if (std.c.getenv("XDG_DATA_HOME")) |xdg| { + const x = std.mem.span(xdg); + if (x.len > 0) return std.fs.path.join(arena, &.{ x, "lightpanda" }) catch null; + } + const home = std.c.getenv("HOME") orelse return null; + return std.fs.path.join(arena, &.{ std.mem.span(home), ".local", "share", "lightpanda" }) catch null; +} + +/// An OAuth credential set. `account_id` is a provider-specific extra (Codex's +/// ChatGPT account id from the JWT); null when the provider has none. pub const TokenSet = struct { access_token: [:0]const u8, refresh_token: []const u8, expires_at_ms: i64, + account_id: ?[]const u8 = null, - pub fn dup(allocator: std.mem.Allocator, access: []const u8, refresh: []const u8, expires_at_ms: i64) !TokenSet { + pub fn dup(allocator: std.mem.Allocator, access: []const u8, refresh: []const u8, expires_at_ms: i64, account_id: ?[]const u8) !TokenSet { const a = try allocator.dupeZ(u8, access); errdefer allocator.free(a); const r = try allocator.dupe(u8, refresh); - return .{ .access_token = a, .refresh_token = r, .expires_at_ms = expires_at_ms }; + errdefer allocator.free(r); + const id = if (account_id) |v| try allocator.dupe(u8, v) else null; + return .{ .access_token = a, .refresh_token = r, .expires_at_ms = expires_at_ms, .account_id = id }; } pub fn deinit(self: TokenSet, allocator: std.mem.Allocator) void { allocator.free(self.access_token); allocator.free(self.refresh_token); + if (self.account_id) |v| allocator.free(v); } }; -/// Static per-provider OAuth configuration. Adding a provider is a data addition -/// here plus an `importFn`. The login-endpoint fields are the seam for the -/// deferred own-OAuth login flow and are unused in v1. +/// Static per-provider OAuth configuration plus the login/refresh implementations. pub const Descriptor = struct { provider: Config.AiProvider, + /// Key in the on-disk store (`auth.json`). id: []const u8, - /// Human label for the credential, e.g. "Claude subscription". + /// Human label for the credential, e.g. "ChatGPT subscription". label: []const u8, - authorize_url: []const u8, - token_url: []const u8, client_id: []const u8, - redirect_uri: []const u8, scope: []const u8, - /// Import a subscription token another CLI already maintains on disk. - importFn: ?*const fn (std.mem.Allocator) anyerror!?TokenSet = null, + /// OAuth token endpoint (code exchange + refresh grant). + token_url: []const u8, + /// Device-authorization endpoints and the URL the user visits to enter the code. + device_code_url: []const u8, + device_token_url: []const u8, + verify_url: []const u8, + /// Interactive login (device-code flow); returns freshly-minted tokens. + loginFn: *const fn (std.mem.Allocator, *const Descriptor) anyerror!TokenSet, + /// Exchange a refresh token for a new `TokenSet` (with re-derived account id). + refreshFn: *const fn (std.mem.Allocator, *const Descriptor, []const u8) anyerror!TokenSet, }; -pub const registry = [_]*const Descriptor{&anthropic.descriptor}; +pub const registry = [_]*const Descriptor{&codex.descriptor}; pub fn descriptorFor(provider: Config.AiProvider) ?*const Descriptor { for (registry) |d| if (d.provider == provider) return d; return null; } -/// Proactive-refresh margin: re-check the source once the token is within this -/// window of expiry, so a turn never starts on a token about to lapse. +// --- On-disk token store: /auth.json, a JSON map keyed by descriptor id --- +// The `*At` variants take an explicit dir (unit-testable); the public wrappers +// resolve the app data dir themselves so callers need not thread it. + +const StoredToken = struct { + access: []const u8, + refresh: []const u8, + expires_at_ms: i64, + account_id: ?[]const u8 = null, +}; + +fn storePath(arena: std.mem.Allocator, dir: []const u8) ![:0]const u8 { + return std.fs.path.joinZ(arena, &.{ dir, "auth.json" }); +} + +fn storeLoadAt(allocator: std.mem.Allocator, dir: []const u8, id: []const u8) !?TokenSet { + var arena: std.heap.ArenaAllocator = .init(allocator); + defer arena.deinit(); + const a = arena.allocator(); + const path = try storePath(a, dir); + const data = std.Io.Dir.cwd().readFileAlloc(lp.io, path, a, .limited(64 * 1024)) catch return null; + const parsed = std.json.parseFromSliceLeaky(std.json.ArrayHashMap(StoredToken), a, data, .{ .ignore_unknown_fields = true }) catch return null; + const t = parsed.map.get(id) orelse return null; + return try TokenSet.dup(allocator, t.access, t.refresh, t.expires_at_ms, t.account_id); +} + +fn storeSaveAt(dir: []const u8, id: []const u8, tokens: TokenSet) !void { + var arena: std.heap.ArenaAllocator = .init(std.heap.page_allocator); + defer arena.deinit(); + const a = arena.allocator(); + const path = try storePath(a, dir); + + var map: std.json.ArrayHashMap(StoredToken) = .{}; + if (std.Io.Dir.cwd().readFileAlloc(lp.io, path, a, .limited(64 * 1024))) |data| { + map = std.json.parseFromSliceLeaky(std.json.ArrayHashMap(StoredToken), a, data, .{ .ignore_unknown_fields = true }) catch .{}; + } else |_| {} + try map.map.put(a, id, .{ + .access = tokens.access_token, + .refresh = tokens.refresh_token, + .expires_at_ms = tokens.expires_at_ms, + .account_id = tokens.account_id, + }); + + var buf: std.Io.Writer.Allocating = .init(a); + try std.json.Stringify.value(map, .{}, &buf.writer); + try std.Io.Dir.cwd().writeFile(lp.io, .{ .sub_path = path, .data = buf.written() }); + // Secrets file: owner read/write only. + _ = std.c.chmod(path.ptr, 0o600); +} + +fn storeDeleteAt(dir: []const u8, id: []const u8) void { + var arena: std.heap.ArenaAllocator = .init(std.heap.page_allocator); + defer arena.deinit(); + const a = arena.allocator(); + const path = storePath(a, dir) catch return; + const data = std.Io.Dir.cwd().readFileAlloc(lp.io, path, a, .limited(64 * 1024)) catch return; + var parsed = std.json.parseFromSliceLeaky(std.json.ArrayHashMap(StoredToken), a, data, .{ .ignore_unknown_fields = true }) catch return; + _ = parsed.map.swapRemove(id); + var buf: std.Io.Writer.Allocating = .init(a); + std.json.Stringify.value(parsed, .{}, &buf.writer) catch return; + std.Io.Dir.cwd().writeFile(lp.io, .{ .sub_path = path, .data = buf.written() }) catch return; + _ = std.c.chmod(path.ptr, 0o600); +} + +/// Load the stored token for `id`, or null when absent/unreadable/no data dir. +pub fn storeLoad(allocator: std.mem.Allocator, id: []const u8) !?TokenSet { + var da: std.heap.ArenaAllocator = .init(allocator); + defer da.deinit(); + const dir = dataDir(da.allocator()) orelse return null; + return storeLoadAt(allocator, dir, id); +} + +pub fn storeSave(id: []const u8, tokens: TokenSet) !void { + var da: std.heap.ArenaAllocator = .init(std.heap.page_allocator); + defer da.deinit(); + const dir = dataDir(da.allocator()) orelse return error.NoDataDir; + return storeSaveAt(dir, id, tokens); +} + +pub fn storeDelete(id: []const u8) void { + var da: std.heap.ArenaAllocator = .init(std.heap.page_allocator); + defer da.deinit(); + const dir = dataDir(da.allocator()) orelse return; + storeDeleteAt(dir, id); +} + +/// Proactive-refresh margin: refresh once the token is within this window of +/// expiry, so a turn never starts on a token about to lapse. const refresh_skew_ms: i64 = 5 * std.time.ms_per_min; -/// A live subscription credential for one provider. Owns its `TokenSet`; the AI -/// client borrows `tokens.access_token`, so the session must outlive the client. +/// A live subscription credential for one provider. Owns its `TokenSet` and +/// persists refreshed tokens back to the store. pub const Session = struct { allocator: std.mem.Allocator, descriptor: *const Descriptor, @@ -90,24 +196,21 @@ pub const Session = struct { /// still pointing at it (until `setApiKey`) never dereferences freed memory. previous: ?TokenSet = null, - /// When the access token is within `refresh_skew_ms` of expiry, re-import - /// from the source and, if the source has a newer token, adopt it and return - /// the new access token (owned by the session). Returns null when nothing - /// changed. The caller must repoint its client with the returned token - /// before its next request; the old buffer stays valid until the following - /// `ensureFresh`/`deinit`. Errors `SubscriptionTokenExpired` when the token - /// has lapsed and the source has no fresher one. + /// When the access token is within `refresh_skew_ms` of expiry, exchange the + /// refresh token for a new one, persist it, and return the new access token + /// (owned by the session). Null when still fresh. The caller must repoint its + /// client before its next request; the old buffer stays valid until the + /// following `ensureFresh`/`deinit`. pub fn ensureFresh(self: *Session) !?[:0]const u8 { const now = nowMs(); if (self.tokens.expires_at_ms - now > refresh_skew_ms) return null; - const importFn = self.descriptor.importFn orelse return self.staleResult(now); - const fresh = (try importFn(self.allocator)) orelse return self.staleResult(now); - // The source (e.g. Claude Code) hasn't refreshed yet. - if (fresh.expires_at_ms <= self.tokens.expires_at_ms) { - fresh.deinit(self.allocator); - return self.staleResult(now); - } + const fresh = self.descriptor.refreshFn(self.allocator, self.descriptor, self.tokens.refresh_token) catch |err| { + // A transient refresh failure while the token is still valid is not fatal. + if (self.tokens.expires_at_ms > now) return null; + return err; + }; + storeSave(self.descriptor.id, fresh) catch {}; if (self.previous) |p| p.deinit(self.allocator); self.previous = self.tokens; @@ -115,10 +218,6 @@ pub const Session = struct { return self.tokens.access_token; } - fn staleResult(self: *Session, now: i64) error{SubscriptionTokenExpired}!?[:0]const u8 { - return if (self.tokens.expires_at_ms > now) null else error.SubscriptionTokenExpired; - } - pub fn deinit(self: *Session) void { self.tokens.deinit(self.allocator); if (self.previous) |p| p.deinit(self.allocator); @@ -126,51 +225,62 @@ pub const Session = struct { } }; -/// Build a bearer session for `provider` from an available subscription -/// credential, or null when none is importable. +/// Load a stored session for `provider`, or null when the user hasn't logged in. pub fn sessionFor(allocator: std.mem.Allocator, provider: Config.AiProvider) !?Session { const desc = descriptorFor(provider) orelse return null; - const importFn = desc.importFn orelse return null; - const tokens = (try importFn(allocator)) orelse return null; + const tokens = (try storeLoad(allocator, desc.id)) orelse return null; return .{ .allocator = allocator, .descriptor = desc, .tokens = tokens }; } -/// Process-lifetime memo for `subscriptionAvailable`: the credential file is -/// probed several times across a single startup resolution, and it doesn't -/// change under us there (runtime `/provider` re-import goes through `sessionFor`, -/// which always reads fresh). -var availability_cache: std.enums.EnumArray(Config.AiProvider, ?bool) = .initFill(null); - -/// Is a usable (present, not hard-expired) subscription token importable for -/// `provider`? Lets the picker offer a subscription without its API-key env var, -/// and not offer it when no credential exists. -pub fn subscriptionAvailable(provider: Config.AiProvider) bool { - if (availability_cache.get(provider)) |cached| return cached; - const result = probeSubscription(provider); - availability_cache.set(provider, result); - return result; +/// Run the interactive login and persist the result, returning a live session. +pub fn login(allocator: std.mem.Allocator, desc: *const Descriptor) !Session { + const tokens = try desc.loginFn(allocator, desc); + storeSave(desc.id, tokens) catch {}; + return .{ .allocator = allocator, .descriptor = desc, .tokens = tokens }; } -fn probeSubscription(provider: Config.AiProvider) bool { +/// Is a usable (present, not hard-expired) stored token available for `provider`? +/// Lets the picker offer the subscription without an API-key env var. +pub fn subscriptionAvailable(provider: Config.AiProvider) bool { const desc = descriptorFor(provider) orelse return false; - const importFn = desc.importFn orelse return false; - var arena: std.heap.ArenaAllocator = .init(std.heap.page_allocator); - defer arena.deinit(); - const tokens = (importFn(arena.allocator()) catch return false) orelse return false; - // expires_at_ms == 0 means "unknown"; let a live request be the judge. + const tokens = (storeLoad(std.heap.page_allocator, desc.id) catch return false) orelse return false; + defer tokens.deinit(std.heap.page_allocator); return tokens.expires_at_ms == 0 or tokens.expires_at_ms > nowMs(); } test "TokenSet dup/deinit round-trips and is leak-free" { const a = std.testing.allocator; - const t = try TokenSet.dup(a, "acc", "ref", 123); + const t = try TokenSet.dup(a, "acc", "ref", 123, "acct-1"); defer t.deinit(a); try std.testing.expectEqualStrings("acc", t.access_token); try std.testing.expectEqualStrings("ref", t.refresh_token); + try std.testing.expectEqualStrings("acct-1", t.account_id.?); try std.testing.expectEqual(@as(i64, 123), t.expires_at_ms); } -test "descriptorFor resolves anthropic, null for a non-OAuth provider" { - try std.testing.expect(descriptorFor(.anthropic) != null); +test "descriptorFor resolves codex, null for a non-OAuth provider" { + try std.testing.expect(descriptorFor(.codex) != null); try std.testing.expectEqual(@as(?*const Descriptor, null), descriptorFor(.openai)); } + +test "token store save/load/delete round-trips" { + const a = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const dir = try tmp.dir.realPathFileAlloc(lp.io, ".", a); + defer a.free(dir); + + const t = try TokenSet.dup(a, "acc-1", "ref-1", 999, "acct-9"); + defer t.deinit(a); + try storeSaveAt(dir, "codex", t); + + const loaded = (try storeLoadAt(a, dir, "codex")).?; + defer loaded.deinit(a); + try std.testing.expectEqualStrings("acc-1", loaded.access_token); + try std.testing.expectEqualStrings("ref-1", loaded.refresh_token); + try std.testing.expectEqualStrings("acct-9", loaded.account_id.?); + try std.testing.expectEqual(@as(i64, 999), loaded.expires_at_ms); + + storeDeleteAt(dir, "codex"); + try std.testing.expectEqual(@as(?TokenSet, null), try storeLoadAt(a, dir, "codex")); +} diff --git a/src/agent/auth/codex.zig b/src/agent/auth/codex.zig new file mode 100644 index 000000000..d4e0970aa --- /dev/null +++ b/src/agent/auth/codex.zig @@ -0,0 +1,304 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// 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 . + +//! OpenAI Codex (ChatGPT subscription) OAuth. Uses the device-code flow (no +//! local callback server): request a user code, the user enters it at the verify +//! URL, we poll for the authorization code, then exchange it for tokens. The +//! `ChatGPT-Account-Id` sent on every API request is derived from the OAuth JWT. +//! Constants mirror the official Codex CLI / opencode. +//! +//! NETWORK-UNVERIFIED: the HTTP flows (`deviceLogin`/`refreshGrant`) cannot be +//! tested without a live subscription; the pure builders + JWT parsing below are +//! unit-tested. Verify the live handshake once a subscription is available. + +const std = @import("std"); +const lp = @import("lightpanda"); +const auth = @import("auth.zig"); + +const client_id = "app_EMoamEEZ73f0CkXaXp7hrann"; +const issuer = "https://auth.openai.com"; +const token_url = issuer ++ "/oauth/token"; +const device_code_url = issuer ++ "/api/accounts/deviceauth/usercode"; +const device_token_url = issuer ++ "/api/accounts/deviceauth/token"; +const verify_url = issuer ++ "/codex/device"; +/// Redirect URI used only in the device-flow token exchange body (never hit). +const device_redirect_uri = issuer ++ "/deviceauth/callback"; +const scope = "openid profile email offline_access"; +const user_agent = "lightpanda"; +const poll_margin_ms: u64 = 3000; + +pub const descriptor: auth.Descriptor = .{ + .provider = .codex, + .id = "codex", + .label = "ChatGPT subscription", + .client_id = client_id, + .scope = scope, + .token_url = token_url, + .device_code_url = device_code_url, + .device_token_url = device_token_url, + .verify_url = verify_url, + .loginFn = deviceLogin, + .refreshFn = refreshGrant, +}; + +// --- JWT account-id extraction (pure) --- + +/// Extract the ChatGPT account id from an OAuth JWT (id_token preferred, else +/// access_token). Probes `chatgpt_account_id`, then +/// `["https://api.openai.com/auth"].chatgpt_account_id`, then +/// `organizations[0].id`. Returns a slice owned by `arena`, or null. +pub fn accountIdFromJwt(arena: std.mem.Allocator, token: []const u8) ?[]const u8 { + var it = std.mem.splitScalar(u8, token, '.'); + _ = it.next() orelse return null; + const payload_b64 = it.next() orelse return null; + + const dec = std.base64.url_safe_no_pad.Decoder; + const len = dec.calcSizeForSlice(payload_b64) catch return null; + const buf = arena.alloc(u8, len) catch return null; + dec.decode(buf, payload_b64) catch return null; + + const claims = std.json.parseFromSliceLeaky(std.json.Value, arena, buf, .{}) catch return null; + const obj = switch (claims) { + .object => |o| o, + else => return null, + }; + if (stringField(obj, "chatgpt_account_id")) |v| return v; + if (obj.get("https://api.openai.com/auth")) |a| if (a == .object) { + if (stringField(a.object, "chatgpt_account_id")) |v| return v; + }; + if (obj.get("organizations")) |orgs| if (orgs == .array and orgs.array.items.len > 0) { + const first = orgs.array.items[0]; + if (first == .object) if (stringField(first.object, "id")) |v| return v; + }; + return null; +} + +fn stringField(obj: std.json.ObjectMap, key: []const u8) ?[]const u8 { + const v = obj.get(key) orelse return null; + return if (v == .string) v.string else null; +} + +// --- Request-body builders (pure) --- + +const TokenResponse = struct { + access_token: []const u8, + refresh_token: []const u8 = "", + id_token: ?[]const u8 = null, + expires_in: ?i64 = null, +}; + +/// Parse an OAuth token response into a `TokenSet`, deriving `account_id` from +/// the JWTs and computing an absolute expiry. +fn parseTokenResponse(allocator: std.mem.Allocator, body: []const u8) !auth.TokenSet { + var arena: std.heap.ArenaAllocator = .init(allocator); + defer arena.deinit(); + const a = arena.allocator(); + const tr = try std.json.parseFromSliceLeaky(TokenResponse, a, body, .{ .ignore_unknown_fields = true }); + const account_id = if (tr.id_token) |t| accountIdFromJwt(a, t) else null orelse + accountIdFromJwt(a, tr.access_token); + const expires_at = auth.nowMs() + (tr.expires_in orelse 3600) * std.time.ms_per_s; + return auth.TokenSet.dup(allocator, tr.access_token, tr.refresh_token, expires_at, account_id); +} + +fn refreshBody(arena: std.mem.Allocator, refresh_token: []const u8) ![]u8 { + var buf: std.Io.Writer.Allocating = .init(arena); + try buf.writer.writeAll("grant_type=refresh_token&client_id=" ++ client_id ++ "&refresh_token="); + try percentEncode(&buf.writer, refresh_token); + return buf.written(); +} + +fn exchangeBody(arena: std.mem.Allocator, code: []const u8, code_verifier: []const u8) ![]u8 { + var buf: std.Io.Writer.Allocating = .init(arena); + try buf.writer.writeAll("grant_type=authorization_code&client_id=" ++ client_id ++ + "&redirect_uri=" ++ device_redirect_uri ++ "&code="); + try percentEncode(&buf.writer, code); + try buf.writer.writeAll("&code_verifier="); + try percentEncode(&buf.writer, code_verifier); + return buf.written(); +} + +/// Percent-encode a form value (RFC 3986 unreserved chars pass through). +fn percentEncode(w: *std.Io.Writer, value: []const u8) !void { + for (value) |c| { + if (std.ascii.isAlphanumeric(c) or c == '-' or c == '.' or c == '_' or c == '~') { + try w.writeByte(c); + } else { + try w.print("%{X:0>2}", .{c}); + } + } +} + +// --- Device-code flow (network-unverified) --- + +const DeviceCode = struct { + device_auth_id: []const u8, + user_code: []const u8, + interval: []const u8 = "5", +}; + +const DeviceToken = struct { + authorization_code: []const u8, + code_verifier: []const u8, +}; + +fn deviceLogin(allocator: std.mem.Allocator, desc: *const auth.Descriptor) !auth.TokenSet { + _ = desc; + var arena: std.heap.ArenaAllocator = .init(allocator); + defer arena.deinit(); + const a = arena.allocator(); + + const code_res = try post(a, device_code_url, "application/json", "{\"client_id\":\"" ++ client_id ++ "\"}"); + if (code_res.status != .ok) return error.DeviceCodeRequestFailed; + const dc = try std.json.parseFromSliceLeaky(DeviceCode, a, code_res.body, .{ .ignore_unknown_fields = true }); + const interval_ms: u64 = @as(u64, @intCast(std.fmt.parseInt(u32, dc.interval, 10) catch 5)) * std.time.ms_per_s; + + std.debug.print( + "\nTo authorize Lightpanda with your ChatGPT subscription:\n 1. Open {s}\n 2. Enter the code: {s}\n\nWaiting for authorization...\n", + .{ verify_url, dc.user_code }, + ); + + const poll_body = try std.fmt.allocPrint(a, "{{\"device_auth_id\":\"{s}\",\"user_code\":\"{s}\"}}", .{ dc.device_auth_id, dc.user_code }); + const dt: DeviceToken = while (true) { + lp.io.sleep(.fromMilliseconds(@intCast(interval_ms + poll_margin_ms)), .awake) catch {}; + const res = try post(a, device_token_url, "application/json", poll_body); + switch (res.status) { + .ok => break try std.json.parseFromSliceLeaky(DeviceToken, a, res.body, .{ .ignore_unknown_fields = true }), + // Still pending — the user hasn't finished authorizing. + .forbidden, .not_found => continue, + else => return error.DeviceAuthFailed, + } + }; + + const exchange = try exchangeBody(a, dt.authorization_code, dt.code_verifier); + const tok_res = try post(a, token_url, "application/x-www-form-urlencoded", exchange); + if (tok_res.status != .ok) return error.TokenExchangeFailed; + return parseTokenResponse(allocator, tok_res.body); +} + +fn refreshGrant(allocator: std.mem.Allocator, desc: *const auth.Descriptor, refresh_token: []const u8) !auth.TokenSet { + _ = desc; + var arena: std.heap.ArenaAllocator = .init(allocator); + defer arena.deinit(); + const a = arena.allocator(); + const body = try refreshBody(a, refresh_token); + const res = try post(a, token_url, "application/x-www-form-urlencoded", body); + if (res.status != .ok) return error.RefreshFailed; + return parseTokenResponse(allocator, res.body); +} + +const PostResult = struct { status: std.http.Status, body: []u8 }; + +fn post(arena: std.mem.Allocator, url: []const u8, content_type: []const u8, body: []const u8) !PostResult { + var client: std.http.Client = .{ .allocator = arena, .io = lp.io }; + defer client.deinit(); + + const uri = try std.Uri.parse(url); + var req = try client.request(.POST, uri, .{ + .redirect_behavior = .unhandled, + .headers = .{ .content_type = .{ .override = content_type }, .user_agent = .{ .override = user_agent } }, + }); + defer req.deinit(); + req.transfer_encoding = .{ .content_length = body.len }; + var sink = try req.sendBodyUnflushed(&.{}); + try sink.writer.writeAll(body); + try sink.end(); + try req.connection.?.flush(); + + var redirect_buffer: [4096]u8 = undefined; + var response = try req.receiveHead(&redirect_buffer); + + const decompress_buffer: []u8 = switch (response.head.content_encoding) { + .identity => &.{}, + .zstd => try arena.alloc(u8, std.compress.zstd.default_window_len), + .deflate, .gzip => try arena.alloc(u8, std.compress.flate.max_window_len), + .compress => return error.UnsupportedCompression, + }; + var transfer_buffer: [4096]u8 = undefined; + var decompress: std.http.Decompress = undefined; + const reader = response.readerDecompressing(&transfer_buffer, &decompress, decompress_buffer); + + var out: std.Io.Writer.Allocating = .init(arena); + _ = reader.streamRemaining(&out.writer) catch |err| switch (err) { + error.ReadFailed => return response.bodyErr().?, + else => |e| return e, + }; + return .{ .status = response.head.status, .body = out.written() }; +} + +// --- Tests (pure paths) --- + +fn makeJwt(arena: std.mem.Allocator, payload_json: []const u8) ![]const u8 { + const enc = std.base64.url_safe_no_pad.Encoder; + const p = try arena.alloc(u8, enc.calcSize(payload_json.len)); + _ = enc.encode(p, payload_json); + return std.fmt.allocPrint(arena, "aGVhZGVy.{s}.c2ln", .{p}); +} + +test "accountIdFromJwt: top-level chatgpt_account_id" { + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + const jwt = try makeJwt(a, "{\"chatgpt_account_id\":\"acct-top\"}"); + try std.testing.expectEqualStrings("acct-top", accountIdFromJwt(a, jwt).?); +} + +test "accountIdFromJwt: nested auth namespace" { + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + const jwt = try makeJwt(a, "{\"https://api.openai.com/auth\":{\"chatgpt_account_id\":\"acct-ns\"}}"); + try std.testing.expectEqualStrings("acct-ns", accountIdFromJwt(a, jwt).?); +} + +test "accountIdFromJwt: organizations fallback, and null when absent" { + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + const jwt = try makeJwt(a, "{\"organizations\":[{\"id\":\"org-1\"}]}"); + try std.testing.expectEqualStrings("org-1", accountIdFromJwt(a, jwt).?); + const none = try makeJwt(a, "{\"sub\":\"x\"}"); + try std.testing.expectEqual(@as(?[]const u8, null), accountIdFromJwt(a, none)); + try std.testing.expectEqual(@as(?[]const u8, null), accountIdFromJwt(a, "not-a-jwt")); +} + +test "refreshBody / exchangeBody percent-encode values" { + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + const rb = try refreshBody(a, "tok/with+special"); + try std.testing.expect(std.mem.find(u8, rb, "grant_type=refresh_token") != null); + try std.testing.expect(std.mem.find(u8, rb, "client_id=" ++ client_id) != null); + try std.testing.expect(std.mem.find(u8, rb, "tok%2Fwith%2Bspecial") != null); + const eb = try exchangeBody(a, "the code", "verifier"); + try std.testing.expect(std.mem.find(u8, eb, "grant_type=authorization_code") != null); + try std.testing.expect(std.mem.find(u8, eb, "code=the%20code") != null); +} + +test "parseTokenResponse derives account id and absolute expiry" { + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + const jwt = try makeJwt(a, "{\"chatgpt_account_id\":\"acct-x\"}"); + const body = try std.fmt.allocPrint(a, "{{\"access_token\":\"acc\",\"refresh_token\":\"ref\",\"id_token\":\"{s}\",\"expires_in\":3600}}", .{jwt}); + const tokens = try parseTokenResponse(std.testing.allocator, body); + defer tokens.deinit(std.testing.allocator); + try std.testing.expectEqualStrings("acc", tokens.access_token); + try std.testing.expectEqualStrings("ref", tokens.refresh_token); + try std.testing.expectEqualStrings("acct-x", tokens.account_id.?); + try std.testing.expect(tokens.expires_at_ms > auth.nowMs()); +} diff --git a/src/agent/settings.zig b/src/agent/settings.zig index ff1bdf770..6c262b87c 100644 --- a/src/agent/settings.zig +++ b/src/agent/settings.zig @@ -31,7 +31,7 @@ const string = @import("../string.zig"); const auth = @import("auth/auth.zig"); const Credentials = zenai.provider.Credentials; -pub const api_keys_hint = "ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_API_KEY, HF_TOKEN, AI_GATEWAY_API_KEY, or MISTRAL_API_KEY (Vertex AI: VERTEX_API_KEY, or GOOGLE_CLOUD_PROJECT via gcloud; Anthropic: a Claude subscription from Claude Code)"; +pub const api_keys_hint = "ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_API_KEY, HF_TOKEN, AI_GATEWAY_API_KEY, or MISTRAL_API_KEY (Vertex AI: VERTEX_API_KEY, or GOOGLE_CLOUD_PROJECT via gcloud; Codex: a ChatGPT subscription via /provider codex)"; /// Determine which provider to use and read its env key. Returns null /// only when no `--provider` was given AND no env key exists (the caller @@ -188,24 +188,24 @@ pub fn resolveCredentials(allocator: std.mem.Allocator, opts: Config.Agent, reme } /// Swaps a placeholder credential for a live token: a gcloud token for -/// project-mode Vertex, or an imported subscription session for the Anthropic -/// bearer placeholder. +/// project-mode Vertex, or a stored subscription session for the subscription +/// (empty-key) placeholder. fn finishResolved(allocator: std.mem.Allocator, credentials: Credentials, source: @FieldType(ResolvedProvider, "source")) !ResolvedProvider { if (credentials.provider == .vertex and vertexProjectMode()) { const token = try gcloudAccessToken(allocator); return .{ .credentials = .{ .provider = .vertex, .key = token }, .source = source, .key_owned = true }; } - if (credentials.auth == .bearer and credentials.key.len == 0) { + if (auth.descriptorFor(credentials.provider) != null and credentials.key.len == 0) { if (try subscriptionResolved(allocator, credentials.provider, source)) |resolved| return resolved; return error.MissingApiKey; } return .{ .credentials = credentials, .source = source }; } -/// Import a subscription and wrap it as a resolved bearer credential. The +/// Load a stored subscription session and wrap it as a resolved credential. The /// returned `session` owns `credentials.key`; the caller must keep it alive as -/// long as the AI client and free it with `session.deinit`. Null when no -/// subscription is importable. +/// long as the AI client and free it with `session.deinit`. Null when the user +/// hasn't logged in. fn subscriptionResolved(allocator: std.mem.Allocator, provider: Config.AiProvider, source: @FieldType(ResolvedProvider, "source")) !?ResolvedProvider { const session = (try auth.sessionFor(allocator, provider)) orelse return null; // Name the credential in effect — a set-but-ignored API key would otherwise @@ -216,7 +216,7 @@ fn subscriptionResolved(allocator: std.mem.Allocator, provider: Config.AiProvide std.debug.print("{s}: using your {s}.\n", .{ @tagName(provider), session.descriptor.label }); } return .{ - .credentials = .{ .provider = provider, .key = session.tokens.access_token, .auth = .bearer }, + .credentials = .{ .provider = provider, .key = session.tokens.access_token }, .source = source, .session = session, }; @@ -279,7 +279,7 @@ pub fn availableProviders(buf: []Credentials) []Credentials { // live token on selection, mirroring Vertex project mode below. for (auth.registry) |desc| { if (!auth.subscriptionAvailable(desc.provider)) continue; - const placeholder: Credentials = .{ .provider = desc.provider, .key = "", .auth = .bearer }; + const placeholder: Credentials = .{ .provider = desc.provider, .key = "" }; if (indexOfProvider(found, desc.provider)) |i| { found[i] = placeholder; } else if (found.len < buf.len) { @@ -357,9 +357,9 @@ pub fn reconcileModel( base_url: ?[:0]const u8, explicit: bool, ) !ReconciledModel { - // A subscription (bearer) token can't list models; trust the desired model - // as-is rather than 401 against `/models`. - if (llm.auth == .bearer) return .{ .use = try allocator.dupe(u8, desired) }; + // A subscription provider can't list models via the provider API; trust the + // desired model as-is rather than error against `/models`. + if (auth.descriptorFor(llm.provider) != null) return .{ .use = try allocator.dupe(u8, desired) }; var arena: std.heap.ArenaAllocator = .init(allocator); defer arena.deinit(); @@ -419,3 +419,10 @@ test "resolveStream: default on, remembered wins" { try testing.expect(resolveStream(.{ .model = "m", .stream = true })); try testing.expect(!resolveStream(.{ .model = "m", .stream = false })); } + +test { + // Pull the auth module tests into the suite (a `const` import alone doesn't). + _ = @import("auth/auth.zig"); + _ = @import("auth/codex.zig"); + _ = @import("auth/models_dev.zig"); +} From 819aee3c07c229e32e0cdafd4cf0debc3f431dcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Tue, 28 Jul 2026 10:28:18 +0200 Subject: [PATCH 03/21] agent: support login cancellation and provider completion` --- src/agent/Agent.zig | 28 ++++++++++++++++++++++++++-- src/agent/auth/auth.zig | 7 ++++--- src/agent/auth/codex.zig | 13 +++++++++++-- 3 files changed, 41 insertions(+), 7 deletions(-) diff --git a/src/agent/Agent.zig b/src/agent/Agent.zig index 63e70d09a..13201c207 100644 --- a/src/agent/Agent.zig +++ b/src/agent/Agent.zig @@ -957,7 +957,13 @@ fn handleProvider(self: *Agent, _: std.mem.Allocator, rest: []const u8) void { // login (device-code flow) when there's none yet. if (auth.descriptorFor(provider)) |desc| { var owned = (auth.sessionFor(self.allocator, provider) catch null) orelse - (auth.login(self.allocator, desc) catch |err| { + (auth.login(self.allocator, desc, &self.cancel_requested) catch |err| { + if (err == error.LoginCancelled) { + // Undo `requestCancel`'s side effects before the next turn. + self.resetAfterCancel(self.conversation.messages.items.len); + self.terminal.printInfo("{s} login cancelled", .{desc.label}); + return; + } self.terminal.printError("{s} login failed: {s}", .{ desc.label, @errorName(err) }); return; }); @@ -1978,11 +1984,29 @@ fn completionProviders(context: *anyopaque, arena: std.mem.Allocator) []const [] }; if (reachable[i]) extra += 1; } - const names = arena.alloc([]const u8, self.available_providers.len + 1 + extra) catch return &.{}; + // Subscription providers complete even without a stored token — selecting + // one is what starts the login. + var subs: [auth.registry.len][]const u8 = undefined; + var n_subs: usize = 0; + for (auth.registry) |desc| { + const name = @tagName(desc.provider); + const detected = for (self.available_providers) |p| { + if (std.mem.eql(u8, p, name)) break true; + } else false; + if (!detected) { + subs[n_subs] = name; + n_subs += 1; + } + } + const names = arena.alloc([]const u8, self.available_providers.len + n_subs + 1 + extra) catch return &.{}; for (self.available_providers, 0..) |p, i| { names[i] = arena.dupe(u8, p) catch return &.{}; } var n = self.available_providers.len; + for (subs[0..n_subs]) |name| { + names[n] = name; + n += 1; + } for (local_providers, reachable) |tag, r| if (r) { names[n] = @tagName(tag); n += 1; diff --git a/src/agent/auth/auth.zig b/src/agent/auth/auth.zig index 0db9b28d6..52ad92152 100644 --- a/src/agent/auth/auth.zig +++ b/src/agent/auth/auth.zig @@ -84,7 +84,8 @@ pub const Descriptor = struct { device_token_url: []const u8, verify_url: []const u8, /// Interactive login (device-code flow); returns freshly-minted tokens. - loginFn: *const fn (std.mem.Allocator, *const Descriptor) anyerror!TokenSet, + /// A set `cancel` flag aborts the flow with `error.LoginCancelled`. + loginFn: *const fn (std.mem.Allocator, *const Descriptor, cancel: ?*const std.atomic.Value(bool)) anyerror!TokenSet, /// Exchange a refresh token for a new `TokenSet` (with re-derived account id). refreshFn: *const fn (std.mem.Allocator, *const Descriptor, []const u8) anyerror!TokenSet, }; @@ -233,8 +234,8 @@ pub fn sessionFor(allocator: std.mem.Allocator, provider: Config.AiProvider) !?S } /// Run the interactive login and persist the result, returning a live session. -pub fn login(allocator: std.mem.Allocator, desc: *const Descriptor) !Session { - const tokens = try desc.loginFn(allocator, desc); +pub fn login(allocator: std.mem.Allocator, desc: *const Descriptor, cancel: ?*const std.atomic.Value(bool)) !Session { + const tokens = try desc.loginFn(allocator, desc, cancel); storeSave(desc.id, tokens) catch {}; return .{ .allocator = allocator, .descriptor = desc, .tokens = tokens }; } diff --git a/src/agent/auth/codex.zig b/src/agent/auth/codex.zig index d4e0970aa..e09f0ac3c 100644 --- a/src/agent/auth/codex.zig +++ b/src/agent/auth/codex.zig @@ -41,6 +41,7 @@ const device_redirect_uri = issuer ++ "/deviceauth/callback"; const scope = "openid profile email offline_access"; const user_agent = "lightpanda"; const poll_margin_ms: u64 = 3000; +const cancel_slice_ms: u64 = 200; pub const descriptor: auth.Descriptor = .{ .provider = .codex, @@ -156,7 +157,7 @@ const DeviceToken = struct { code_verifier: []const u8, }; -fn deviceLogin(allocator: std.mem.Allocator, desc: *const auth.Descriptor) !auth.TokenSet { +fn deviceLogin(allocator: std.mem.Allocator, desc: *const auth.Descriptor, cancel: ?*const std.atomic.Value(bool)) !auth.TokenSet { _ = desc; var arena: std.heap.ArenaAllocator = .init(allocator); defer arena.deinit(); @@ -174,7 +175,15 @@ fn deviceLogin(allocator: std.mem.Allocator, desc: *const auth.Descriptor) !auth const poll_body = try std.fmt.allocPrint(a, "{{\"device_auth_id\":\"{s}\",\"user_code\":\"{s}\"}}", .{ dc.device_auth_id, dc.user_code }); const dt: DeviceToken = while (true) { - lp.io.sleep(.fromMilliseconds(@intCast(interval_ms + poll_margin_ms)), .awake) catch {}; + // The REPL's Ctrl-C only sets the cancel flag (it never kills the + // process), so the wait must poll it. + var remaining_ms: u64 = interval_ms + poll_margin_ms; + while (remaining_ms > 0) { + if (cancel) |flag| if (flag.load(.acquire)) return error.LoginCancelled; + const slice_ms = @min(remaining_ms, cancel_slice_ms); + lp.io.sleep(.fromMilliseconds(@intCast(slice_ms)), .awake) catch {}; + remaining_ms -= slice_ms; + } const res = try post(a, device_token_url, "application/json", poll_body); switch (res.status) { .ok => break try std.json.parseFromSliceLeaky(DeviceToken, a, res.body, .{ .ignore_unknown_fields = true }), From 01a7b091db5c8799b707f50909bed47adac7da79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Tue, 28 Jul 2026 10:56:24 +0200 Subject: [PATCH 04/21] agent: map models_dev_id and filter tool-call models --- build.zig.zon | 4 ++-- src/agent/Agent.zig | 8 +++---- src/agent/auth/auth.zig | 3 +++ src/agent/auth/codex.zig | 1 + src/agent/auth/models_dev.zig | 40 ++++++++++++++++++++++++++--------- 5 files changed, 40 insertions(+), 16 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index ae87bd301..2f92925df 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -36,8 +36,8 @@ .hash = "sqlite3-3.53.2-DMxLWuAOAAA_Px0arJOIOaP4AKEu5prbsQgPMA35W1zz", }, .zenai = .{ - .url = "git+https://github.com/lightpanda-io/zenai.git?ref=codex#69673248321adeee537dfedcf9c13eed18928c30", - .hash = "zenai-0.0.0-iOY_VP0GBgCClHsRHVmHvSVfYVT33yty_Lb-Qafpb2Gm", + .url = "git+https://github.com/lightpanda-io/zenai.git#2fb52c9ef814b6b57596692477862d5183969d7c", + .hash = "zenai-0.0.0-iOY_VOb-BQCiU8PDX1qxvpJm-AQHLGFOp_DN6xIV5iJx", }, .isocline = .{ .url = "git+https://github.com/arrufat/isocline?ref=lightpanda#832a9fe25f5f4458fcc47b5acc7c21db669c2f47", diff --git a/src/agent/Agent.zig b/src/agent/Agent.zig index 13201c207..887e029cb 100644 --- a/src/agent/Agent.zig +++ b/src/agent/Agent.zig @@ -1941,8 +1941,8 @@ pub fn listModels(allocator: std.mem.Allocator, opts: Config.Agent) !void { defer arena.deinit(); // A subscription provider can't list models via the provider API; use the // free models.dev catalog (uncached here — this is a one-shot CLI command). - if (auth.descriptorFor(llm.provider) != null) { - const sub_ids = models_dev.modelIds(arena.allocator(), @tagName(llm.provider), null); + if (auth.descriptorFor(llm.provider)) |desc| { + const sub_ids = models_dev.modelIds(arena.allocator(), desc.models_dev_id, null); var stdout_sub = std.Io.File.stdout().writerStreaming(lp.io, &.{}); const ws = &stdout_sub.interface; for (sub_ids) |id| try ws.print("{s}\n", .{id}); @@ -2026,8 +2026,8 @@ fn completionModels(context: *anyopaque, _: std.mem.Allocator) []const []const u const arena = self.model_completion_arena.allocator(); // A subscription provider can't hit the provider's `/models` endpoint, so // list from the free, unauthenticated models.dev catalog instead. - const ids = if (auth.descriptorFor(llm.provider) != null) - models_dev.modelIds(arena, @tagName(llm.provider), self.app_dir) + const ids = if (auth.descriptorFor(llm.provider)) |desc| + models_dev.modelIds(arena, desc.models_dev_id, self.app_dir) else zenai.provider.listChatModelIds( lp.io, diff --git a/src/agent/auth/auth.zig b/src/agent/auth/auth.zig index 52ad92152..8d994f24b 100644 --- a/src/agent/auth/auth.zig +++ b/src/agent/auth/auth.zig @@ -73,6 +73,9 @@ pub const Descriptor = struct { provider: Config.AiProvider, /// Key in the on-disk store (`auth.json`). id: []const u8, + /// Provider key in the models.dev catalog (subscription backends have no + /// entry of their own; codex's models are listed under "openai"). + models_dev_id: []const u8, /// Human label for the credential, e.g. "ChatGPT subscription". label: []const u8, client_id: []const u8, diff --git a/src/agent/auth/codex.zig b/src/agent/auth/codex.zig index e09f0ac3c..089aac379 100644 --- a/src/agent/auth/codex.zig +++ b/src/agent/auth/codex.zig @@ -46,6 +46,7 @@ const cancel_slice_ms: u64 = 200; pub const descriptor: auth.Descriptor = .{ .provider = .codex, .id = "codex", + .models_dev_id = "openai", .label = "ChatGPT subscription", .client_id = client_id, .scope = scope, diff --git a/src/agent/auth/models_dev.zig b/src/agent/auth/models_dev.zig index 2a44f4d2d..30efef3c9 100644 --- a/src/agent/auth/models_dev.zig +++ b/src/agent/auth/models_dev.zig @@ -74,23 +74,43 @@ fn writeCache(app_dir: []const u8, provider_id: []const u8, ids: []const []const try std.Io.Dir.cwd().writeFile(lp.io, .{ .sub_path = path, .data = buf.written() }); } -/// Model-id keys of `catalog[provider_id].models`. `ignore_unknown_fields` skips -/// every provider and model's metadata, so only the id strings are built — no -/// Value tree for the multi-MB payload. The parse runs in a scoped arena; the -/// ids are duped into `arena`. +/// Model-id keys of `catalog[provider_id].models`, filtered to tool-call-capable +/// models (the agent needs tools; this also drops embedding/image entries). +/// `ignore_unknown_fields` skips the rest of the metadata, so no Value tree is +/// built for the multi-MB payload. The parse runs in a scoped arena; the ids +/// are duped into `arena`. fn parseProviderModels(arena: std.mem.Allocator, catalog: []const u8, provider_id: []const u8) ![]const []const u8 { var parse_arena: std.heap.ArenaAllocator = .init(arena); defer parse_arena.deinit(); - const Empty = struct {}; - const Provider = struct { models: std.json.ArrayHashMap(Empty) = .{} }; + const Model = struct { tool_call: bool = false }; + const Provider = struct { models: std.json.ArrayHashMap(Model) = .{} }; const parsed = try std.json.parseFromSliceLeaky(std.json.ArrayHashMap(Provider), parse_arena.allocator(), catalog, .{ .ignore_unknown_fields = true }); const provider = parsed.map.get(provider_id) orelse return error.ProviderMissing; - const keys = provider.models.map.keys(); - const ids = try arena.alloc([]const u8, keys.len); - for (keys, ids) |k, *dst| dst.* = try arena.dupe(u8, k); - return ids; + var ids: std.ArrayList([]const u8) = .empty; + var it = provider.models.map.iterator(); + while (it.next()) |entry| { + if (!entry.value_ptr.tool_call) continue; + try ids.append(arena, try arena.dupe(u8, entry.key_ptr.*)); + } + return ids.toOwnedSlice(arena); +} + +test "parseProviderModels filters to tool-call models" { + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + const catalog = + \\{"openai":{"models":{ + \\ "gpt-5.5":{"tool_call":true}, + \\ "text-embedding-3-small":{"tool_call":false}, + \\ "gpt-image-1":{} + \\}}} + ; + const ids = try parseProviderModels(arena.allocator(), catalog, "openai"); + try std.testing.expectEqual(1, ids.len); + try std.testing.expectEqualStrings("gpt-5.5", ids[0]); + try std.testing.expectError(error.ProviderMissing, parseProviderModels(arena.allocator(), catalog, "nope")); } fn fetch(arena: std.mem.Allocator) ![]u8 { From 00cdc3ba8d1c062fdb6e04335db23fed2b408d58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Tue, 28 Jul 2026 11:17:08 +0200 Subject: [PATCH 05/21] agent: update zenai and switch auth to interruptible HTTP --- build.zig.zon | 4 +- src/App.zig | 2 +- src/agent/Agent.zig | 73 ++++++++++------------- src/agent/auth/auth.zig | 79 ++++++++++--------------- src/agent/auth/codex.zig | 107 ++++++++++------------------------ src/agent/auth/models_dev.zig | 25 +------- src/agent/settings.zig | 14 +++-- 7 files changed, 107 insertions(+), 197 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 2f92925df..68845e049 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -36,8 +36,8 @@ .hash = "sqlite3-3.53.2-DMxLWuAOAAA_Px0arJOIOaP4AKEu5prbsQgPMA35W1zz", }, .zenai = .{ - .url = "git+https://github.com/lightpanda-io/zenai.git#2fb52c9ef814b6b57596692477862d5183969d7c", - .hash = "zenai-0.0.0-iOY_VOb-BQCiU8PDX1qxvpJm-AQHLGFOp_DN6xIV5iJx", + .url = "git+https://github.com/lightpanda-io/zenai.git#58489db10aaa22204b0347c0938ee3293c8cd3e9", + .hash = "zenai-0.0.0-iOY_VAkABgBXETyPYATPQE2D3_4eF-A8k0Nt_c0G3nKt", }, .isocline = .{ .url = "git+https://github.com/arrufat/isocline?ref=lightpanda#832a9fe25f5f4458fcc47b5acc7c21db669c2f47", diff --git a/src/App.zig b/src/App.zig index 0b7d5ef09..5eff1f64e 100644 --- a/src/App.zig +++ b/src/App.zig @@ -129,7 +129,7 @@ fn getAndMakeAppDir(allocator: Allocator) ?[]const u8 { return app_dir_path; } -fn getAppDataDir(allocator: Allocator, appname: []const u8) ![]const u8 { +pub fn getAppDataDir(allocator: Allocator, appname: []const u8) ![]const u8 { switch (@import("builtin").os.tag) { .macos, .ios => { const home = std.c.getenv("HOME") orelse return error.AppDataDirUnavailable; diff --git a/src/agent/Agent.zig b/src/agent/Agent.zig index 887e029cb..e6c6e86d4 100644 --- a/src/agent/Agent.zig +++ b/src/agent/Agent.zig @@ -937,8 +937,8 @@ fn handleProvider(self: *Agent, _: std.mem.Allocator, rest: []const u8) void { // Re-selecting vertex (token refresh) or a subscription provider (re-import // after logging into its CLI) falls through instead of no-op'ing. const vertex_project = provider == .vertex and settings.vertexProjectMode(); - const subscription = auth.descriptorFor(provider) != null; - if (self.model_credentials) |current| if (provider == current.provider and !vertex_project and !subscription) { + const sub_desc = auth.descriptorFor(provider); + if (self.model_credentials) |current| if (provider == current.provider and !vertex_project and sub_desc == null) { self.terminal.printInfo("provider: {s}", .{@tagName(provider)}); return; }; @@ -955,9 +955,9 @@ fn handleProvider(self: *Agent, _: std.mem.Allocator, rest: []const u8) void { } // Subscription provider: use the stored session, or run the interactive // login (device-code flow) when there's none yet. - if (auth.descriptorFor(provider)) |desc| { + if (sub_desc) |desc| { var owned = (auth.sessionFor(self.allocator, provider) catch null) orelse - (auth.login(self.allocator, desc, &self.cancel_requested) catch |err| { + (auth.login(self.allocator, desc, &self.http_interrupt) catch |err| { if (err == error.LoginCancelled) { // Undo `requestCancel`'s side effects before the next turn. self.resetAfterCancel(self.conversation.messages.items.len); @@ -1049,25 +1049,25 @@ fn setProvider(self: *Agent, credentials: Credentials, owned_key: ?[:0]const u8, self.terminal.printInfo("effort: {s} ({s} default)", .{ @tagName(e), @tagName(credentials.provider) }); }; self.reportSaved("model", self.model); - // Priming warms the completion cache; skip it for a subscription provider, - // whose catalog is a multi-MB models.dev download best deferred to first use. - if (auth.descriptorFor(credentials.provider) == null) _ = completionModels(self, self.allocator); + // Prime the completion cache on the command, not on a `/model` keystroke. + _ = completionModels(self, self.allocator); } /// Keep a subscription (bearer) token current before a model request: when the /// active session's token is near expiry, re-import from the source and repoint /// the client and credentials at the fresh token. A no-op for API-key auth. -/// Runs between turns on the single agent thread, never during a request, so the -/// old buffer (held one cycle by `auth.Session`) is never dereferenced freed. +/// Runs between turns on the single agent thread, never during a request, so +/// the displaced token is never dereferenced after it's freed here. fn refreshAuthIfNeeded(self: *Agent) void { if (self.auth_session) |*session| { - const new_token = session.ensureFresh() catch |err| { + const displaced = session.ensureFresh() catch |err| { self.terminal.printError("could not refresh the subscription token: {s}", .{@errorName(err)}); return; }; - if (new_token) |tok| { - if (self.ai_client) |client| client.setApiKey(tok); - if (self.model_credentials) |*c| c.key = tok; + if (displaced) |old| { + if (self.ai_client) |client| client.setApiKey(session.tokens.access_token); + if (self.model_credentials) |*c| c.key = session.tokens.access_token; + old.deinit(session.allocator); } } } @@ -1940,21 +1940,16 @@ pub fn listModels(allocator: std.mem.Allocator, opts: Config.Agent) !void { var arena: std.heap.ArenaAllocator = .init(allocator); defer arena.deinit(); // A subscription provider can't list models via the provider API; use the - // free models.dev catalog (uncached here — this is a one-shot CLI command). - if (auth.descriptorFor(llm.provider)) |desc| { - const sub_ids = models_dev.modelIds(arena.allocator(), desc.models_dev_id, null); - var stdout_sub = std.Io.File.stdout().writerStreaming(lp.io, &.{}); - const ws = &stdout_sub.interface; - for (sub_ids) |id| try ws.print("{s}\n", .{id}); - try ws.flush(); - return; - } - const ids = zenai.provider.listChatModelIds(lp.io, allocator, arena.allocator(), llm.provider, llm.key, .{ .base_url = opts.base_url, .environ = lp.environ() }) catch |err| { - if (llm.provider == .vertex and !settings.vertexProjectMode()) { - std.debug.print("Vertex express mode cannot list models (the endpoint requires OAuth); set GOOGLE_CLOUD_PROJECT for project mode.\n", .{}); - } - return err; - }; + // free models.dev catalog. + const ids: []const []const u8 = if (auth.descriptorFor(llm.provider)) |desc| + models_dev.modelIds(arena.allocator(), desc.models_dev_id, auth.dataDir(arena.allocator())) + else + zenai.provider.listChatModelIds(lp.io, allocator, arena.allocator(), llm.provider, llm.key, .{ .base_url = opts.base_url, .environ = lp.environ() }) catch |err| { + if (llm.provider == .vertex and !settings.vertexProjectMode()) { + std.debug.print("Vertex express mode cannot list models (the endpoint requires OAuth); set GOOGLE_CLOUD_PROJECT for project mode.\n", .{}); + } + return err; + }; var stdout_file = std.Io.File.stdout().writerStreaming(lp.io, &.{}); const w = &stdout_file.interface; @@ -1984,35 +1979,29 @@ fn completionProviders(context: *anyopaque, arena: std.mem.Allocator) []const [] }; if (reachable[i]) extra += 1; } + const names = arena.alloc([]const u8, self.available_providers.len + auth.registry.len + 1 + extra) catch return &.{}; + for (self.available_providers, 0..) |p, i| { + names[i] = arena.dupe(u8, p) catch return &.{}; + } + var n = self.available_providers.len; // Subscription providers complete even without a stored token — selecting // one is what starts the login. - var subs: [auth.registry.len][]const u8 = undefined; - var n_subs: usize = 0; for (auth.registry) |desc| { const name = @tagName(desc.provider); const detected = for (self.available_providers) |p| { if (std.mem.eql(u8, p, name)) break true; } else false; if (!detected) { - subs[n_subs] = name; - n_subs += 1; + names[n] = name; + n += 1; } } - const names = arena.alloc([]const u8, self.available_providers.len + n_subs + 1 + extra) catch return &.{}; - for (self.available_providers, 0..) |p, i| { - names[i] = arena.dupe(u8, p) catch return &.{}; - } - var n = self.available_providers.len; - for (subs[0..n_subs]) |name| { - names[n] = name; - n += 1; - } for (local_providers, reachable) |tag, r| if (r) { names[n] = @tagName(tag); n += 1; }; names[n] = provider_off_keyword; - return names; + return names[0 .. n + 1]; } /// `CompletionSource.models`. Blocks on a one-time fetch per provider, caching diff --git a/src/agent/auth/auth.zig b/src/agent/auth/auth.zig index 8d994f24b..d1fae55cb 100644 --- a/src/agent/auth/auth.zig +++ b/src/agent/auth/auth.zig @@ -17,7 +17,7 @@ // along with this program. If not, see . //! Provider-agnostic subscription (OAuth) auth for the agent. A `Descriptor` -//! carries the endpoints plus `loginFn`/`refreshFn` hooks (implemented per +//! names a provider plus its `loginFn`/`refreshFn` hooks (implemented per //! provider, e.g. `codex.zig`); this module owns the on-disk token store (in the //! app data dir) and the `Session` lifecycle (refresh-on-expiry with silent //! persistence). The AI client borrows `Session.tokens.access_token`, so a @@ -25,23 +25,19 @@ const std = @import("std"); const lp = @import("lightpanda"); +const zenai = @import("zenai"); const Config = lp.Config; const codex = @import("codex.zig"); /// Wall-clock ms since the Unix epoch. pub fn nowMs() i64 { - return std.Io.Clock.now(.real, lp.io).toMilliseconds(); + return @intCast(lp.datetime.milliTimestamp(.real)); } -/// Resolve the app data dir (mirrors `App.getAppDataDir("lightpanda")`) into -/// `arena`. Null when neither XDG_DATA_HOME nor HOME is set. -fn dataDir(arena: std.mem.Allocator) ?[]const u8 { - if (std.c.getenv("XDG_DATA_HOME")) |xdg| { - const x = std.mem.span(xdg); - if (x.len > 0) return std.fs.path.join(arena, &.{ x, "lightpanda" }) catch null; - } - const home = std.c.getenv("HOME") orelse return null; - return std.fs.path.join(arena, &.{ std.mem.span(home), ".local", "share", "lightpanda" }) catch null; +/// Resolve the app data dir into `arena`, for callers without an `App` (the +/// store wrappers, `--list-models`). Null when no home is resolvable. +pub fn dataDir(arena: std.mem.Allocator) ?[]const u8 { + return lp.App.getAppDataDir(arena, "lightpanda") catch null; } /// An OAuth credential set. `account_id` is a provider-specific extra (Codex's @@ -68,29 +64,25 @@ pub const TokenSet = struct { } }; -/// Static per-provider OAuth configuration plus the login/refresh implementations. +/// Per-provider login/refresh implementations; OAuth endpoints and client ids +/// live in the implementing file. pub const Descriptor = struct { provider: Config.AiProvider, - /// Key in the on-disk store (`auth.json`). - id: []const u8, /// Provider key in the models.dev catalog (subscription backends have no /// entry of their own; codex's models are listed under "openai"). models_dev_id: []const u8, /// Human label for the credential, e.g. "ChatGPT subscription". label: []const u8, - client_id: []const u8, - scope: []const u8, - /// OAuth token endpoint (code exchange + refresh grant). - token_url: []const u8, - /// Device-authorization endpoints and the URL the user visits to enter the code. - device_code_url: []const u8, - device_token_url: []const u8, - verify_url: []const u8, /// Interactive login (device-code flow); returns freshly-minted tokens. - /// A set `cancel` flag aborts the flow with `error.LoginCancelled`. - loginFn: *const fn (std.mem.Allocator, *const Descriptor, cancel: ?*const std.atomic.Value(bool)) anyerror!TokenSet, + /// Firing `interrupt` aborts the flow with `error.LoginCancelled`. + loginFn: *const fn (std.mem.Allocator, interrupt: ?*zenai.http.Interrupt) anyerror!TokenSet, /// Exchange a refresh token for a new `TokenSet` (with re-derived account id). - refreshFn: *const fn (std.mem.Allocator, *const Descriptor, []const u8) anyerror!TokenSet, + refreshFn: *const fn (std.mem.Allocator, refresh_token: []const u8) anyerror!TokenSet, + + /// Key in the on-disk store (`auth.json`). + pub fn storeKey(self: *const Descriptor) []const u8 { + return @tagName(self.provider); + } }; pub const registry = [_]*const Descriptor{&codex.descriptor}; @@ -100,7 +92,7 @@ pub fn descriptorFor(provider: Config.AiProvider) ?*const Descriptor { return null; } -// --- On-disk token store: /auth.json, a JSON map keyed by descriptor id --- +// --- On-disk token store: /auth.json, a JSON map keyed by storeKey --- // The `*At` variants take an explicit dir (unit-testable); the public wrappers // resolve the app data dir themselves so callers need not thread it. @@ -196,50 +188,43 @@ pub const Session = struct { allocator: std.mem.Allocator, descriptor: *const Descriptor, tokens: TokenSet, - /// The immediately-prior token, kept alive one refresh cycle so a client - /// still pointing at it (until `setApiKey`) never dereferences freed memory. - previous: ?TokenSet = null, /// When the access token is within `refresh_skew_ms` of expiry, exchange the - /// refresh token for a new one, persist it, and return the new access token - /// (owned by the session). Null when still fresh. The caller must repoint its - /// client before its next request; the old buffer stays valid until the - /// following `ensureFresh`/`deinit`. - pub fn ensureFresh(self: *Session) !?[:0]const u8 { + /// refresh token for a new one, persist it, and return the displaced set. + /// Null when still fresh. The caller must repoint anything borrowing from + /// the displaced set (`tokens` holds the fresh one) before freeing it. + pub fn ensureFresh(self: *Session) !?TokenSet { const now = nowMs(); if (self.tokens.expires_at_ms - now > refresh_skew_ms) return null; - const fresh = self.descriptor.refreshFn(self.allocator, self.descriptor, self.tokens.refresh_token) catch |err| { + const fresh = self.descriptor.refreshFn(self.allocator, self.tokens.refresh_token) catch |err| { // A transient refresh failure while the token is still valid is not fatal. if (self.tokens.expires_at_ms > now) return null; return err; }; - storeSave(self.descriptor.id, fresh) catch {}; + storeSave(self.descriptor.storeKey(), fresh) catch {}; - if (self.previous) |p| p.deinit(self.allocator); - self.previous = self.tokens; + const displaced = self.tokens; self.tokens = fresh; - return self.tokens.access_token; + return displaced; } pub fn deinit(self: *Session) void { self.tokens.deinit(self.allocator); - if (self.previous) |p| p.deinit(self.allocator); - self.previous = null; } }; /// Load a stored session for `provider`, or null when the user hasn't logged in. pub fn sessionFor(allocator: std.mem.Allocator, provider: Config.AiProvider) !?Session { const desc = descriptorFor(provider) orelse return null; - const tokens = (try storeLoad(allocator, desc.id)) orelse return null; + const tokens = (try storeLoad(allocator, desc.storeKey())) orelse return null; return .{ .allocator = allocator, .descriptor = desc, .tokens = tokens }; } /// Run the interactive login and persist the result, returning a live session. -pub fn login(allocator: std.mem.Allocator, desc: *const Descriptor, cancel: ?*const std.atomic.Value(bool)) !Session { - const tokens = try desc.loginFn(allocator, desc, cancel); - storeSave(desc.id, tokens) catch {}; +pub fn login(allocator: std.mem.Allocator, desc: *const Descriptor, interrupt: ?*zenai.http.Interrupt) !Session { + const tokens = try desc.loginFn(allocator, interrupt); + storeSave(desc.storeKey(), tokens) catch {}; return .{ .allocator = allocator, .descriptor = desc, .tokens = tokens }; } @@ -247,9 +232,9 @@ pub fn login(allocator: std.mem.Allocator, desc: *const Descriptor, cancel: ?*co /// Lets the picker offer the subscription without an API-key env var. pub fn subscriptionAvailable(provider: Config.AiProvider) bool { const desc = descriptorFor(provider) orelse return false; - const tokens = (storeLoad(std.heap.page_allocator, desc.id) catch return false) orelse return false; + const tokens = (storeLoad(std.heap.page_allocator, desc.storeKey()) catch return false) orelse return false; defer tokens.deinit(std.heap.page_allocator); - return tokens.expires_at_ms == 0 or tokens.expires_at_ms > nowMs(); + return tokens.expires_at_ms > nowMs(); } test "TokenSet dup/deinit round-trips and is leak-free" { diff --git a/src/agent/auth/codex.zig b/src/agent/auth/codex.zig index 089aac379..d2eb67a58 100644 --- a/src/agent/auth/codex.zig +++ b/src/agent/auth/codex.zig @@ -21,13 +21,10 @@ //! URL, we poll for the authorization code, then exchange it for tokens. The //! `ChatGPT-Account-Id` sent on every API request is derived from the OAuth JWT. //! Constants mirror the official Codex CLI / opencode. -//! -//! NETWORK-UNVERIFIED: the HTTP flows (`deviceLogin`/`refreshGrant`) cannot be -//! tested without a live subscription; the pure builders + JWT parsing below are -//! unit-tested. Verify the live handshake once a subscription is available. const std = @import("std"); const lp = @import("lightpanda"); +const zenai = @import("zenai"); const auth = @import("auth.zig"); const client_id = "app_EMoamEEZ73f0CkXaXp7hrann"; @@ -38,22 +35,14 @@ const device_token_url = issuer ++ "/api/accounts/deviceauth/token"; const verify_url = issuer ++ "/codex/device"; /// Redirect URI used only in the device-flow token exchange body (never hit). const device_redirect_uri = issuer ++ "/deviceauth/callback"; -const scope = "openid profile email offline_access"; const user_agent = "lightpanda"; const poll_margin_ms: u64 = 3000; const cancel_slice_ms: u64 = 200; pub const descriptor: auth.Descriptor = .{ .provider = .codex, - .id = "codex", .models_dev_id = "openai", .label = "ChatGPT subscription", - .client_id = client_id, - .scope = scope, - .token_url = token_url, - .device_code_url = device_code_url, - .device_token_url = device_token_url, - .verify_url = verify_url, .loginFn = deviceLogin, .refreshFn = refreshGrant, }; @@ -111,41 +100,27 @@ fn parseTokenResponse(allocator: std.mem.Allocator, body: []const u8) !auth.Toke defer arena.deinit(); const a = arena.allocator(); const tr = try std.json.parseFromSliceLeaky(TokenResponse, a, body, .{ .ignore_unknown_fields = true }); - const account_id = if (tr.id_token) |t| accountIdFromJwt(a, t) else null orelse + const account_id = (if (tr.id_token) |t| accountIdFromJwt(a, t) else null) orelse accountIdFromJwt(a, tr.access_token); const expires_at = auth.nowMs() + (tr.expires_in orelse 3600) * std.time.ms_per_s; return auth.TokenSet.dup(allocator, tr.access_token, tr.refresh_token, expires_at, account_id); } fn refreshBody(arena: std.mem.Allocator, refresh_token: []const u8) ![]u8 { - var buf: std.Io.Writer.Allocating = .init(arena); - try buf.writer.writeAll("grant_type=refresh_token&client_id=" ++ client_id ++ "&refresh_token="); - try percentEncode(&buf.writer, refresh_token); - return buf.written(); + return std.fmt.allocPrint(arena, "grant_type=refresh_token&client_id=" ++ client_id ++ "&refresh_token={s}", .{ + try lp.URL.percentEncodeSegment(arena, refresh_token, .component), + }); } fn exchangeBody(arena: std.mem.Allocator, code: []const u8, code_verifier: []const u8) ![]u8 { - var buf: std.Io.Writer.Allocating = .init(arena); - try buf.writer.writeAll("grant_type=authorization_code&client_id=" ++ client_id ++ - "&redirect_uri=" ++ device_redirect_uri ++ "&code="); - try percentEncode(&buf.writer, code); - try buf.writer.writeAll("&code_verifier="); - try percentEncode(&buf.writer, code_verifier); - return buf.written(); + return std.fmt.allocPrint(arena, "grant_type=authorization_code&client_id=" ++ client_id ++ + "&redirect_uri=" ++ device_redirect_uri ++ "&code={s}&code_verifier={s}", .{ + try lp.URL.percentEncodeSegment(arena, code, .component), + try lp.URL.percentEncodeSegment(arena, code_verifier, .component), + }); } -/// Percent-encode a form value (RFC 3986 unreserved chars pass through). -fn percentEncode(w: *std.Io.Writer, value: []const u8) !void { - for (value) |c| { - if (std.ascii.isAlphanumeric(c) or c == '-' or c == '.' or c == '_' or c == '~') { - try w.writeByte(c); - } else { - try w.print("%{X:0>2}", .{c}); - } - } -} - -// --- Device-code flow (network-unverified) --- +// --- Device-code flow --- const DeviceCode = struct { device_auth_id: []const u8, @@ -158,13 +133,12 @@ const DeviceToken = struct { code_verifier: []const u8, }; -fn deviceLogin(allocator: std.mem.Allocator, desc: *const auth.Descriptor, cancel: ?*const std.atomic.Value(bool)) !auth.TokenSet { - _ = desc; +fn deviceLogin(allocator: std.mem.Allocator, interrupt: ?*zenai.http.Interrupt) !auth.TokenSet { var arena: std.heap.ArenaAllocator = .init(allocator); defer arena.deinit(); const a = arena.allocator(); - const code_res = try post(a, device_code_url, "application/json", "{\"client_id\":\"" ++ client_id ++ "\"}"); + const code_res = try post(a, interrupt, device_code_url, "application/json", "{\"client_id\":\"" ++ client_id ++ "\"}"); if (code_res.status != .ok) return error.DeviceCodeRequestFailed; const dc = try std.json.parseFromSliceLeaky(DeviceCode, a, code_res.body, .{ .ignore_unknown_fields = true }); const interval_ms: u64 = @as(u64, @intCast(std.fmt.parseInt(u32, dc.interval, 10) catch 5)) * std.time.ms_per_s; @@ -176,16 +150,16 @@ fn deviceLogin(allocator: std.mem.Allocator, desc: *const auth.Descriptor, cance const poll_body = try std.fmt.allocPrint(a, "{{\"device_auth_id\":\"{s}\",\"user_code\":\"{s}\"}}", .{ dc.device_auth_id, dc.user_code }); const dt: DeviceToken = while (true) { - // The REPL's Ctrl-C only sets the cancel flag (it never kills the + // The REPL's Ctrl-C only fires the interrupt (it never kills the // process), so the wait must poll it. var remaining_ms: u64 = interval_ms + poll_margin_ms; while (remaining_ms > 0) { - if (cancel) |flag| if (flag.load(.acquire)) return error.LoginCancelled; + if (interrupt) |it| if (it.isFired()) return error.LoginCancelled; const slice_ms = @min(remaining_ms, cancel_slice_ms); lp.io.sleep(.fromMilliseconds(@intCast(slice_ms)), .awake) catch {}; remaining_ms -= slice_ms; } - const res = try post(a, device_token_url, "application/json", poll_body); + const res = try post(a, interrupt, device_token_url, "application/json", poll_body); switch (res.status) { .ok => break try std.json.parseFromSliceLeaky(DeviceToken, a, res.body, .{ .ignore_unknown_fields = true }), // Still pending — the user hasn't finished authorizing. @@ -195,59 +169,40 @@ fn deviceLogin(allocator: std.mem.Allocator, desc: *const auth.Descriptor, cance }; const exchange = try exchangeBody(a, dt.authorization_code, dt.code_verifier); - const tok_res = try post(a, token_url, "application/x-www-form-urlencoded", exchange); + const tok_res = try post(a, interrupt, token_url, "application/x-www-form-urlencoded", exchange); if (tok_res.status != .ok) return error.TokenExchangeFailed; return parseTokenResponse(allocator, tok_res.body); } -fn refreshGrant(allocator: std.mem.Allocator, desc: *const auth.Descriptor, refresh_token: []const u8) !auth.TokenSet { - _ = desc; +fn refreshGrant(allocator: std.mem.Allocator, refresh_token: []const u8) !auth.TokenSet { var arena: std.heap.ArenaAllocator = .init(allocator); defer arena.deinit(); const a = arena.allocator(); const body = try refreshBody(a, refresh_token); - const res = try post(a, token_url, "application/x-www-form-urlencoded", body); + const res = try post(a, null, token_url, "application/x-www-form-urlencoded", body); if (res.status != .ok) return error.RefreshFailed; return parseTokenResponse(allocator, res.body); } const PostResult = struct { status: std.http.Status, body: []u8 }; -fn post(arena: std.mem.Allocator, url: []const u8, content_type: []const u8, body: []const u8) !PostResult { +/// One-shot POST. Firing `interrupt` aborts an in-flight exchange and surfaces +/// `error.LoginCancelled` instead of the read error it caused. +fn post(arena: std.mem.Allocator, interrupt: ?*zenai.http.Interrupt, url: []const u8, content_type: []const u8, body: []const u8) !PostResult { var client: std.http.Client = .{ .allocator = arena, .io = lp.io }; defer client.deinit(); - const uri = try std.Uri.parse(url); - var req = try client.request(.POST, uri, .{ - .redirect_behavior = .unhandled, - .headers = .{ .content_type = .{ .override = content_type }, .user_agent = .{ .override = user_agent } }, - }); - defer req.deinit(); - req.transfer_encoding = .{ .content_length = body.len }; - var sink = try req.sendBodyUnflushed(&.{}); - try sink.writer.writeAll(body); - try sink.end(); - try req.connection.?.flush(); - - var redirect_buffer: [4096]u8 = undefined; - var response = try req.receiveHead(&redirect_buffer); - - const decompress_buffer: []u8 = switch (response.head.content_encoding) { - .identity => &.{}, - .zstd => try arena.alloc(u8, std.compress.zstd.default_window_len), - .deflate, .gzip => try arena.alloc(u8, std.compress.flate.max_window_len), - .compress => return error.UnsupportedCompression, - }; - var transfer_buffer: [4096]u8 = undefined; - var decompress: std.http.Decompress = undefined; - const reader = response.readerDecompressing(&transfer_buffer, &decompress, decompress_buffer); - var out: std.Io.Writer.Allocating = .init(arena); - _ = reader.streamRemaining(&out.writer) catch |err| switch (err) { - error.ReadFailed => return response.bodyErr().?, - else => |e| return e, + const status = zenai.http.fetchInterruptible(arena, &client, .{ + .location = .{ .url = url }, + .method = .POST, + .payload = body, + .headers = .{ .content_type = .{ .override = content_type }, .user_agent = .{ .override = user_agent } }, + }, &out.writer, interrupt) catch |err| { + if (interrupt) |it| if (it.isFired()) return error.LoginCancelled; + return err; }; - return .{ .status = response.head.status, .body = out.written() }; + return .{ .status = status, .body = out.written() }; } // --- Tests (pure paths) --- diff --git a/src/agent/auth/models_dev.zig b/src/agent/auth/models_dev.zig index 30efef3c9..51b3f5b5c 100644 --- a/src/agent/auth/models_dev.zig +++ b/src/agent/auth/models_dev.zig @@ -117,29 +117,8 @@ fn fetch(arena: std.mem.Allocator) ![]u8 { var client: std.http.Client = .{ .allocator = arena, .io = lp.io }; defer client.deinit(); - const uri = try std.Uri.parse(api_url); - var req = try client.request(.GET, uri, .{ .redirect_behavior = @enumFromInt(3) }); - defer req.deinit(); - try req.sendBodiless(); - - var redirect_buffer: [8 * 1024]u8 = undefined; - var response = try req.receiveHead(&redirect_buffer); - if (response.head.status != .ok) return error.HttpStatus; - - const decompress_buffer: []u8 = switch (response.head.content_encoding) { - .identity => &.{}, - .zstd => try arena.alloc(u8, std.compress.zstd.default_window_len), - .deflate, .gzip => try arena.alloc(u8, std.compress.flate.max_window_len), - .compress => return error.UnsupportedCompression, - }; - var transfer_buffer: [4096]u8 = undefined; - var decompress: std.http.Decompress = undefined; - const reader = response.readerDecompressing(&transfer_buffer, &decompress, decompress_buffer); - var out: std.Io.Writer.Allocating = .init(arena); - _ = reader.streamRemaining(&out.writer) catch |err| switch (err) { - error.ReadFailed => return response.bodyErr().?, - else => |e| return e, - }; + const res = try client.fetch(.{ .location = .{ .url = api_url }, .response_writer = &out.writer }); + if (res.status != .ok) return error.HttpStatus; return out.written(); } diff --git a/src/agent/settings.zig b/src/agent/settings.zig index dacae01af..8b94ea339 100644 --- a/src/agent/settings.zig +++ b/src/agent/settings.zig @@ -102,16 +102,18 @@ pub fn gcloudAccessToken(allocator: std.mem.Allocator) ![:0]const u8 { /// env-detected). Skips the Ollama probe so it isn't run twice at startup; the /// interactive picker only fires on detected keys, which this still catches. pub fn hasDetectableKey(opts: Config.Agent, remembered: ?Remembered) bool { - if (opts.provider) |p| return zenai.provider.envApiKey(lp.environ(), p) != null or (p == .vertex and vertexProjectMode()) or auth.subscriptionAvailable(p); - if (remembered) |r| if (r.provider) |p| { - if (zenai.provider.envApiKey(lp.environ(), p) != null) return true; - if (p == .vertex and vertexProjectMode()) return true; - if (auth.subscriptionAvailable(p)) return true; - }; + if (opts.provider) |p| return detectableKey(p); + if (remembered) |r| if (r.provider) |p| if (detectableKey(p)) return true; var buf: [zenai.provider.default_candidates.len]Credentials = undefined; return availableProviders(&buf).len > 0; } +fn detectableKey(p: Config.AiProvider) bool { + return zenai.provider.envApiKey(lp.environ(), p) != null or + (p == .vertex and vertexProjectMode()) or + auth.subscriptionAvailable(p); +} + /// Precedence: `--provider` > remembered (if its key is still set) > first /// detected. Null means no key at all (the reason is already printed). pub fn resolveCredentials(allocator: std.mem.Allocator, opts: Config.Agent, remembered: ?Remembered, allow_pick: bool) !?ResolvedProvider { From a53fbcc6ebbe1a3dcdbf3bad71890a39fbf877db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Tue, 28 Jul 2026 12:44:37 +0200 Subject: [PATCH 06/21] agent: introduce KeyOwnership union for credentials Consolidates owned_key, session, and unowned env key management into a tagged union KeyOwnership to simplify resource lifetime handling. --- src/agent/Agent.zig | 71 ++++++++++++++++++------------------------ src/agent/settings.zig | 51 +++++++++++++++++++++--------- 2 files changed, 66 insertions(+), 56 deletions(-) diff --git a/src/agent/Agent.zig b/src/agent/Agent.zig index e6c6e86d4..09fa7efd5 100644 --- a/src/agent/Agent.zig +++ b/src/agent/Agent.zig @@ -135,12 +135,9 @@ const synthesis_prompt = allocator: std.mem.Allocator, ai_client: ?zenai.provider.Client, model_credentials: ?Credentials, -/// Allocated credentials key (Vertex gcloud token) — other keys are unowned -/// env pointers. The AI client references it: free only after client deinit. -owned_key: ?[:0]const u8, -/// Active subscription (bearer) auth. Owns `model_credentials.key`, so it must -/// outlive the AI client; refreshed between turns by `refreshAuthIfNeeded`. -auth_session: ?auth.Session, +/// Ownership of `model_credentials.key`. The AI client borrows the key, so +/// deinit only after the client is gone. +key_ownership: settings.KeyOwnership, /// App data dir (owned by `App`, which outlives the agent), used to cache the /// models.dev catalog. Null when no data dir is available. app_dir: ?[]const u8, @@ -272,8 +269,7 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent var resolved: ?settings.ResolvedProvider = if (resolve) try settings.resolveCredentials(allocator, opts, remembered, will_repl) else null; // Before the ai_client errdefer, so on unwind the client goes first. - errdefer if (resolved) |r| if (r.key_owned) allocator.free(r.credentials.key); - errdefer if (resolved) |*r| if (r.session) |*s| s.deinit(); + errdefer if (resolved) |*r| r.ownership.deinit(allocator); if (will_repl and !banner_before and resolved != null) welcome.print(resolve); const llm: ?Credentials = if (resolved) |r| r.credentials else null; @@ -330,8 +326,7 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent .allocator = allocator, .ai_client = null, .model_credentials = llm, - .owned_key = if (resolved) |r| (if (r.key_owned) r.credentials.key else null) else null, - .auth_session = if (resolved) |r| r.session else null, + .key_ownership = if (resolved) |r| r.ownership else .env, .app_dir = app.app_dir_path, .no_llm_persisted = remembered_no_llm, .model_base_url = opts.base_url, @@ -365,7 +360,7 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent try self.startSession(); - self.ai_client = if (llm) |l| try zenai.provider.Client.init(lp.io, allocator, l, .{ .base_url = opts.base_url, .retry_policy = .long_running, .bill_to = hfBillTo(l.provider), .environ = lp.environ(), .account_id = if (self.auth_session) |s| s.tokens.account_id else null }) else null; + self.ai_client = if (llm) |l| try zenai.provider.Client.init(lp.io, allocator, l, .{ .base_url = opts.base_url, .retry_policy = .long_running, .bill_to = hfBillTo(l.provider), .environ = lp.environ(), .account_id = self.key_ownership.accountId() }) else null; errdefer if (self.ai_client) |c| c.deinit(allocator); if (self.ai_client) |c| c.setInterrupt(&self.http_interrupt); @@ -394,8 +389,7 @@ pub fn deinit(self: *Agent) void { self.browser.deinit(); self.notification.deinit(); if (self.ai_client) |ai_client| ai_client.deinit(self.allocator); - if (self.auth_session) |*s| s.deinit(); - if (self.owned_key) |k| self.allocator.free(k); + self.key_ownership.deinit(self.allocator); self.allocator.free(self.model); for (self.available_providers) |p| self.allocator.free(p); self.allocator.free(self.available_providers); @@ -947,7 +941,7 @@ fn handleProvider(self: *Agent, _: std.mem.Allocator, rest: []const u8) void { self.terminal.printError("could not obtain a Vertex access token: {s} (details above)", .{@errorName(err)}); return; }; - self.setProvider(.{ .provider = .vertex, .key = token }, token, null) catch |err| { + self.setProvider(.{ .provider = .vertex, .key = token }, .{ .owned = token }) catch |err| { self.allocator.free(token); self.terminal.printError("failed to set provider: {s}", .{@errorName(err)}); }; @@ -967,7 +961,7 @@ fn handleProvider(self: *Agent, _: std.mem.Allocator, rest: []const u8) void { self.terminal.printError("{s} login failed: {s}", .{ desc.label, @errorName(err) }); return; }); - self.setProvider(.{ .provider = provider, .key = owned.tokens.access_token }, null, owned) catch |err| { + self.setProvider(.{ .provider = provider, .key = owned.tokens.access_token }, .{ .session = owned }) catch |err| { owned.deinit(); self.terminal.printError("failed to set provider: {s}", .{@errorName(err)}); }; @@ -990,7 +984,7 @@ fn handleProvider(self: *Agent, _: std.mem.Allocator, rest: []const u8) void { self.terminal.printError("no llama.cpp server with a loaded model at {s}", .{self.model_base_url orelse zenai.provider.llama_cpp_default_base_url}); return; } - self.setProvider(.{ .provider = provider, .key = key }, null, null) catch |err| { + self.setProvider(.{ .provider = provider, .key = key }, .env) catch |err| { self.terminal.printError("failed to set provider: {s}", .{@errorName(err)}); }; } @@ -1000,8 +994,7 @@ fn handleProvider(self: *Agent, _: std.mem.Allocator, rest: []const u8) void { fn disableProvider(self: *Agent) void { if (self.ai_client) |client| client.deinit(self.allocator); self.ai_client = null; - if (self.owned_key) |k| self.allocator.free(k); - self.owned_key = null; + self.key_ownership.deinit(self.allocator); self.model_credentials = null; self.model_completions = null; self.no_llm_persisted = true; @@ -1016,22 +1009,19 @@ fn hfBillTo(provider: Config.AiProvider) ?[]const u8 { return std.mem.span(v); } -/// `owned_key` transfers ownership of an allocated `credentials.key` (Vertex -/// gcloud token) on success; on error the caller still owns it. `session` -/// likewise transfers a subscription session that owns `credentials.key`; the -/// previous session is freed only after the old client is gone. -fn setProvider(self: *Agent, credentials: Credentials, owned_key: ?[:0]const u8, session: ?auth.Session) !void { - const new_client = try zenai.provider.Client.init(lp.io, self.allocator, credentials, .{ .base_url = self.model_base_url, .retry_policy = .long_running, .bill_to = hfBillTo(credentials.provider), .environ = lp.environ(), .account_id = if (session) |s| s.tokens.account_id else null }); +/// `ownership` transfers whatever owns `credentials.key` on success; on error +/// the caller still owns it. The previous credential is released only after +/// the old client is gone. +fn setProvider(self: *Agent, credentials: Credentials, ownership: settings.KeyOwnership) !void { + const new_client = try zenai.provider.Client.init(lp.io, self.allocator, credentials, .{ .base_url = self.model_base_url, .retry_policy = .long_running, .bill_to = hfBillTo(credentials.provider), .environ = lp.environ(), .account_id = ownership.accountId() }); errdefer new_client.deinit(self.allocator); // A same-provider re-select (vertex token refresh) must not reset the model. const same_provider = if (self.model_credentials) |c| c.provider == credentials.provider else false; const new_model = try self.allocator.dupe(u8, if (same_provider) self.model else zenai.provider.defaultModel(credentials.provider)); if (self.ai_client) |client| client.deinit(self.allocator); - if (self.owned_key) |k| self.allocator.free(k); - if (self.auth_session) |*s| s.deinit(); - self.owned_key = owned_key; - self.auth_session = session; + self.key_ownership.deinit(self.allocator); + self.key_ownership = ownership; new_client.setInterrupt(&self.http_interrupt); self.ai_client = new_client; self.model_credentials = credentials; @@ -1059,16 +1049,16 @@ fn setProvider(self: *Agent, credentials: Credentials, owned_key: ?[:0]const u8, /// Runs between turns on the single agent thread, never during a request, so /// the displaced token is never dereferenced after it's freed here. fn refreshAuthIfNeeded(self: *Agent) void { - if (self.auth_session) |*session| { - const displaced = session.ensureFresh() catch |err| { - self.terminal.printError("could not refresh the subscription token: {s}", .{@errorName(err)}); - return; - }; - if (displaced) |old| { - if (self.ai_client) |client| client.setApiKey(session.tokens.access_token); - if (self.model_credentials) |*c| c.key = session.tokens.access_token; - old.deinit(session.allocator); - } + if (self.key_ownership != .session) return; + const session = &self.key_ownership.session; + const displaced = session.ensureFresh() catch |err| { + self.terminal.printError("could not refresh the subscription token: {s}", .{@errorName(err)}); + return; + }; + if (displaced) |old| { + if (self.ai_client) |client| client.setApiKey(session.tokens.access_token); + if (self.model_credentials) |*c| c.key = session.tokens.access_token; + old.deinit(session.allocator); } } @@ -1633,7 +1623,7 @@ fn formatApiError(self: *Agent, client: zenai.provider.Client, err: anyerror) [] const e = client.lastError(); const status = e.status orelse return @errorName(err); const hint = if (status == 401 and client == .vertex) - if (self.owned_key != null) + if (self.key_ownership == .owned) " (Vertex token may have expired; run /provider vertex to refresh)" else " (Vertex express mode needs an express API key — a Gemini Developer key won't work)" @@ -1934,8 +1924,7 @@ pub fn listModels(allocator: std.mem.Allocator, opts: Config.Agent) !void { } var resolved = (try settings.resolveCredentials(allocator, opts, null, false)) orelse return error.MissingProvider; const llm = resolved.credentials; - defer if (resolved.key_owned) allocator.free(llm.key); - defer if (resolved.session) |*s| s.deinit(); + defer resolved.ownership.deinit(allocator); var arena: std.heap.ArenaAllocator = .init(allocator); defer arena.deinit(); diff --git a/src/agent/settings.zig b/src/agent/settings.zig index 8b94ea339..9e5f86c2a 100644 --- a/src/agent/settings.zig +++ b/src/agent/settings.zig @@ -39,13 +39,35 @@ pub const api_keys_hint = "ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_API_KEY, HF pub const ResolvedProvider = struct { credentials: Credentials, source: enum { flag, remembered, detected, picked }, - /// Key allocated (Vertex gcloud token) rather than an env pointer; the - /// caller frees it, only after the client that references it is gone. - key_owned: bool = false, - /// Set for subscription (bearer) auth. Owns `credentials.key`, so it must - /// outlive the AI client; the caller takes ownership. When set, `key_owned` - /// stays false — the session frees the key, not the caller directly. - session: ?auth.Session = null, + ownership: KeyOwnership = .env, +}; + +/// How the active `Credentials.key` is owned. `deinit` releases whatever the +/// active variant holds — call it only after the AI client that borrows the +/// key is gone. +pub const KeyOwnership = union(enum) { + /// Unowned env-var pointer. + env, + /// Allocated key (Vertex gcloud token); aliases the active `Credentials.key`. + owned: [:0]const u8, + /// Subscription (bearer) auth owning the key; refreshed between turns. + session: auth.Session, + + pub fn deinit(self: *KeyOwnership, allocator: std.mem.Allocator) void { + switch (self.*) { + .env => {}, + .owned => |k| allocator.free(k), + .session => |*s| s.deinit(), + } + self.* = .env; + } + + pub fn accountId(self: *const KeyOwnership) ?[]const u8 { + return switch (self.*) { + .session => |*s| s.tokens.account_id, + else => null, + }; + } }; /// Probe a keyless local provider (Ollama, llama.cpp): its env key is a @@ -120,7 +142,7 @@ pub fn resolveCredentials(allocator: std.mem.Allocator, opts: Config.Agent, reme if (opts.provider) |p| { if (p == .vertex and vertexProjectMode()) { const token = try gcloudAccessToken(allocator); - return .{ .credentials = .{ .provider = p, .key = token }, .source = .flag, .key_owned = true }; + return .{ .credentials = .{ .provider = p, .key = token }, .source = .flag, .ownership = .{ .owned = token } }; } // A subscription takes priority over an API key; null = not a // subscription provider (or none available), so fall through. @@ -146,7 +168,7 @@ pub fn resolveCredentials(allocator: std.mem.Allocator, opts: Config.Agent, reme if (p == .vertex and vertexProjectMode()) { // On failure the reason is already printed; fall through to detection. if (gcloudAccessToken(allocator)) |token| { - return .{ .credentials = .{ .provider = p, .key = token }, .source = .remembered, .key_owned = true }; + return .{ .credentials = .{ .provider = p, .key = token }, .source = .remembered, .ownership = .{ .owned = token } }; } else |_| {} } else { // Subscription takes priority over an API key; both fall through to @@ -195,7 +217,7 @@ pub fn resolveCredentials(allocator: std.mem.Allocator, opts: Config.Agent, reme fn finishResolved(allocator: std.mem.Allocator, credentials: Credentials, source: @FieldType(ResolvedProvider, "source")) !ResolvedProvider { if (credentials.provider == .vertex and vertexProjectMode()) { const token = try gcloudAccessToken(allocator); - return .{ .credentials = .{ .provider = .vertex, .key = token }, .source = source, .key_owned = true }; + return .{ .credentials = .{ .provider = .vertex, .key = token }, .source = source, .ownership = .{ .owned = token } }; } if (auth.descriptorFor(credentials.provider) != null and credentials.key.len == 0) { if (try subscriptionResolved(allocator, credentials.provider, source)) |resolved| return resolved; @@ -204,10 +226,9 @@ fn finishResolved(allocator: std.mem.Allocator, credentials: Credentials, source return .{ .credentials = credentials, .source = source }; } -/// Load a stored subscription session and wrap it as a resolved credential. The -/// returned `session` owns `credentials.key`; the caller must keep it alive as -/// long as the AI client and free it with `session.deinit`. Null when the user -/// hasn't logged in. +/// Load a stored subscription session and wrap it as a resolved credential. +/// The session owns `credentials.key`; the caller takes ownership via +/// `ownership.deinit`. Null when the user hasn't logged in. fn subscriptionResolved(allocator: std.mem.Allocator, provider: Config.AiProvider, source: @FieldType(ResolvedProvider, "source")) !?ResolvedProvider { const session = (try auth.sessionFor(allocator, provider)) orelse return null; // Name the credential in effect — a set-but-ignored API key would otherwise @@ -220,7 +241,7 @@ fn subscriptionResolved(allocator: std.mem.Allocator, provider: Config.AiProvide return .{ .credentials = .{ .provider = provider, .key = session.tokens.access_token }, .source = source, - .session = session, + .ownership = .{ .session = session }, }; } From 8b6f7fe0647447d3c4ea6baee075efbe292988bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Tue, 28 Jul 2026 13:05:46 +0200 Subject: [PATCH 07/21] agent: simplify credential and key ownership handling --- build.zig.zon | 4 +- src/agent/Agent.zig | 114 +++++++++++++++++++--------------------- src/agent/settings.zig | 116 ++++++++++++++++++++++------------------- 3 files changed, 119 insertions(+), 115 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 68845e049..916a0b3a3 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -36,8 +36,8 @@ .hash = "sqlite3-3.53.2-DMxLWuAOAAA_Px0arJOIOaP4AKEu5prbsQgPMA35W1zz", }, .zenai = .{ - .url = "git+https://github.com/lightpanda-io/zenai.git#58489db10aaa22204b0347c0938ee3293c8cd3e9", - .hash = "zenai-0.0.0-iOY_VAkABgBXETyPYATPQE2D3_4eF-A8k0Nt_c0G3nKt", + .url = "git+https://github.com/lightpanda-io/zenai.git#ceb6a7bf54ad032870da56b5ac6f1da0e476de6e", + .hash = "zenai-0.0.0-iOY_VAYABgCAHU20NPcSQotRdYG9aJf-ASE86YsN-AMJ", }, .isocline = .{ .url = "git+https://github.com/arrufat/isocline?ref=lightpanda#832a9fe25f5f4458fcc47b5acc7c21db669c2f47", diff --git a/src/agent/Agent.zig b/src/agent/Agent.zig index 09fa7efd5..7894feb5e 100644 --- a/src/agent/Agent.zig +++ b/src/agent/Agent.zig @@ -29,7 +29,7 @@ const Command = lp.Command; const Schema = lp.Schema; const Recorder = lp.Recorder; const ScriptRuntime = lp.Runtime; -const Credentials = zenai.provider.Credentials; +const Candidate = zenai.provider.Candidate; const App = @import("../App.zig"); const CDPNode = @import("../cdp/Node.zig"); @@ -134,10 +134,9 @@ const synthesis_prompt = allocator: std.mem.Allocator, ai_client: ?zenai.provider.Client, -model_credentials: ?Credentials, -/// Ownership of `model_credentials.key`. The AI client borrows the key, so -/// deinit only after the client is gone. -key_ownership: settings.KeyOwnership, +/// The active LLM credential (null = LLM disabled). The AI client borrows +/// its key, so deinit only after the client is gone. +credential: ?settings.Credential, /// App data dir (owned by `App`, which outlives the agent), used to cache the /// models.dev catalog. Null when no data dir is available. app_dir: ?[]const u8, @@ -197,7 +196,7 @@ api_error_buf: [512]u8 = undefined, api_error_detail: ?[]const u8 = null, pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent { - var providers_buf: [@typeInfo(Config.AiProvider).@"enum".fields.len]Credentials = undefined; + var providers_buf: [@typeInfo(Config.AiProvider).@"enum".fields.len]Candidate = undefined; const found_providers = settings.availableProviders(&providers_buf); const available_providers = try allocator.alloc([]const u8, found_providers.len); var provider_count: usize = 0; @@ -269,12 +268,11 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent var resolved: ?settings.ResolvedProvider = if (resolve) try settings.resolveCredentials(allocator, opts, remembered, will_repl) else null; // Before the ai_client errdefer, so on unwind the client goes first. - errdefer if (resolved) |*r| r.ownership.deinit(allocator); + errdefer if (resolved) |*r| r.credential.deinit(allocator); if (will_repl and !banner_before and resolved != null) welcome.print(resolve); - const llm: ?Credentials = if (resolved) |r| r.credentials else null; - if (llm == null and requires_llm) { + if (resolved == null and requires_llm) { if (opts.no_llm) { std.debug.print("--no-llm forbids LLM use; drop it to run this mode.\n", .{}); } @@ -286,10 +284,10 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent // The REPL skips this network round trip for snappy startup; an invalid // model surfaces on the first turn instead. - if (llm) |l| if (!will_repl) { - const remembered_matches = remembered != null and remembered.?.provider == l.provider; + if (resolved) |*r| if (!will_repl) { + const remembered_matches = remembered != null and remembered.?.provider == r.credential.provider; const explicit = opts.model != null or remembered_matches; - switch (try settings.reconcileModel(allocator, l, model, opts.base_url, explicit)) { + switch (try settings.reconcileModel(allocator, &r.credential, model, opts.base_url, explicit)) { .use => |m| { allocator.free(model); model = m; @@ -298,14 +296,14 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent } }; - const effort = settings.resolveEffort(opts, remembered, will_repl, if (resolved) |r| r.credentials.provider else null); + const effort = settings.resolveEffort(opts, remembered, will_repl, if (resolved) |r| r.credential.provider else null); const verbosity = settings.resolveVerbosity(opts, remembered); const stream_enabled = settings.resolveStream(remembered); browser_tools.search_engine = settings.resolveSearchEngine(remembered); if (resolved) |r| { if (r.source == .picked) { - settings.saveRemembered(.{ .provider = r.credentials.provider, .model = model, .effort = effort, .verbosity = verbosity, .stream = stream_enabled, .search_engine = browser_tools.search_engine }) catch {}; + settings.saveRemembered(.{ .provider = r.credential.provider, .model = model, .effort = effort, .verbosity = verbosity, .stream = stream_enabled, .search_engine = browser_tools.search_engine }) catch {}; } // provider/model now live in the status bar; just space before the help std.debug.print("\n", .{}); @@ -325,8 +323,7 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent self.* = .{ .allocator = allocator, .ai_client = null, - .model_credentials = llm, - .key_ownership = if (resolved) |r| r.ownership else .env, + .credential = if (resolved) |r| r.credential else null, .app_dir = app.app_dir_path, .no_llm_persisted = remembered_no_llm, .model_base_url = opts.base_url, @@ -360,7 +357,7 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent try self.startSession(); - self.ai_client = if (llm) |l| try zenai.provider.Client.init(lp.io, allocator, l, .{ .base_url = opts.base_url, .retry_policy = .long_running, .bill_to = hfBillTo(l.provider), .environ = lp.environ(), .account_id = self.key_ownership.accountId() }) else null; + self.ai_client = if (self.credential) |*c| try zenai.provider.Client.init(lp.io, allocator, c.provider, c.keySlice(), .{ .base_url = opts.base_url, .retry_policy = .long_running, .bill_to = hfBillTo(c.provider), .environ = lp.environ(), .account_id = c.accountId() }) else null; errdefer if (self.ai_client) |c| c.deinit(allocator); if (self.ai_client) |c| c.setInterrupt(&self.http_interrupt); @@ -389,7 +386,7 @@ pub fn deinit(self: *Agent) void { self.browser.deinit(); self.notification.deinit(); if (self.ai_client) |ai_client| ai_client.deinit(self.allocator); - self.key_ownership.deinit(self.allocator); + if (self.credential) |*c| c.deinit(self.allocator); self.allocator.free(self.model); for (self.available_providers) |p| self.allocator.free(p); self.allocator.free(self.available_providers); @@ -855,7 +852,7 @@ const provider_off_keyword = "null"; const local_providers = [_]Config.AiProvider{ .ollama, .llama_cpp }; fn requireLlm(self: *Agent, name: []const u8) bool { - if (self.model_credentials == null) { + if (self.credential == null) { self.terminal.printError("{s} requires an LLM — " ++ llm_setup_hint ++ ".", .{name}); return false; } @@ -886,7 +883,7 @@ fn handleModel(self: *Agent, _: std.mem.Allocator, rest: []const u8) void { /// credentials it persists `provider = null` only when that's an intentional /// preference (`no_llm_persisted`); a transient --no-llm run reports without saving. fn reportSaved(self: *Agent, label: []const u8, value: []const u8) void { - const provider: ?Config.AiProvider = if (self.model_credentials) |c| c.provider else null; + const provider: ?Config.AiProvider = if (self.credential) |c| c.provider else null; // A transient --no-llm run has no provider and no intent to persist one; // report without saving so we don't clobber the remembered selection. if (provider == null and !self.no_llm_persisted) { @@ -911,7 +908,7 @@ fn handleProvider(self: *Agent, _: std.mem.Allocator, rest: []const u8) void { const trimmed = std.mem.trim(u8, rest, &std.ascii.whitespace); if (trimmed.len == 0) { - if (self.model_credentials) |c| { + if (self.credential) |c| { self.terminal.printInfo("Current provider: {s}", .{@tagName(c.provider)}); } else { self.terminal.printInfo("Current provider: null — LLM disabled", .{}); @@ -932,7 +929,7 @@ fn handleProvider(self: *Agent, _: std.mem.Allocator, rest: []const u8) void { // after logging into its CLI) falls through instead of no-op'ing. const vertex_project = provider == .vertex and settings.vertexProjectMode(); const sub_desc = auth.descriptorFor(provider); - if (self.model_credentials) |current| if (provider == current.provider and !vertex_project and sub_desc == null) { + if (self.credential) |current| if (provider == current.provider and !vertex_project and sub_desc == null) { self.terminal.printInfo("provider: {s}", .{@tagName(provider)}); return; }; @@ -941,7 +938,7 @@ fn handleProvider(self: *Agent, _: std.mem.Allocator, rest: []const u8) void { self.terminal.printError("could not obtain a Vertex access token: {s} (details above)", .{@errorName(err)}); return; }; - self.setProvider(.{ .provider = .vertex, .key = token }, .{ .owned = token }) catch |err| { + self.setProvider(.{ .provider = .vertex, .key = .{ .owned = token } }) catch |err| { self.allocator.free(token); self.terminal.printError("failed to set provider: {s}", .{@errorName(err)}); }; @@ -961,7 +958,7 @@ fn handleProvider(self: *Agent, _: std.mem.Allocator, rest: []const u8) void { self.terminal.printError("{s} login failed: {s}", .{ desc.label, @errorName(err) }); return; }); - self.setProvider(.{ .provider = provider, .key = owned.tokens.access_token }, .{ .session = owned }) catch |err| { + self.setProvider(.{ .provider = provider, .key = .{ .session = owned } }) catch |err| { owned.deinit(); self.terminal.printError("failed to set provider: {s}", .{@errorName(err)}); }; @@ -984,7 +981,7 @@ fn handleProvider(self: *Agent, _: std.mem.Allocator, rest: []const u8) void { self.terminal.printError("no llama.cpp server with a loaded model at {s}", .{self.model_base_url orelse zenai.provider.llama_cpp_default_base_url}); return; } - self.setProvider(.{ .provider = provider, .key = key }, .env) catch |err| { + self.setProvider(.{ .provider = provider, .key = .{ .env = key } }) catch |err| { self.terminal.printError("failed to set provider: {s}", .{@errorName(err)}); }; } @@ -994,8 +991,8 @@ fn handleProvider(self: *Agent, _: std.mem.Allocator, rest: []const u8) void { fn disableProvider(self: *Agent) void { if (self.ai_client) |client| client.deinit(self.allocator); self.ai_client = null; - self.key_ownership.deinit(self.allocator); - self.model_credentials = null; + if (self.credential) |*c| c.deinit(self.allocator); + self.credential = null; self.model_completions = null; self.no_llm_persisted = true; self.reportSaved("provider", provider_off_keyword); @@ -1009,34 +1006,33 @@ fn hfBillTo(provider: Config.AiProvider) ?[]const u8 { return std.mem.span(v); } -/// `ownership` transfers whatever owns `credentials.key` on success; on error -/// the caller still owns it. The previous credential is released only after +/// `credential` transfers ownership of its key resources on success; on error +/// the caller still owns them. The previous credential is released only after /// the old client is gone. -fn setProvider(self: *Agent, credentials: Credentials, ownership: settings.KeyOwnership) !void { - const new_client = try zenai.provider.Client.init(lp.io, self.allocator, credentials, .{ .base_url = self.model_base_url, .retry_policy = .long_running, .bill_to = hfBillTo(credentials.provider), .environ = lp.environ(), .account_id = ownership.accountId() }); +fn setProvider(self: *Agent, credential: settings.Credential) !void { + const new_client = try zenai.provider.Client.init(lp.io, self.allocator, credential.provider, credential.keySlice(), .{ .base_url = self.model_base_url, .retry_policy = .long_running, .bill_to = hfBillTo(credential.provider), .environ = lp.environ(), .account_id = credential.accountId() }); errdefer new_client.deinit(self.allocator); // A same-provider re-select (vertex token refresh) must not reset the model. - const same_provider = if (self.model_credentials) |c| c.provider == credentials.provider else false; - const new_model = try self.allocator.dupe(u8, if (same_provider) self.model else zenai.provider.defaultModel(credentials.provider)); + const same_provider = if (self.credential) |c| c.provider == credential.provider else false; + const new_model = try self.allocator.dupe(u8, if (same_provider) self.model else zenai.provider.defaultModel(credential.provider)); if (self.ai_client) |client| client.deinit(self.allocator); - self.key_ownership.deinit(self.allocator); - self.key_ownership = ownership; + if (self.credential) |*c| c.deinit(self.allocator); + self.credential = credential; new_client.setInterrupt(&self.http_interrupt); self.ai_client = new_client; - self.model_credentials = credentials; self.no_llm_persisted = false; self.model_completions = null; self.allocator.free(self.model); self.model = new_model; - if (auth.descriptorFor(credentials.provider)) |d| { - self.terminal.printInfo("provider: {s} ({s})", .{ @tagName(credentials.provider), d.label }); + if (auth.descriptorFor(credential.provider)) |d| { + self.terminal.printInfo("provider: {s} ({s})", .{ @tagName(credential.provider), d.label }); } else { - self.terminal.printInfo("provider: {s}", .{@tagName(credentials.provider)}); + self.terminal.printInfo("provider: {s}", .{@tagName(credential.provider)}); } - if (zenai.provider.defaultEffort(credentials.provider)) |e| if (e != self.effort) { + if (zenai.provider.defaultEffort(credential.provider)) |e| if (e != self.effort) { self.effort = e; - self.terminal.printInfo("effort: {s} ({s} default)", .{ @tagName(e), @tagName(credentials.provider) }); + self.terminal.printInfo("effort: {s} ({s} default)", .{ @tagName(e), @tagName(credential.provider) }); }; self.reportSaved("model", self.model); // Prime the completion cache on the command, not on a `/model` keystroke. @@ -1045,19 +1041,20 @@ fn setProvider(self: *Agent, credentials: Credentials, ownership: settings.KeyOw /// Keep a subscription (bearer) token current before a model request: when the /// active session's token is near expiry, re-import from the source and repoint -/// the client and credentials at the fresh token. A no-op for API-key auth. -/// Runs between turns on the single agent thread, never during a request, so -/// the displaced token is never dereferenced after it's freed here. +/// the client at the fresh token (`keySlice` readers see it automatically). +/// A no-op for API-key auth. Runs between turns on the single agent thread, +/// never during a request, so the displaced token is never dereferenced after +/// it's freed here. fn refreshAuthIfNeeded(self: *Agent) void { - if (self.key_ownership != .session) return; - const session = &self.key_ownership.session; + const cred = if (self.credential) |*c| c else return; + if (cred.key != .session) return; + const session = &cred.key.session; const displaced = session.ensureFresh() catch |err| { self.terminal.printError("could not refresh the subscription token: {s}", .{@errorName(err)}); return; }; if (displaced) |old| { if (self.ai_client) |client| client.setApiKey(session.tokens.access_token); - if (self.model_credentials) |*c| c.key = session.tokens.access_token; old.deinit(session.allocator); } } @@ -1623,7 +1620,7 @@ fn formatApiError(self: *Agent, client: zenai.provider.Client, err: anyerror) [] const e = client.lastError(); const status = e.status orelse return @errorName(err); const hint = if (status == 401 and client == .vertex) - if (self.key_ownership == .owned) + if (self.credential != null and self.credential.?.key == .owned) " (Vertex token may have expired; run /provider vertex to refresh)" else " (Vertex express mode needs an express API key — a Gemini Developer key won't work)" @@ -1923,18 +1920,17 @@ pub fn listModels(allocator: std.mem.Allocator, opts: Config.Agent) !void { return error.ConflictingFlags; } var resolved = (try settings.resolveCredentials(allocator, opts, null, false)) orelse return error.MissingProvider; - const llm = resolved.credentials; - defer resolved.ownership.deinit(allocator); + defer resolved.credential.deinit(allocator); var arena: std.heap.ArenaAllocator = .init(allocator); defer arena.deinit(); // A subscription provider can't list models via the provider API; use the // free models.dev catalog. - const ids: []const []const u8 = if (auth.descriptorFor(llm.provider)) |desc| + const ids: []const []const u8 = if (auth.descriptorFor(resolved.credential.provider)) |desc| models_dev.modelIds(arena.allocator(), desc.models_dev_id, auth.dataDir(arena.allocator())) else - zenai.provider.listChatModelIds(lp.io, allocator, arena.allocator(), llm.provider, llm.key, .{ .base_url = opts.base_url, .environ = lp.environ() }) catch |err| { - if (llm.provider == .vertex and !settings.vertexProjectMode()) { + zenai.provider.listChatModelIds(lp.io, allocator, arena.allocator(), resolved.credential.provider, resolved.credential.keySlice(), .{ .base_url = opts.base_url, .environ = lp.environ() }) catch |err| { + if (resolved.credential.provider == .vertex and !settings.vertexProjectMode()) { std.debug.print("Vertex express mode cannot list models (the endpoint requires OAuth); set GOOGLE_CLOUD_PROJECT for project mode.\n", .{}); } return err; @@ -1997,25 +1993,25 @@ fn completionProviders(context: *anyopaque, arena: std.mem.Allocator) []const [] /// success or empty so the per-keystroke hinter pays the round-trip once. fn completionModels(context: *anyopaque, _: std.mem.Allocator) []const []const u8 { const self: *Agent = @ptrCast(@alignCast(context)); - const llm = self.model_credentials orelse return &.{}; - if (self.model_completions) |c| if (c.provider == llm.provider) return c.ids; + const cred = if (self.credential) |*c| c else return &.{}; + if (self.model_completions) |c| if (c.provider == cred.provider) return c.ids; _ = self.model_completion_arena.reset(.retain_capacity); const arena = self.model_completion_arena.allocator(); // A subscription provider can't hit the provider's `/models` endpoint, so // list from the free, unauthenticated models.dev catalog instead. - const ids = if (auth.descriptorFor(llm.provider)) |desc| + const ids = if (auth.descriptorFor(cred.provider)) |desc| models_dev.modelIds(arena, desc.models_dev_id, self.app_dir) else zenai.provider.listChatModelIds( lp.io, self.allocator, arena, - llm.provider, - llm.key, + cred.provider, + cred.keySlice(), .{ .base_url = self.model_base_url, .environ = lp.environ() }, ) catch &.{}; - self.model_completions = .{ .provider = llm.provider, .ids = ids }; + self.model_completions = .{ .provider = cred.provider, .ids = ids }; return ids; } diff --git a/src/agent/settings.zig b/src/agent/settings.zig index 9e5f86c2a..f428751d5 100644 --- a/src/agent/settings.zig +++ b/src/agent/settings.zig @@ -29,7 +29,7 @@ const Config = lp.Config; const picker = @import("picker.zig"); const string = @import("../string.zig"); const auth = @import("auth/auth.zig"); -const Credentials = zenai.provider.Credentials; +const Candidate = zenai.provider.Candidate; pub const api_keys_hint = "ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_API_KEY, HF_TOKEN, AI_GATEWAY_API_KEY, or MISTRAL_API_KEY (Vertex AI: VERTEX_API_KEY, or GOOGLE_CLOUD_PROJECT via gcloud; Codex: a ChatGPT subscription via /provider codex)"; @@ -37,43 +37,52 @@ pub const api_keys_hint = "ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_API_KEY, HF /// only when no `--provider` was given AND no env key exists (the caller /// decides whether that's fatal — basic REPL tolerates it). pub const ResolvedProvider = struct { - credentials: Credentials, + credential: Credential, source: enum { flag, remembered, detected, picked }, - ownership: KeyOwnership = .env, }; -/// How the active `Credentials.key` is owned. `deinit` releases whatever the -/// active variant holds — call it only after the AI client that borrows the -/// key is gone. -pub const KeyOwnership = union(enum) { - /// Unowned env-var pointer. - env, - /// Allocated key (Vertex gcloud token); aliases the active `Credentials.key`. - owned: [:0]const u8, - /// Subscription (bearer) auth owning the key; refreshed between turns. - session: auth.Session, +/// The provider credential in effect: the key lives in exactly one place — +/// inside the variant that owns it. The AI client borrows `keySlice()`, so +/// deinit only after that client is gone. +pub const Credential = struct { + provider: Config.AiProvider, + key: union(enum) { + /// Unowned env-var pointer. + env: [:0]const u8, + /// Allocated key (Vertex gcloud token). + owned: [:0]const u8, + /// Subscription (bearer) auth; the key is the access token, refreshed + /// between turns. + session: auth.Session, + }, - pub fn deinit(self: *KeyOwnership, allocator: std.mem.Allocator) void { - switch (self.*) { + pub fn keySlice(self: *const Credential) [:0]const u8 { + return switch (self.key) { + .env, .owned => |k| k, + .session => |*s| s.tokens.access_token, + }; + } + + pub fn accountId(self: *const Credential) ?[]const u8 { + return switch (self.key) { + .session => |*s| s.tokens.account_id, + else => null, + }; + } + + pub fn deinit(self: *Credential, allocator: std.mem.Allocator) void { + switch (self.key) { .env => {}, .owned => |k| allocator.free(k), .session => |*s| s.deinit(), } - self.* = .env; - } - - pub fn accountId(self: *const KeyOwnership) ?[]const u8 { - return switch (self.*) { - .session => |*s| s.tokens.account_id, - else => null, - }; } }; /// Probe a keyless local provider (Ollama, llama.cpp): its env key is a /// placeholder, so the only honest availability signal is the server answering /// `/v1/models` with a loaded model. Null means no server responded. -pub fn detectLocalProvider(allocator: std.mem.Allocator, tag: Config.AiProvider, base_url: ?[:0]const u8) ?Credentials { +pub fn detectLocalProvider(allocator: std.mem.Allocator, tag: Config.AiProvider, base_url: ?[:0]const u8) ?Candidate { const key = zenai.provider.envApiKey(lp.environ(), tag) orelse return null; var arena: std.heap.ArenaAllocator = .init(allocator); defer arena.deinit(); @@ -126,7 +135,7 @@ pub fn gcloudAccessToken(allocator: std.mem.Allocator) ![:0]const u8 { pub fn hasDetectableKey(opts: Config.Agent, remembered: ?Remembered) bool { if (opts.provider) |p| return detectableKey(p); if (remembered) |r| if (r.provider) |p| if (detectableKey(p)) return true; - var buf: [zenai.provider.default_candidates.len]Credentials = undefined; + var buf: [zenai.provider.default_candidates.len]Candidate = undefined; return availableProviders(&buf).len > 0; } @@ -142,7 +151,7 @@ pub fn resolveCredentials(allocator: std.mem.Allocator, opts: Config.Agent, reme if (opts.provider) |p| { if (p == .vertex and vertexProjectMode()) { const token = try gcloudAccessToken(allocator); - return .{ .credentials = .{ .provider = p, .key = token }, .source = .flag, .ownership = .{ .owned = token } }; + return .{ .credential = .{ .provider = p, .key = .{ .owned = token } }, .source = .flag }; } // A subscription takes priority over an API key; null = not a // subscription provider (or none available), so fall through. @@ -161,33 +170,33 @@ pub fn resolveCredentials(allocator: std.mem.Allocator, opts: Config.Agent, reme ); return error.MissingApiKey; }; - return .{ .credentials = .{ .provider = p, .key = key }, .source = .flag }; + return .{ .credential = .{ .provider = p, .key = .{ .env = key } }, .source = .flag }; } if (remembered) |r| if (r.provider) |p| { if (p == .vertex and vertexProjectMode()) { // On failure the reason is already printed; fall through to detection. if (gcloudAccessToken(allocator)) |token| { - return .{ .credentials = .{ .provider = p, .key = token }, .source = .remembered, .ownership = .{ .owned = token } }; + return .{ .credential = .{ .provider = p, .key = .{ .owned = token } }, .source = .remembered }; } else |_| {} } else { // Subscription takes priority over an API key; both fall through to // detection on miss. if (try subscriptionResolved(allocator, p, .remembered)) |resolved| return resolved; if (zenai.provider.envApiKey(lp.environ(), p)) |key| { - return .{ .credentials = .{ .provider = p, .key = key }, .source = .remembered }; + return .{ .credential = .{ .provider = p, .key = .{ .env = key } }, .source = .remembered }; } } }; - var buf: [zenai.provider.default_candidates.len]Credentials = undefined; + var buf: [zenai.provider.default_candidates.len]Candidate = undefined; const found = availableProviders(&buf); if (found.len == 0) { if (detectLocalProvider(allocator, .ollama, opts.base_url)) |creds| { - return .{ .credentials = creds, .source = .detected }; + return .{ .credential = .{ .provider = creds.provider, .key = .{ .env = creds.key } }, .source = .detected }; } if (detectLocalProvider(allocator, .llama_cpp, opts.base_url)) |creds| { - return .{ .credentials = creds, .source = .detected }; + return .{ .credential = .{ .provider = creds.provider, .key = .{ .env = creds.key } }, .source = .detected }; } std.debug.print( \\No API key detected. Set {s}, or run a local Ollama or llama.cpp server with a loaded model. @@ -214,20 +223,20 @@ pub fn resolveCredentials(allocator: std.mem.Allocator, opts: Config.Agent, reme /// Swaps a placeholder credential for a live token: a gcloud token for /// project-mode Vertex, or a stored subscription session for the subscription /// (empty-key) placeholder. -fn finishResolved(allocator: std.mem.Allocator, credentials: Credentials, source: @FieldType(ResolvedProvider, "source")) !ResolvedProvider { - if (credentials.provider == .vertex and vertexProjectMode()) { +fn finishResolved(allocator: std.mem.Allocator, candidate: Candidate, source: @FieldType(ResolvedProvider, "source")) !ResolvedProvider { + if (candidate.provider == .vertex and vertexProjectMode()) { const token = try gcloudAccessToken(allocator); - return .{ .credentials = .{ .provider = .vertex, .key = token }, .source = source, .ownership = .{ .owned = token } }; + return .{ .credential = .{ .provider = .vertex, .key = .{ .owned = token } }, .source = source }; } - if (auth.descriptorFor(credentials.provider) != null and credentials.key.len == 0) { - if (try subscriptionResolved(allocator, credentials.provider, source)) |resolved| return resolved; + if (auth.descriptorFor(candidate.provider) != null and candidate.key.len == 0) { + if (try subscriptionResolved(allocator, candidate.provider, source)) |resolved| return resolved; return error.MissingApiKey; } - return .{ .credentials = credentials, .source = source }; + return .{ .credential = .{ .provider = candidate.provider, .key = .{ .env = candidate.key } }, .source = source }; } /// Load a stored subscription session and wrap it as a resolved credential. -/// The session owns `credentials.key`; the caller takes ownership via +/// The session owns the key; the caller takes ownership via /// `ownership.deinit`. Null when the user hasn't logged in. fn subscriptionResolved(allocator: std.mem.Allocator, provider: Config.AiProvider, source: @FieldType(ResolvedProvider, "source")) !?ResolvedProvider { const session = (try auth.sessionFor(allocator, provider)) orelse return null; @@ -239,9 +248,8 @@ fn subscriptionResolved(allocator: std.mem.Allocator, provider: Config.AiProvide std.debug.print("{s}: using your {s}.\n", .{ @tagName(provider), session.descriptor.label }); } return .{ - .credentials = .{ .provider = provider, .key = session.tokens.access_token }, + .credential = .{ .provider = provider, .key = .{ .session = session } }, .source = source, - .ownership = .{ .session = session }, }; } @@ -296,14 +304,14 @@ pub fn saveRemembered(remembered: Remembered) !void { /// a live probe (`detectLocalProvider`), too costly for an unconditional startup scan. /// Vertex project mode joins with a placeholder key — no subprocess during a /// scan; the gcloud token is fetched on selection (`finishResolved`). -pub fn availableProviders(buf: []Credentials) []Credentials { +pub fn availableProviders(buf: []Candidate) []Candidate { var found = zenai.provider.detectKeys(lp.environ(), buf, zenai.provider.default_candidates); // A subscription takes priority over an API key: offer it as a bearer // placeholder (replacing any API-key entry) that `finishResolved` swaps for a // live token on selection, mirroring Vertex project mode below. for (auth.registry) |desc| { if (!auth.subscriptionAvailable(desc.provider)) continue; - const placeholder: Credentials = .{ .provider = desc.provider, .key = "" }; + const placeholder: Candidate = .{ .provider = desc.provider, .key = "" }; if (indexOfProvider(found, desc.provider)) |i| { found[i] = placeholder; } else if (found.len < buf.len) { @@ -318,8 +326,8 @@ pub fn availableProviders(buf: []Credentials) []Credentials { return found; } -fn indexOfProvider(creds: []const Credentials, provider: Config.AiProvider) ?usize { - for (creds, 0..) |c, i| if (c.provider == provider) return i; +fn indexOfProvider(candidates: []const Candidate, provider: Config.AiProvider) ?usize { + for (candidates, 0..) |c, i| if (c.provider == provider) return i; return null; } @@ -329,9 +337,9 @@ pub fn resolveModelName(opts: Config.Agent, resolved: ?ResolvedProvider, remembe // Use the remembered model whenever it matches the chosen provider, // not only when the provider itself came from the remembered file. if (remembered) |rem| { - if (rem.provider) |p| if (p == r.credentials.provider) return rem.model; + if (rem.provider) |p| if (p == r.credential.provider) return rem.model; } - return zenai.provider.defaultModel(r.credentials.provider); + return zenai.provider.defaultModel(r.credential.provider); } return ""; } @@ -383,30 +391,30 @@ pub const ReconciledModel = union(enum) { /// model when unloaded; cloud defaults are hardcoded real models, trusted as-is. pub fn reconcileModel( allocator: std.mem.Allocator, - llm: Credentials, + credential: *const Credential, desired: []const u8, base_url: ?[:0]const u8, explicit: bool, ) !ReconciledModel { // A subscription provider can't list models via the provider API; trust the // desired model as-is rather than error against `/models`. - if (auth.descriptorFor(llm.provider) != null) return .{ .use = try allocator.dupe(u8, desired) }; + if (auth.descriptorFor(credential.provider) != null) return .{ .use = try allocator.dupe(u8, desired) }; var arena: std.heap.ArenaAllocator = .init(allocator); defer arena.deinit(); - const ids: []const []const u8 = zenai.provider.listChatModelIds(lp.io, allocator, arena.allocator(), llm.provider, llm.key, .{ .base_url = base_url, .environ = lp.environ() }) catch &.{}; + const ids: []const []const u8 = zenai.provider.listChatModelIds(lp.io, allocator, arena.allocator(), credential.provider, credential.keySlice(), .{ .base_url = base_url, .environ = lp.environ() }) catch &.{}; if (ids.len == 0 or string.isOneOf(desired, ids)) return .{ .use = try allocator.dupe(u8, desired) }; if (!explicit) { - switch (llm.provider) { + switch (credential.provider) { .ollama, .llama_cpp => {}, else => return .{ .use = try allocator.dupe(u8, desired) }, } - std.debug.print("Default {s} model '{s}' is not loaded; using '{s}'.\n", .{ @tagName(llm.provider), desired, ids[0] }); + std.debug.print("Default {s} model '{s}' is not loaded; using '{s}'.\n", .{ @tagName(credential.provider), desired, ids[0] }); return .{ .use = try allocator.dupe(u8, ids[0]) }; } - if (llm.provider == .ollama) { + if (credential.provider == .ollama) { const installed = std.mem.join(arena.allocator(), ", ", ids) catch ""; std.debug.print( "Model '{s}' is not installed in Ollama.\nInstalled: {s}\nRun `ollama pull {s}` to install it, or choose one of the above.\n", @@ -415,7 +423,7 @@ pub fn reconcileModel( } else { std.debug.print( "Model '{s}' is not available for {s}.\nRun with --list-models to see options.\n", - .{ desired, @tagName(llm.provider) }, + .{ desired, @tagName(credential.provider) }, ); } return .abort; From a324ad9addd0f5a0c5311ea2057355affa2e2014 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Tue, 28 Jul 2026 13:29:05 +0200 Subject: [PATCH 08/21] build: update zenai dependency --- build.zig.zon | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 916a0b3a3..94707cffb 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -36,8 +36,8 @@ .hash = "sqlite3-3.53.2-DMxLWuAOAAA_Px0arJOIOaP4AKEu5prbsQgPMA35W1zz", }, .zenai = .{ - .url = "git+https://github.com/lightpanda-io/zenai.git#ceb6a7bf54ad032870da56b5ac6f1da0e476de6e", - .hash = "zenai-0.0.0-iOY_VAYABgCAHU20NPcSQotRdYG9aJf-ASE86YsN-AMJ", + .url = "git+https://github.com/lightpanda-io/zenai.git#dcf17cf1c944c8b16e5ecb7419374b134cf47df3", + .hash = "zenai-0.0.0-iOY_VH4NBgA-zH-jOigwD0H4QTUSq1Tvm5qH99XNqd7a", }, .isocline = .{ .url = "git+https://github.com/arrufat/isocline?ref=lightpanda#832a9fe25f5f4458fcc47b5acc7c21db669c2f47", From 041ca0ba5104758150947405d89270ac63f1dfb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Wed, 29 Jul 2026 09:00:46 +0200 Subject: [PATCH 09/21] auth: pass allocator to token store save/delete functions --- src/agent/auth/auth.zig | 68 ++++++++++++++++++++--------------------- 1 file changed, 33 insertions(+), 35 deletions(-) diff --git a/src/agent/auth/auth.zig b/src/agent/auth/auth.zig index d1fae55cb..13b58df3c 100644 --- a/src/agent/auth/auth.zig +++ b/src/agent/auth/auth.zig @@ -93,8 +93,8 @@ pub fn descriptorFor(provider: Config.AiProvider) ?*const Descriptor { } // --- On-disk token store: /auth.json, a JSON map keyed by storeKey --- -// The `*At` variants take an explicit dir (unit-testable); the public wrappers -// resolve the app data dir themselves so callers need not thread it. +// The `*At` variants take a scratch arena and an explicit dir (unit-testable); +// the public wrappers build the arena and resolve the app data dir themselves. const StoredToken = struct { access: []const u8, @@ -118,39 +118,33 @@ fn storeLoadAt(allocator: std.mem.Allocator, dir: []const u8, id: []const u8) !? return try TokenSet.dup(allocator, t.access, t.refresh, t.expires_at_ms, t.account_id); } -fn storeSaveAt(dir: []const u8, id: []const u8, tokens: TokenSet) !void { - var arena: std.heap.ArenaAllocator = .init(std.heap.page_allocator); - defer arena.deinit(); - const a = arena.allocator(); - const path = try storePath(a, dir); +fn storeSaveAt(arena: std.mem.Allocator, dir: []const u8, id: []const u8, tokens: TokenSet) !void { + const path = try storePath(arena, dir); var map: std.json.ArrayHashMap(StoredToken) = .{}; - if (std.Io.Dir.cwd().readFileAlloc(lp.io, path, a, .limited(64 * 1024))) |data| { - map = std.json.parseFromSliceLeaky(std.json.ArrayHashMap(StoredToken), a, data, .{ .ignore_unknown_fields = true }) catch .{}; + if (std.Io.Dir.cwd().readFileAlloc(lp.io, path, arena, .limited(64 * 1024))) |data| { + map = std.json.parseFromSliceLeaky(std.json.ArrayHashMap(StoredToken), arena, data, .{ .ignore_unknown_fields = true }) catch .{}; } else |_| {} - try map.map.put(a, id, .{ + try map.map.put(arena, id, .{ .access = tokens.access_token, .refresh = tokens.refresh_token, .expires_at_ms = tokens.expires_at_ms, .account_id = tokens.account_id, }); - var buf: std.Io.Writer.Allocating = .init(a); + var buf: std.Io.Writer.Allocating = .init(arena); try std.json.Stringify.value(map, .{}, &buf.writer); try std.Io.Dir.cwd().writeFile(lp.io, .{ .sub_path = path, .data = buf.written() }); // Secrets file: owner read/write only. _ = std.c.chmod(path.ptr, 0o600); } -fn storeDeleteAt(dir: []const u8, id: []const u8) void { - var arena: std.heap.ArenaAllocator = .init(std.heap.page_allocator); - defer arena.deinit(); - const a = arena.allocator(); - const path = storePath(a, dir) catch return; - const data = std.Io.Dir.cwd().readFileAlloc(lp.io, path, a, .limited(64 * 1024)) catch return; - var parsed = std.json.parseFromSliceLeaky(std.json.ArrayHashMap(StoredToken), a, data, .{ .ignore_unknown_fields = true }) catch return; +fn storeDeleteAt(arena: std.mem.Allocator, dir: []const u8, id: []const u8) void { + const path = storePath(arena, dir) catch return; + const data = std.Io.Dir.cwd().readFileAlloc(lp.io, path, arena, .limited(64 * 1024)) catch return; + var parsed = std.json.parseFromSliceLeaky(std.json.ArrayHashMap(StoredToken), arena, data, .{ .ignore_unknown_fields = true }) catch return; _ = parsed.map.swapRemove(id); - var buf: std.Io.Writer.Allocating = .init(a); + var buf: std.Io.Writer.Allocating = .init(arena); std.json.Stringify.value(parsed, .{}, &buf.writer) catch return; std.Io.Dir.cwd().writeFile(lp.io, .{ .sub_path = path, .data = buf.written() }) catch return; _ = std.c.chmod(path.ptr, 0o600); @@ -164,18 +158,20 @@ pub fn storeLoad(allocator: std.mem.Allocator, id: []const u8) !?TokenSet { return storeLoadAt(allocator, dir, id); } -pub fn storeSave(id: []const u8, tokens: TokenSet) !void { - var da: std.heap.ArenaAllocator = .init(std.heap.page_allocator); - defer da.deinit(); - const dir = dataDir(da.allocator()) orelse return error.NoDataDir; - return storeSaveAt(dir, id, tokens); +pub fn storeSave(allocator: std.mem.Allocator, id: []const u8, tokens: TokenSet) !void { + var arena: std.heap.ArenaAllocator = .init(allocator); + defer arena.deinit(); + const a = arena.allocator(); + const dir = dataDir(a) orelse return error.NoDataDir; + return storeSaveAt(a, dir, id, tokens); } -pub fn storeDelete(id: []const u8) void { - var da: std.heap.ArenaAllocator = .init(std.heap.page_allocator); - defer da.deinit(); - const dir = dataDir(da.allocator()) orelse return; - storeDeleteAt(dir, id); +pub fn storeDelete(allocator: std.mem.Allocator, id: []const u8) void { + var arena: std.heap.ArenaAllocator = .init(allocator); + defer arena.deinit(); + const a = arena.allocator(); + const dir = dataDir(a) orelse return; + storeDeleteAt(a, dir, id); } /// Proactive-refresh margin: refresh once the token is within this window of @@ -202,7 +198,7 @@ pub const Session = struct { if (self.tokens.expires_at_ms > now) return null; return err; }; - storeSave(self.descriptor.storeKey(), fresh) catch {}; + storeSave(self.allocator, self.descriptor.storeKey(), fresh) catch {}; const displaced = self.tokens; self.tokens = fresh; @@ -224,7 +220,7 @@ pub fn sessionFor(allocator: std.mem.Allocator, provider: Config.AiProvider) !?S /// Run the interactive login and persist the result, returning a live session. pub fn login(allocator: std.mem.Allocator, desc: *const Descriptor, interrupt: ?*zenai.http.Interrupt) !Session { const tokens = try desc.loginFn(allocator, interrupt); - storeSave(desc.storeKey(), tokens) catch {}; + storeSave(allocator, desc.storeKey(), tokens) catch {}; return .{ .allocator = allocator, .descriptor = desc, .tokens = tokens }; } @@ -254,14 +250,16 @@ test "descriptorFor resolves codex, null for a non-OAuth provider" { test "token store save/load/delete round-trips" { const a = std.testing.allocator; + var arena: std.heap.ArenaAllocator = .init(a); + defer arena.deinit(); + const scratch = arena.allocator(); var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); - const dir = try tmp.dir.realPathFileAlloc(lp.io, ".", a); - defer a.free(dir); + const dir = try tmp.dir.realPathFileAlloc(lp.io, ".", scratch); const t = try TokenSet.dup(a, "acc-1", "ref-1", 999, "acct-9"); defer t.deinit(a); - try storeSaveAt(dir, "codex", t); + try storeSaveAt(scratch, dir, "codex", t); const loaded = (try storeLoadAt(a, dir, "codex")).?; defer loaded.deinit(a); @@ -270,6 +268,6 @@ test "token store save/load/delete round-trips" { try std.testing.expectEqualStrings("acct-9", loaded.account_id.?); try std.testing.expectEqual(@as(i64, 999), loaded.expires_at_ms); - storeDeleteAt(dir, "codex"); + storeDeleteAt(scratch, dir, "codex"); try std.testing.expectEqual(@as(?TokenSet, null), try storeLoadAt(a, dir, "codex")); } From 52c2b90d304310a14e85a85eaaf19148972df6b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Wed, 29 Jul 2026 09:07:51 +0200 Subject: [PATCH 10/21] auth: set token file permissions on creation --- src/agent/auth/auth.zig | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/src/agent/auth/auth.zig b/src/agent/auth/auth.zig index 13b58df3c..1ff5e1474 100644 --- a/src/agent/auth/auth.zig +++ b/src/agent/auth/auth.zig @@ -103,8 +103,8 @@ const StoredToken = struct { account_id: ?[]const u8 = null, }; -fn storePath(arena: std.mem.Allocator, dir: []const u8) ![:0]const u8 { - return std.fs.path.joinZ(arena, &.{ dir, "auth.json" }); +fn storePath(arena: std.mem.Allocator, dir: []const u8) ![]const u8 { + return std.fs.path.join(arena, &.{ dir, "auth.json" }); } fn storeLoadAt(allocator: std.mem.Allocator, dir: []const u8, id: []const u8) !?TokenSet { @@ -132,11 +132,13 @@ fn storeSaveAt(arena: std.mem.Allocator, dir: []const u8, id: []const u8, tokens .account_id = tokens.account_id, }); - var buf: std.Io.Writer.Allocating = .init(arena); - try std.json.Stringify.value(map, .{}, &buf.writer); - try std.Io.Dir.cwd().writeFile(lp.io, .{ .sub_path = path, .data = buf.written() }); - // Secrets file: owner read/write only. - _ = std.c.chmod(path.ptr, 0o600); + // Secrets file: owner read/write only, from the moment it exists. + const file = try std.Io.Dir.cwd().createFile(lp.io, path, .{ .permissions = .fromMode(0o600) }); + defer file.close(lp.io); + var buf: [1024]u8 = undefined; + var w = file.writer(lp.io, &buf); + try std.json.Stringify.value(map, .{}, &w.interface); + try w.end(); } fn storeDeleteAt(arena: std.mem.Allocator, dir: []const u8, id: []const u8) void { @@ -144,10 +146,12 @@ fn storeDeleteAt(arena: std.mem.Allocator, dir: []const u8, id: []const u8) void const data = std.Io.Dir.cwd().readFileAlloc(lp.io, path, arena, .limited(64 * 1024)) catch return; var parsed = std.json.parseFromSliceLeaky(std.json.ArrayHashMap(StoredToken), arena, data, .{ .ignore_unknown_fields = true }) catch return; _ = parsed.map.swapRemove(id); - var buf: std.Io.Writer.Allocating = .init(arena); - std.json.Stringify.value(parsed, .{}, &buf.writer) catch return; - std.Io.Dir.cwd().writeFile(lp.io, .{ .sub_path = path, .data = buf.written() }) catch return; - _ = std.c.chmod(path.ptr, 0o600); + const file = std.Io.Dir.cwd().createFile(lp.io, path, .{ .permissions = .fromMode(0o600) }) catch return; + defer file.close(lp.io); + var buf: [1024]u8 = undefined; + var w = file.writer(lp.io, &buf); + std.json.Stringify.value(parsed, .{}, &w.interface) catch return; + w.end() catch return; } /// Load the stored token for `id`, or null when absent/unreadable/no data dir. @@ -261,6 +265,9 @@ test "token store save/load/delete round-trips" { defer t.deinit(a); try storeSaveAt(scratch, dir, "codex", t); + const st = try tmp.dir.statFile(lp.io, "auth.json", .{}); + try std.testing.expectEqual(@as(std.posix.mode_t, 0o600), st.permissions.toMode() & 0o777); + const loaded = (try storeLoadAt(a, dir, "codex")).?; defer loaded.deinit(a); try std.testing.expectEqualStrings("acc-1", loaded.access_token); From 6c28c3d6c96c4e07ff4bf8e54cc70ef4c3cc4a22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Wed, 29 Jul 2026 09:09:24 +0200 Subject: [PATCH 11/21] auth: log warning on token persistence failure --- src/agent/auth/auth.zig | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/src/agent/auth/auth.zig b/src/agent/auth/auth.zig index 1ff5e1474..c742408f8 100644 --- a/src/agent/auth/auth.zig +++ b/src/agent/auth/auth.zig @@ -25,6 +25,7 @@ const std = @import("std"); const lp = @import("lightpanda"); +const log = lp.log; const zenai = @import("zenai"); const Config = lp.Config; const codex = @import("codex.zig"); @@ -170,14 +171,6 @@ pub fn storeSave(allocator: std.mem.Allocator, id: []const u8, tokens: TokenSet) return storeSaveAt(a, dir, id, tokens); } -pub fn storeDelete(allocator: std.mem.Allocator, id: []const u8) void { - var arena: std.heap.ArenaAllocator = .init(allocator); - defer arena.deinit(); - const a = arena.allocator(); - const dir = dataDir(a) orelse return; - storeDeleteAt(a, dir, id); -} - /// Proactive-refresh margin: refresh once the token is within this window of /// expiry, so a turn never starts on a token about to lapse. const refresh_skew_ms: i64 = 5 * std.time.ms_per_min; @@ -202,7 +195,9 @@ pub const Session = struct { if (self.tokens.expires_at_ms > now) return null; return err; }; - storeSave(self.allocator, self.descriptor.storeKey(), fresh) catch {}; + storeSave(self.allocator, self.descriptor.storeKey(), fresh) catch |err| { + log.warn(.app, "auth token persist failed", .{ .provider = self.descriptor.storeKey(), .err = err }); + }; const displaced = self.tokens; self.tokens = fresh; @@ -224,7 +219,9 @@ pub fn sessionFor(allocator: std.mem.Allocator, provider: Config.AiProvider) !?S /// Run the interactive login and persist the result, returning a live session. pub fn login(allocator: std.mem.Allocator, desc: *const Descriptor, interrupt: ?*zenai.http.Interrupt) !Session { const tokens = try desc.loginFn(allocator, interrupt); - storeSave(allocator, desc.storeKey(), tokens) catch {}; + storeSave(allocator, desc.storeKey(), tokens) catch |err| { + log.warn(.app, "auth token persist failed", .{ .provider = desc.storeKey(), .err = err }); + }; return .{ .allocator = allocator, .descriptor = desc, .tokens = tokens }; } From 0035d31fc99a6b0df20d51eb1d27f686cb214ee2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Wed, 29 Jul 2026 09:12:16 +0200 Subject: [PATCH 12/21] codex: use std.json.fmt for poll body --- src/agent/auth/codex.zig | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/agent/auth/codex.zig b/src/agent/auth/codex.zig index d2eb67a58..b25882341 100644 --- a/src/agent/auth/codex.zig +++ b/src/agent/auth/codex.zig @@ -148,7 +148,10 @@ fn deviceLogin(allocator: std.mem.Allocator, interrupt: ?*zenai.http.Interrupt) .{ verify_url, dc.user_code }, ); - const poll_body = try std.fmt.allocPrint(a, "{{\"device_auth_id\":\"{s}\",\"user_code\":\"{s}\"}}", .{ dc.device_auth_id, dc.user_code }); + const poll_body = try std.fmt.allocPrint(a, "{f}", .{std.json.fmt( + .{ .device_auth_id = dc.device_auth_id, .user_code = dc.user_code }, + .{}, + )}); const dt: DeviceToken = while (true) { // The REPL's Ctrl-C only fires the interrupt (it never kills the // process), so the wait must poll it. From 41e365b3f4a664d284d6e1b5480df1b558cfda53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Wed, 29 Jul 2026 09:21:02 +0200 Subject: [PATCH 13/21] agent: move session banner printing to Terminal --- src/agent/Agent.zig | 3 +-- src/agent/Terminal.zig | 10 ++++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/agent/Agent.zig b/src/agent/Agent.zig index 7894feb5e..649eb2791 100644 --- a/src/agent/Agent.zig +++ b/src/agent/Agent.zig @@ -35,7 +35,6 @@ const App = @import("../App.zig"); const CDPNode = @import("../cdp/Node.zig"); const Conversation = @import("Conversation.zig"); const Terminal = @import("Terminal.zig"); -const ansi = @import("ansi.zig"); const SlashCommand = @import("SlashCommand.zig"); const settings = @import("settings.zig"); const auth = @import("auth/auth.zig"); @@ -602,7 +601,7 @@ fn runRepl(self: *Agent) void { log.debug(.app, "tools loaded", .{ .count = globalTools().len }); if (self.ai_client != null) { - std.debug.print(" model: {s}{s} {s}effort: {s}{s} {s}stream: {s}{s}{s}\n", .{ ansi.dim, self.model, ansi.reset, ansi.dim, @tagName(self.effort), ansi.reset, ansi.dim, if (self.stream_enabled) "on" else "off", ansi.reset }); + self.terminal.printSessionBanner(self.model, @tagName(self.effort), self.stream_enabled); } repl: while (true) { diff --git a/src/agent/Terminal.zig b/src/agent/Terminal.zig index e7f6479a6..94875f910 100644 --- a/src/agent/Terminal.zig +++ b/src/agent/Terminal.zig @@ -364,6 +364,16 @@ pub fn printDimmed(self: *Terminal, comptime fmt: []const u8, args: anytype) voi std.debug.print(ansi.dim ++ fmt ++ ansi.reset ++ "\n", args); } +/// REPL startup banner line: plain labels, dimmed values. +pub fn printSessionBanner(self: *Terminal, model: []const u8, effort: []const u8, stream_enabled: bool) void { + if (!self.isRepl()) return; + std.debug.print(" model: {s}{s}{s} effort: {s}{s}{s} stream: {s}{s}{s}\n", .{ + ansi.dim, model, ansi.reset, + ansi.dim, effort, ansi.reset, + ansi.dim, if (stream_enabled) "on" else "off", ansi.reset, + }); +} + pub fn printItalic(self: *Terminal, comptime fmt: []const u8, args: anytype) void { if (!self.isRepl() and !self.verbosity.atLeast(.medium)) return; std.debug.print(ansi.italic ++ fmt ++ ansi.reset ++ "\n", args); From 6070119ca83cbbdc0f012ac172879503beb9fe67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Wed, 29 Jul 2026 09:26:56 +0200 Subject: [PATCH 14/21] auth: write auth store and model cache atomically --- src/agent/auth/auth.zig | 17 ++++++++++------- src/agent/auth/models_dev.zig | 20 ++++++++++---------- 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/src/agent/auth/auth.zig b/src/agent/auth/auth.zig index c742408f8..5d18ecfa0 100644 --- a/src/agent/auth/auth.zig +++ b/src/agent/auth/auth.zig @@ -133,13 +133,15 @@ fn storeSaveAt(arena: std.mem.Allocator, dir: []const u8, id: []const u8, tokens .account_id = tokens.account_id, }); - // Secrets file: owner read/write only, from the moment it exists. - const file = try std.Io.Dir.cwd().createFile(lp.io, path, .{ .permissions = .fromMode(0o600) }); - defer file.close(lp.io); + // Secrets file: owner read/write only, from the moment it exists. Written + // to a temp file and renamed so a failed write can't corrupt the store. + var af = try std.Io.Dir.cwd().createFileAtomic(lp.io, path, .{ .permissions = .fromMode(0o600), .replace = true }); + defer af.deinit(lp.io); var buf: [1024]u8 = undefined; - var w = file.writer(lp.io, &buf); + var w = af.file.writer(lp.io, &buf); try std.json.Stringify.value(map, .{}, &w.interface); try w.end(); + try af.replace(lp.io); } fn storeDeleteAt(arena: std.mem.Allocator, dir: []const u8, id: []const u8) void { @@ -147,12 +149,13 @@ fn storeDeleteAt(arena: std.mem.Allocator, dir: []const u8, id: []const u8) void const data = std.Io.Dir.cwd().readFileAlloc(lp.io, path, arena, .limited(64 * 1024)) catch return; var parsed = std.json.parseFromSliceLeaky(std.json.ArrayHashMap(StoredToken), arena, data, .{ .ignore_unknown_fields = true }) catch return; _ = parsed.map.swapRemove(id); - const file = std.Io.Dir.cwd().createFile(lp.io, path, .{ .permissions = .fromMode(0o600) }) catch return; - defer file.close(lp.io); + var af = std.Io.Dir.cwd().createFileAtomic(lp.io, path, .{ .permissions = .fromMode(0o600), .replace = true }) catch return; + defer af.deinit(lp.io); var buf: [1024]u8 = undefined; - var w = file.writer(lp.io, &buf); + var w = af.file.writer(lp.io, &buf); std.json.Stringify.value(parsed, .{}, &w.interface) catch return; w.end() catch return; + af.replace(lp.io) catch return; } /// Load the stored token for `id`, or null when absent/unreadable/no data dir. diff --git a/src/agent/auth/models_dev.zig b/src/agent/auth/models_dev.zig index 51b3f5b5c..caf69e163 100644 --- a/src/agent/auth/models_dev.zig +++ b/src/agent/auth/models_dev.zig @@ -40,7 +40,7 @@ pub fn modelIds(arena: std.mem.Allocator, provider_id: []const u8, app_dir: ?[]c } const catalog = fetch(arena) catch return &.{}; const ids = parseProviderModels(arena, catalog, provider_id) catch return &.{}; - if (app_dir) |dir| writeCache(dir, provider_id, ids) catch {}; + if (app_dir) |dir| writeCache(arena, dir, provider_id, ids) catch {}; return ids; } @@ -63,15 +63,15 @@ fn readCache(arena: std.mem.Allocator, app_dir: []const u8, provider_id: []const return parsed.ids; } -fn writeCache(app_dir: []const u8, provider_id: []const u8, ids: []const []const u8) !void { - var arena: std.heap.ArenaAllocator = .init(std.heap.page_allocator); - defer arena.deinit(); - const a = arena.allocator(); - const path = try cachePath(a, app_dir, provider_id); - - var buf: std.Io.Writer.Allocating = .init(a); - try std.json.Stringify.value(Cache{ .fetched_ms = auth.nowMs(), .ids = ids }, .{}, &buf.writer); - try std.Io.Dir.cwd().writeFile(lp.io, .{ .sub_path = path, .data = buf.written() }); +fn writeCache(arena: std.mem.Allocator, app_dir: []const u8, provider_id: []const u8, ids: []const []const u8) !void { + const path = try cachePath(arena, app_dir, provider_id); + var af = try std.Io.Dir.cwd().createFileAtomic(lp.io, path, .{ .replace = true }); + defer af.deinit(lp.io); + var buf: [1024]u8 = undefined; + var w = af.file.writer(lp.io, &buf); + try std.json.Stringify.value(Cache{ .fetched_ms = auth.nowMs(), .ids = ids }, .{}, &w.interface); + try w.end(); + try af.replace(lp.io); } /// Model-id keys of `catalog[provider_id].models`, filtered to tool-call-capable From c8e7761fcee43db6a9753fbe920ce60192b0cd56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Wed, 29 Jul 2026 09:32:11 +0200 Subject: [PATCH 15/21] agent/auth: parse models directly into arena --- src/agent/auth/models_dev.zig | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/agent/auth/models_dev.zig b/src/agent/auth/models_dev.zig index caf69e163..ac4890d10 100644 --- a/src/agent/auth/models_dev.zig +++ b/src/agent/auth/models_dev.zig @@ -77,22 +77,18 @@ fn writeCache(arena: std.mem.Allocator, app_dir: []const u8, provider_id: []cons /// Model-id keys of `catalog[provider_id].models`, filtered to tool-call-capable /// models (the agent needs tools; this also drops embedding/image entries). /// `ignore_unknown_fields` skips the rest of the metadata, so no Value tree is -/// built for the multi-MB payload. The parse runs in a scoped arena; the ids -/// are duped into `arena`. +/// built for the multi-MB payload, but the parsed id map accumulates in `arena` +/// until the caller deinits it. The ids may alias `catalog`. fn parseProviderModels(arena: std.mem.Allocator, catalog: []const u8, provider_id: []const u8) ![]const []const u8 { - var parse_arena: std.heap.ArenaAllocator = .init(arena); - defer parse_arena.deinit(); - const Model = struct { tool_call: bool = false }; const Provider = struct { models: std.json.ArrayHashMap(Model) = .{} }; - const parsed = try std.json.parseFromSliceLeaky(std.json.ArrayHashMap(Provider), parse_arena.allocator(), catalog, .{ .ignore_unknown_fields = true }); + const parsed = try std.json.parseFromSliceLeaky(std.json.ArrayHashMap(Provider), arena, catalog, .{ .ignore_unknown_fields = true }); const provider = parsed.map.get(provider_id) orelse return error.ProviderMissing; var ids: std.ArrayList([]const u8) = .empty; var it = provider.models.map.iterator(); while (it.next()) |entry| { - if (!entry.value_ptr.tool_call) continue; - try ids.append(arena, try arena.dupe(u8, entry.key_ptr.*)); + if (entry.value_ptr.tool_call) try ids.append(arena, entry.key_ptr.*); } return ids.toOwnedSlice(arena); } From 939feeb9a37c7d729e62000272bc0159f3346c12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Wed, 29 Jul 2026 09:35:14 +0200 Subject: [PATCH 16/21] auth: consider refreshable tokens in subscription check --- src/agent/auth/auth.zig | 11 ++++++----- src/agent/auth/models_dev.zig | 4 ++-- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/agent/auth/auth.zig b/src/agent/auth/auth.zig index 5d18ecfa0..502f582c4 100644 --- a/src/agent/auth/auth.zig +++ b/src/agent/auth/auth.zig @@ -133,8 +133,8 @@ fn storeSaveAt(arena: std.mem.Allocator, dir: []const u8, id: []const u8, tokens .account_id = tokens.account_id, }); - // Secrets file: owner read/write only, from the moment it exists. Written - // to a temp file and renamed so a failed write can't corrupt the store. + // Secrets file: owner-only perms; temp+rename so a failed write can't + // corrupt the store. var af = try std.Io.Dir.cwd().createFileAtomic(lp.io, path, .{ .permissions = .fromMode(0o600), .replace = true }); defer af.deinit(lp.io); var buf: [1024]u8 = undefined; @@ -228,13 +228,14 @@ pub fn login(allocator: std.mem.Allocator, desc: *const Descriptor, interrupt: ? return .{ .allocator = allocator, .descriptor = desc, .tokens = tokens }; } -/// Is a usable (present, not hard-expired) stored token available for `provider`? -/// Lets the picker offer the subscription without an API-key env var. +/// Is a stored credential available for `provider` — unexpired, or refreshable +/// (`ensureFresh` runs before the first request)? Lets the picker offer the +/// subscription without an API-key env var. pub fn subscriptionAvailable(provider: Config.AiProvider) bool { const desc = descriptorFor(provider) orelse return false; const tokens = (storeLoad(std.heap.page_allocator, desc.storeKey()) catch return false) orelse return false; defer tokens.deinit(std.heap.page_allocator); - return tokens.expires_at_ms > nowMs(); + return tokens.refresh_token.len > 0 or tokens.expires_at_ms > nowMs(); } test "TokenSet dup/deinit round-trips and is leak-free" { diff --git a/src/agent/auth/models_dev.zig b/src/agent/auth/models_dev.zig index ac4890d10..fc52c022a 100644 --- a/src/agent/auth/models_dev.zig +++ b/src/agent/auth/models_dev.zig @@ -77,8 +77,8 @@ fn writeCache(arena: std.mem.Allocator, app_dir: []const u8, provider_id: []cons /// Model-id keys of `catalog[provider_id].models`, filtered to tool-call-capable /// models (the agent needs tools; this also drops embedding/image entries). /// `ignore_unknown_fields` skips the rest of the metadata, so no Value tree is -/// built for the multi-MB payload, but the parsed id map accumulates in `arena` -/// until the caller deinits it. The ids may alias `catalog`. +/// built for the multi-MB payload, but the parsed id map accumulates in +/// `arena`. The ids may alias `catalog`. fn parseProviderModels(arena: std.mem.Allocator, catalog: []const u8, provider_id: []const u8) ![]const []const u8 { const Model = struct { tool_call: bool = false }; const Provider = struct { models: std.json.ArrayHashMap(Model) = .{} }; From a00b5dceb7f107b8192008ff6f669ee3ba776ff1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Wed, 29 Jul 2026 09:37:38 +0200 Subject: [PATCH 17/21] auth: parse device code interval as u32 --- src/agent/auth/codex.zig | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/agent/auth/codex.zig b/src/agent/auth/codex.zig index b25882341..a1a006a8c 100644 --- a/src/agent/auth/codex.zig +++ b/src/agent/auth/codex.zig @@ -125,7 +125,9 @@ fn exchangeBody(arena: std.mem.Allocator, code: []const u8, code_verifier: []con const DeviceCode = struct { device_auth_id: []const u8, user_code: []const u8, - interval: []const u8 = "5", + /// Seconds between polls. The spec says number; OpenAI returns a string; + /// std.json int parsing accepts both. + interval: u32 = 5, }; const DeviceToken = struct { @@ -141,7 +143,7 @@ fn deviceLogin(allocator: std.mem.Allocator, interrupt: ?*zenai.http.Interrupt) const code_res = try post(a, interrupt, device_code_url, "application/json", "{\"client_id\":\"" ++ client_id ++ "\"}"); if (code_res.status != .ok) return error.DeviceCodeRequestFailed; const dc = try std.json.parseFromSliceLeaky(DeviceCode, a, code_res.body, .{ .ignore_unknown_fields = true }); - const interval_ms: u64 = @as(u64, @intCast(std.fmt.parseInt(u32, dc.interval, 10) catch 5)) * std.time.ms_per_s; + const interval_ms: u64 = @as(u64, dc.interval) * std.time.ms_per_s; std.debug.print( "\nTo authorize Lightpanda with your ChatGPT subscription:\n 1. Open {s}\n 2. Enter the code: {s}\n\nWaiting for authorization...\n", From 5f76f7056ab6b09aca991f59abbd9d13b077b112 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Wed, 29 Jul 2026 09:38:53 +0200 Subject: [PATCH 18/21] auth: log warnings on codex auth request failures --- src/agent/auth/codex.zig | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/agent/auth/codex.zig b/src/agent/auth/codex.zig index a1a006a8c..00e361caa 100644 --- a/src/agent/auth/codex.zig +++ b/src/agent/auth/codex.zig @@ -24,6 +24,7 @@ const std = @import("std"); const lp = @import("lightpanda"); +const log = lp.log; const zenai = @import("zenai"); const auth = @import("auth.zig"); @@ -141,7 +142,10 @@ fn deviceLogin(allocator: std.mem.Allocator, interrupt: ?*zenai.http.Interrupt) const a = arena.allocator(); const code_res = try post(a, interrupt, device_code_url, "application/json", "{\"client_id\":\"" ++ client_id ++ "\"}"); - if (code_res.status != .ok) return error.DeviceCodeRequestFailed; + if (code_res.status != .ok) { + log.warn(.app, "codex device-code request failed", .{ .status = @intFromEnum(code_res.status), .body = code_res.body }); + return error.DeviceCodeRequestFailed; + } const dc = try std.json.parseFromSliceLeaky(DeviceCode, a, code_res.body, .{ .ignore_unknown_fields = true }); const interval_ms: u64 = @as(u64, dc.interval) * std.time.ms_per_s; @@ -169,13 +173,19 @@ fn deviceLogin(allocator: std.mem.Allocator, interrupt: ?*zenai.http.Interrupt) .ok => break try std.json.parseFromSliceLeaky(DeviceToken, a, res.body, .{ .ignore_unknown_fields = true }), // Still pending — the user hasn't finished authorizing. .forbidden, .not_found => continue, - else => return error.DeviceAuthFailed, + else => { + log.warn(.app, "codex device-auth poll failed", .{ .status = @intFromEnum(res.status), .body = res.body }); + return error.DeviceAuthFailed; + }, } }; const exchange = try exchangeBody(a, dt.authorization_code, dt.code_verifier); const tok_res = try post(a, interrupt, token_url, "application/x-www-form-urlencoded", exchange); - if (tok_res.status != .ok) return error.TokenExchangeFailed; + if (tok_res.status != .ok) { + log.warn(.app, "codex token exchange failed", .{ .status = @intFromEnum(tok_res.status), .body = tok_res.body }); + return error.TokenExchangeFailed; + } return parseTokenResponse(allocator, tok_res.body); } @@ -185,7 +195,10 @@ fn refreshGrant(allocator: std.mem.Allocator, refresh_token: []const u8) !auth.T const a = arena.allocator(); const body = try refreshBody(a, refresh_token); const res = try post(a, null, token_url, "application/x-www-form-urlencoded", body); - if (res.status != .ok) return error.RefreshFailed; + if (res.status != .ok) { + log.warn(.app, "codex token refresh failed", .{ .status = @intFromEnum(res.status), .body = res.body }); + return error.RefreshFailed; + } return parseTokenResponse(allocator, res.body); } From d7eba33fb07bf1ce4a3989f5d0dc5c2ee7f552a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Wed, 29 Jul 2026 09:41:50 +0200 Subject: [PATCH 19/21] auth: fallback to stale models.dev cache on fetch failure --- src/agent/auth/models_dev.zig | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/agent/auth/models_dev.zig b/src/agent/auth/models_dev.zig index fc52c022a..3f4f5ea8d 100644 --- a/src/agent/auth/models_dev.zig +++ b/src/agent/auth/models_dev.zig @@ -30,16 +30,17 @@ const auth = @import("auth.zig"); const api_url = "https://models.dev/api.json"; const cache_ttl_ms: i64 = 24 * std.time.ms_per_hour; -/// Chat-model ids for `provider_id` from models.dev, allocated in `arena`. Reads -/// a fresh on-disk cache when present, else fetches and refreshes it. Returns an -/// empty slice on any failure (offline, parse error, no cache) so callers -/// degrade to accepting a typed-in model name. +/// Chat-model ids for `provider_id` from models.dev, allocated in `arena`. +/// Serves a fresh on-disk cache, else fetches and refreshes it, falling back +/// to an expired cache when the fetch fails. Empty only when nothing is +/// available, so callers degrade to accepting a typed-in model name. pub fn modelIds(arena: std.mem.Allocator, provider_id: []const u8, app_dir: ?[]const u8) []const []const u8 { - if (app_dir) |dir| { - if (readCache(arena, dir, provider_id)) |ids| return ids; - } - const catalog = fetch(arena) catch return &.{}; - const ids = parseProviderModels(arena, catalog, provider_id) catch return &.{}; + const cached: ?Cache = if (app_dir) |dir| readCache(arena, dir, provider_id) else null; + if (cached) |c| if (auth.nowMs() - c.fetched_ms <= cache_ttl_ms) return c.ids; + const stale: []const []const u8 = if (cached) |c| c.ids else &.{}; + + const catalog = fetch(arena) catch return stale; + const ids = parseProviderModels(arena, catalog, provider_id) catch return stale; if (app_dir) |dir| writeCache(arena, dir, provider_id, ids) catch {}; return ids; } @@ -54,13 +55,12 @@ fn cachePath(arena: std.mem.Allocator, app_dir: []const u8, provider_id: []const return std.fs.path.join(arena, &.{ app_dir, name }); } -fn readCache(arena: std.mem.Allocator, app_dir: []const u8, provider_id: []const u8) ?[]const []const u8 { +fn readCache(arena: std.mem.Allocator, app_dir: []const u8, provider_id: []const u8) ?Cache { const path = cachePath(arena, app_dir, provider_id) catch return null; const data = std.Io.Dir.cwd().readFileAlloc(lp.io, path, arena, .limited(1024 * 1024)) catch return null; const parsed = std.json.parseFromSliceLeaky(Cache, arena, data, .{ .ignore_unknown_fields = true }) catch return null; - if (auth.nowMs() - parsed.fetched_ms > cache_ttl_ms) return null; if (parsed.ids.len == 0) return null; - return parsed.ids; + return parsed; } fn writeCache(arena: std.mem.Allocator, app_dir: []const u8, provider_id: []const u8, ids: []const []const u8) !void { From 27b37e6c937443f71b31b3d499af58d9280d07b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Wed, 29 Jul 2026 09:55:49 +0200 Subject: [PATCH 20/21] auth: simplify token refresh and json store helpers --- src/agent/Agent.zig | 20 +++--- src/agent/auth/auth.zig | 111 ++++++++++++++++------------------ src/agent/auth/codex.zig | 22 ++++--- src/agent/auth/models_dev.zig | 8 +-- 4 files changed, 72 insertions(+), 89 deletions(-) diff --git a/src/agent/Agent.zig b/src/agent/Agent.zig index 649eb2791..d4646909d 100644 --- a/src/agent/Agent.zig +++ b/src/agent/Agent.zig @@ -1041,21 +1041,17 @@ fn setProvider(self: *Agent, credential: settings.Credential) !void { /// Keep a subscription (bearer) token current before a model request: when the /// active session's token is near expiry, re-import from the source and repoint /// the client at the fresh token (`keySlice` readers see it automatically). -/// A no-op for API-key auth. Runs between turns on the single agent thread, -/// never during a request, so the displaced token is never dereferenced after -/// it's freed here. +/// A no-op for API-key auth. Runs between turns on the single agent thread, so +/// the client is repointed before the next request borrows the token. fn refreshAuthIfNeeded(self: *Agent) void { const cred = if (self.credential) |*c| c else return; if (cred.key != .session) return; const session = &cred.key.session; - const displaced = session.ensureFresh() catch |err| { + const refreshed = session.ensureFresh() catch |err| { self.terminal.printError("could not refresh the subscription token: {s}", .{@errorName(err)}); return; }; - if (displaced) |old| { - if (self.ai_client) |client| client.setApiKey(session.tokens.access_token); - old.deinit(session.allocator); - } + if (refreshed) if (self.ai_client) |client| client.setApiKey(session.tokens.access_token); } const PathAndMode = struct { path: []const u8, mode: save.Mode }; @@ -1618,8 +1614,9 @@ fn recordSlashToolCall( fn formatApiError(self: *Agent, client: zenai.provider.Client, err: anyerror) []const u8 { const e = client.lastError(); const status = e.status orelse return @errorName(err); + const owned_key = if (self.credential) |c| c.key == .owned else false; const hint = if (status == 401 and client == .vertex) - if (self.credential != null and self.credential.?.key == .owned) + if (owned_key) " (Vertex token may have expired; run /provider vertex to refresh)" else " (Vertex express mode needs an express API key — a Gemini Developer key won't work)" @@ -1972,10 +1969,7 @@ fn completionProviders(context: *anyopaque, arena: std.mem.Allocator) []const [] // one is what starts the login. for (auth.registry) |desc| { const name = @tagName(desc.provider); - const detected = for (self.available_providers) |p| { - if (std.mem.eql(u8, p, name)) break true; - } else false; - if (!detected) { + if (!string.isOneOf(name, self.available_providers)) { names[n] = name; n += 1; } diff --git a/src/agent/auth/auth.zig b/src/agent/auth/auth.zig index 502f582c4..bdc654b75 100644 --- a/src/agent/auth/auth.zig +++ b/src/agent/auth/auth.zig @@ -79,11 +79,6 @@ pub const Descriptor = struct { loginFn: *const fn (std.mem.Allocator, interrupt: ?*zenai.http.Interrupt) anyerror!TokenSet, /// Exchange a refresh token for a new `TokenSet` (with re-derived account id). refreshFn: *const fn (std.mem.Allocator, refresh_token: []const u8) anyerror!TokenSet, - - /// Key in the on-disk store (`auth.json`). - pub fn storeKey(self: *const Descriptor) []const u8 { - return @tagName(self.provider); - } }; pub const registry = [_]*const Descriptor{&codex.descriptor}; @@ -93,7 +88,7 @@ pub fn descriptorFor(provider: Config.AiProvider) ?*const Descriptor { return null; } -// --- On-disk token store: /auth.json, a JSON map keyed by storeKey --- +// --- On-disk token store: /auth.json, a JSON map keyed by provider tag --- // The `*At` variants take a scratch arena and an explicit dir (unit-testable); // the public wrappers build the arena and resolve the app data dir themselves. @@ -108,54 +103,44 @@ fn storePath(arena: std.mem.Allocator, dir: []const u8) ![]const u8 { return std.fs.path.join(arena, &.{ dir, "auth.json" }); } +/// The parsed store; empty on any failure (absent, unreadable, malformed). +fn readStoreFile(arena: std.mem.Allocator, path: []const u8) std.json.ArrayHashMap(StoredToken) { + const data = std.Io.Dir.cwd().readFileAlloc(lp.io, path, arena, .limited(64 * 1024)) catch return .{}; + return std.json.parseFromSliceLeaky(std.json.ArrayHashMap(StoredToken), arena, data, .{ .ignore_unknown_fields = true }) catch .{}; +} + +/// Serialize `value` as JSON to `path` via temp+rename, so a failed write +/// can't corrupt an existing file. +pub fn writeJsonAtomic(path: []const u8, value: anytype, permissions: std.Io.File.Permissions) !void { + var af = try std.Io.Dir.cwd().createFileAtomic(lp.io, path, .{ .permissions = permissions, .replace = true }); + defer af.deinit(lp.io); + var buf: [1024]u8 = undefined; + var w = af.file.writer(lp.io, &buf); + try std.json.Stringify.value(value, .{}, &w.interface); + try w.end(); + try af.replace(lp.io); +} + fn storeLoadAt(allocator: std.mem.Allocator, dir: []const u8, id: []const u8) !?TokenSet { var arena: std.heap.ArenaAllocator = .init(allocator); defer arena.deinit(); const a = arena.allocator(); const path = try storePath(a, dir); - const data = std.Io.Dir.cwd().readFileAlloc(lp.io, path, a, .limited(64 * 1024)) catch return null; - const parsed = std.json.parseFromSliceLeaky(std.json.ArrayHashMap(StoredToken), a, data, .{ .ignore_unknown_fields = true }) catch return null; - const t = parsed.map.get(id) orelse return null; + const t = readStoreFile(a, path).map.get(id) orelse return null; return try TokenSet.dup(allocator, t.access, t.refresh, t.expires_at_ms, t.account_id); } fn storeSaveAt(arena: std.mem.Allocator, dir: []const u8, id: []const u8, tokens: TokenSet) !void { const path = try storePath(arena, dir); - - var map: std.json.ArrayHashMap(StoredToken) = .{}; - if (std.Io.Dir.cwd().readFileAlloc(lp.io, path, arena, .limited(64 * 1024))) |data| { - map = std.json.parseFromSliceLeaky(std.json.ArrayHashMap(StoredToken), arena, data, .{ .ignore_unknown_fields = true }) catch .{}; - } else |_| {} + var map = readStoreFile(arena, path); try map.map.put(arena, id, .{ .access = tokens.access_token, .refresh = tokens.refresh_token, .expires_at_ms = tokens.expires_at_ms, .account_id = tokens.account_id, }); - - // Secrets file: owner-only perms; temp+rename so a failed write can't - // corrupt the store. - var af = try std.Io.Dir.cwd().createFileAtomic(lp.io, path, .{ .permissions = .fromMode(0o600), .replace = true }); - defer af.deinit(lp.io); - var buf: [1024]u8 = undefined; - var w = af.file.writer(lp.io, &buf); - try std.json.Stringify.value(map, .{}, &w.interface); - try w.end(); - try af.replace(lp.io); -} - -fn storeDeleteAt(arena: std.mem.Allocator, dir: []const u8, id: []const u8) void { - const path = storePath(arena, dir) catch return; - const data = std.Io.Dir.cwd().readFileAlloc(lp.io, path, arena, .limited(64 * 1024)) catch return; - var parsed = std.json.parseFromSliceLeaky(std.json.ArrayHashMap(StoredToken), arena, data, .{ .ignore_unknown_fields = true }) catch return; - _ = parsed.map.swapRemove(id); - var af = std.Io.Dir.cwd().createFileAtomic(lp.io, path, .{ .permissions = .fromMode(0o600), .replace = true }) catch return; - defer af.deinit(lp.io); - var buf: [1024]u8 = undefined; - var w = af.file.writer(lp.io, &buf); - std.json.Stringify.value(parsed, .{}, &w.interface) catch return; - w.end() catch return; - af.replace(lp.io) catch return; + // Secrets file: owner-only perms. + try writeJsonAtomic(path, map, .fromMode(0o600)); } /// Load the stored token for `id`, or null when absent/unreadable/no data dir. @@ -184,30 +169,35 @@ pub const Session = struct { allocator: std.mem.Allocator, descriptor: *const Descriptor, tokens: TokenSet, + /// The set displaced by the last refresh, kept until the next one (or + /// deinit) because the AI client borrows the access token until the + /// caller repoints it. + displaced: ?TokenSet = null, - /// When the access token is within `refresh_skew_ms` of expiry, exchange the - /// refresh token for a new one, persist it, and return the displaced set. - /// Null when still fresh. The caller must repoint anything borrowing from - /// the displaced set (`tokens` holds the fresh one) before freeing it. - pub fn ensureFresh(self: *Session) !?TokenSet { + /// When the access token is within `refresh_skew_ms` of expiry, exchange + /// the refresh token for a new one and persist it. True when `tokens` was + /// replaced — repoint anything borrowing the old access token. + pub fn ensureFresh(self: *Session) !bool { const now = nowMs(); - if (self.tokens.expires_at_ms - now > refresh_skew_ms) return null; + if (self.tokens.expires_at_ms - now > refresh_skew_ms) return false; const fresh = self.descriptor.refreshFn(self.allocator, self.tokens.refresh_token) catch |err| { // A transient refresh failure while the token is still valid is not fatal. - if (self.tokens.expires_at_ms > now) return null; + if (self.tokens.expires_at_ms > now) return false; return err; }; - storeSave(self.allocator, self.descriptor.storeKey(), fresh) catch |err| { - log.warn(.app, "auth token persist failed", .{ .provider = self.descriptor.storeKey(), .err = err }); + storeSave(self.allocator, @tagName(self.descriptor.provider), fresh) catch |err| { + log.warn(.app, "auth token persist failed", .{ .provider = @tagName(self.descriptor.provider), .err = err }); }; - const displaced = self.tokens; + if (self.displaced) |old| old.deinit(self.allocator); + self.displaced = self.tokens; self.tokens = fresh; - return displaced; + return true; } pub fn deinit(self: *Session) void { + if (self.displaced) |old| old.deinit(self.allocator); self.tokens.deinit(self.allocator); } }; @@ -215,15 +205,15 @@ pub const Session = struct { /// Load a stored session for `provider`, or null when the user hasn't logged in. pub fn sessionFor(allocator: std.mem.Allocator, provider: Config.AiProvider) !?Session { const desc = descriptorFor(provider) orelse return null; - const tokens = (try storeLoad(allocator, desc.storeKey())) orelse return null; + const tokens = (try storeLoad(allocator, @tagName(provider))) orelse return null; return .{ .allocator = allocator, .descriptor = desc, .tokens = tokens }; } /// Run the interactive login and persist the result, returning a live session. pub fn login(allocator: std.mem.Allocator, desc: *const Descriptor, interrupt: ?*zenai.http.Interrupt) !Session { const tokens = try desc.loginFn(allocator, interrupt); - storeSave(allocator, desc.storeKey(), tokens) catch |err| { - log.warn(.app, "auth token persist failed", .{ .provider = desc.storeKey(), .err = err }); + storeSave(allocator, @tagName(desc.provider), tokens) catch |err| { + log.warn(.app, "auth token persist failed", .{ .provider = @tagName(desc.provider), .err = err }); }; return .{ .allocator = allocator, .descriptor = desc, .tokens = tokens }; } @@ -232,10 +222,14 @@ pub fn login(allocator: std.mem.Allocator, desc: *const Descriptor, interrupt: ? /// (`ensureFresh` runs before the first request)? Lets the picker offer the /// subscription without an API-key env var. pub fn subscriptionAvailable(provider: Config.AiProvider) bool { - const desc = descriptorFor(provider) orelse return false; - const tokens = (storeLoad(std.heap.page_allocator, desc.storeKey()) catch return false) orelse return false; - defer tokens.deinit(std.heap.page_allocator); - return tokens.refresh_token.len > 0 or tokens.expires_at_ms > nowMs(); + if (descriptorFor(provider) == null) return false; + var arena: std.heap.ArenaAllocator = .init(std.heap.page_allocator); + defer arena.deinit(); + const a = arena.allocator(); + const dir = dataDir(a) orelse return false; + const path = storePath(a, dir) catch return false; + const t = readStoreFile(a, path).map.get(@tagName(provider)) orelse return false; + return t.refresh.len > 0 or t.expires_at_ms > nowMs(); } test "TokenSet dup/deinit round-trips and is leak-free" { @@ -253,7 +247,7 @@ test "descriptorFor resolves codex, null for a non-OAuth provider" { try std.testing.expectEqual(@as(?*const Descriptor, null), descriptorFor(.openai)); } -test "token store save/load/delete round-trips" { +test "token store save/load round-trips" { const a = std.testing.allocator; var arena: std.heap.ArenaAllocator = .init(a); defer arena.deinit(); @@ -275,7 +269,4 @@ test "token store save/load/delete round-trips" { try std.testing.expectEqualStrings("ref-1", loaded.refresh_token); try std.testing.expectEqualStrings("acct-9", loaded.account_id.?); try std.testing.expectEqual(@as(i64, 999), loaded.expires_at_ms); - - storeDeleteAt(scratch, dir, "codex"); - try std.testing.expectEqual(@as(?TokenSet, null), try storeLoadAt(a, dir, "codex")); } diff --git a/src/agent/auth/codex.zig b/src/agent/auth/codex.zig index 00e361caa..01b0cff06 100644 --- a/src/agent/auth/codex.zig +++ b/src/agent/auth/codex.zig @@ -136,6 +136,18 @@ const DeviceToken = struct { code_verifier: []const u8, }; +/// Sleep in short slices, polling `interrupt` between them: the REPL's Ctrl-C +/// only fires the interrupt (it never kills the process). +fn waitCancellable(ms: u64, interrupt: ?*zenai.http.Interrupt) error{LoginCancelled}!void { + var remaining_ms = ms; + while (remaining_ms > 0) { + if (interrupt) |it| if (it.isFired()) return error.LoginCancelled; + const slice_ms = @min(remaining_ms, cancel_slice_ms); + lp.io.sleep(.fromMilliseconds(@intCast(slice_ms)), .awake) catch {}; + remaining_ms -= slice_ms; + } +} + fn deviceLogin(allocator: std.mem.Allocator, interrupt: ?*zenai.http.Interrupt) !auth.TokenSet { var arena: std.heap.ArenaAllocator = .init(allocator); defer arena.deinit(); @@ -159,15 +171,7 @@ fn deviceLogin(allocator: std.mem.Allocator, interrupt: ?*zenai.http.Interrupt) .{}, )}); const dt: DeviceToken = while (true) { - // The REPL's Ctrl-C only fires the interrupt (it never kills the - // process), so the wait must poll it. - var remaining_ms: u64 = interval_ms + poll_margin_ms; - while (remaining_ms > 0) { - if (interrupt) |it| if (it.isFired()) return error.LoginCancelled; - const slice_ms = @min(remaining_ms, cancel_slice_ms); - lp.io.sleep(.fromMilliseconds(@intCast(slice_ms)), .awake) catch {}; - remaining_ms -= slice_ms; - } + try waitCancellable(interval_ms + poll_margin_ms, interrupt); const res = try post(a, interrupt, device_token_url, "application/json", poll_body); switch (res.status) { .ok => break try std.json.parseFromSliceLeaky(DeviceToken, a, res.body, .{ .ignore_unknown_fields = true }), diff --git a/src/agent/auth/models_dev.zig b/src/agent/auth/models_dev.zig index 3f4f5ea8d..9e3221b96 100644 --- a/src/agent/auth/models_dev.zig +++ b/src/agent/auth/models_dev.zig @@ -65,13 +65,7 @@ fn readCache(arena: std.mem.Allocator, app_dir: []const u8, provider_id: []const fn writeCache(arena: std.mem.Allocator, app_dir: []const u8, provider_id: []const u8, ids: []const []const u8) !void { const path = try cachePath(arena, app_dir, provider_id); - var af = try std.Io.Dir.cwd().createFileAtomic(lp.io, path, .{ .replace = true }); - defer af.deinit(lp.io); - var buf: [1024]u8 = undefined; - var w = af.file.writer(lp.io, &buf); - try std.json.Stringify.value(Cache{ .fetched_ms = auth.nowMs(), .ids = ids }, .{}, &w.interface); - try w.end(); - try af.replace(lp.io); + try auth.writeJsonAtomic(path, Cache{ .fetched_ms = auth.nowMs(), .ids = ids }, .default_file); } /// Model-id keys of `catalog[provider_id].models`, filtered to tool-call-capable From 97e26ae86ee157d6aacb1398ad681cd30a1b1e93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Wed, 29 Jul 2026 10:07:52 +0200 Subject: [PATCH 21/21] agent: add keep/login/logout menu for subscription providers --- src/agent/Agent.zig | 64 +++++++++++++++++++++++++++++++---------- src/agent/auth/auth.zig | 26 ++++++++++++++++- 2 files changed, 74 insertions(+), 16 deletions(-) diff --git a/src/agent/Agent.zig b/src/agent/Agent.zig index d4646909d..d77433a83 100644 --- a/src/agent/Agent.zig +++ b/src/agent/Agent.zig @@ -924,8 +924,8 @@ fn handleProvider(self: *Agent, _: std.mem.Allocator, rest: []const u8) void { self.terminal.printError("unknown provider: {s} (or 'null' to disable the LLM)", .{trimmed}); return; }; - // Re-selecting vertex (token refresh) or a subscription provider (re-import - // after logging into its CLI) falls through instead of no-op'ing. + // Re-selecting vertex (token refresh) or a subscription provider (the + // keep/login/logout menu) falls through instead of no-op'ing. const vertex_project = provider == .vertex and settings.vertexProjectMode(); const sub_desc = auth.descriptorFor(provider); if (self.credential) |current| if (provider == current.provider and !vertex_project and sub_desc == null) { @@ -943,20 +943,12 @@ fn handleProvider(self: *Agent, _: std.mem.Allocator, rest: []const u8) void { }; return; } - // Subscription provider: use the stored session, or run the interactive - // login (device-code flow) when there's none yet. + // Subscription provider: menu over the stored session, or first-time login. if (sub_desc) |desc| { - var owned = (auth.sessionFor(self.allocator, provider) catch null) orelse - (auth.login(self.allocator, desc, &self.http_interrupt) catch |err| { - if (err == error.LoginCancelled) { - // Undo `requestCancel`'s side effects before the next turn. - self.resetAfterCancel(self.conversation.messages.items.len); - self.terminal.printInfo("{s} login cancelled", .{desc.label}); - return; - } - self.terminal.printError("{s} login failed: {s}", .{ desc.label, @errorName(err) }); - return; - }); + var owned = if (auth.sessionFor(self.allocator, provider) catch null) |stored| + self.promptStoredSubscription(desc, stored) orelse return + else + self.subscriptionLogin(desc) orelse return; self.setProvider(.{ .provider = provider, .key = .{ .session = owned } }) catch |err| { owned.deinit(); self.terminal.printError("failed to set provider: {s}", .{@errorName(err)}); @@ -985,6 +977,48 @@ fn handleProvider(self: *Agent, _: std.mem.Allocator, rest: []const u8) void { }; } +/// Interactive device-code login; cancellation/failure is reported here. +fn subscriptionLogin(self: *Agent, desc: *const auth.Descriptor) ?auth.Session { + return auth.login(self.allocator, desc, &self.http_interrupt) catch |err| { + if (err == error.LoginCancelled) { + // Undo `requestCancel`'s side effects before the next turn. + self.resetAfterCancel(self.conversation.messages.items.len); + self.terminal.printInfo("{s} login cancelled", .{desc.label}); + return null; + } + self.terminal.printError("{s} login failed: {s}", .{ desc.label, @errorName(err) }); + return null; + }; +} + +/// Menu for re-selecting an already-logged-in subscription provider: keep the +/// stored login, re-run it (switch accounts), or log out. Null when there is +/// nothing to activate. +fn promptStoredSubscription(self: *Agent, desc: *const auth.Descriptor, stored: auth.Session) ?auth.Session { + var session = stored; + var header_buf: [128]u8 = undefined; + const header = std.fmt.bufPrint(&header_buf, "Already logged in with your {s}. Pick:", .{desc.label}) catch + "Already logged in. Pick:"; + const idx = picker.promptNumberedChoice(header, &.{ + "keep — use the stored login", + "login — run the login again (e.g. to switch accounts)", + "logout — forget the stored login", + }, 0) catch { + session.deinit(); + return null; + }; + if (idx == 0) return session; + session.deinit(); + if (idx == 1) return self.subscriptionLogin(desc); + auth.logout(self.allocator, desc) catch |err| { + self.terminal.printError("could not remove the stored login: {s}", .{@errorName(err)}); + return null; + }; + self.terminal.printInfo("{s} logged out.", .{desc.label}); + if (self.credential) |c| if (c.provider == desc.provider) self.disableProvider(); + return null; +} + /// Tear down the LLM client and persist a null provider so the next REPL launch /// starts in basic mode without re-prompting. Inverse of `setProvider`. fn disableProvider(self: *Agent) void { diff --git a/src/agent/auth/auth.zig b/src/agent/auth/auth.zig index bdc654b75..cb64147a7 100644 --- a/src/agent/auth/auth.zig +++ b/src/agent/auth/auth.zig @@ -143,6 +143,22 @@ fn storeSaveAt(arena: std.mem.Allocator, dir: []const u8, id: []const u8, tokens try writeJsonAtomic(path, map, .fromMode(0o600)); } +fn storeDeleteAt(arena: std.mem.Allocator, dir: []const u8, id: []const u8) !void { + const path = try storePath(arena, dir); + var map = readStoreFile(arena, path); + if (!map.map.swapRemove(id)) return; + try writeJsonAtomic(path, map, .fromMode(0o600)); +} + +/// Remove the stored token for `id`. No-op when absent. +fn storeDelete(allocator: std.mem.Allocator, id: []const u8) !void { + var arena: std.heap.ArenaAllocator = .init(allocator); + defer arena.deinit(); + const a = arena.allocator(); + const dir = dataDir(a) orelse return error.NoDataDir; + return storeDeleteAt(a, dir, id); +} + /// Load the stored token for `id`, or null when absent/unreadable/no data dir. pub fn storeLoad(allocator: std.mem.Allocator, id: []const u8) !?TokenSet { var da: std.heap.ArenaAllocator = .init(allocator); @@ -218,6 +234,11 @@ pub fn login(allocator: std.mem.Allocator, desc: *const Descriptor, interrupt: ? return .{ .allocator = allocator, .descriptor = desc, .tokens = tokens }; } +/// Forget the stored credential for `desc`'s provider. +pub fn logout(allocator: std.mem.Allocator, desc: *const Descriptor) !void { + return storeDelete(allocator, @tagName(desc.provider)); +} + /// Is a stored credential available for `provider` — unexpired, or refreshable /// (`ensureFresh` runs before the first request)? Lets the picker offer the /// subscription without an API-key env var. @@ -247,7 +268,7 @@ test "descriptorFor resolves codex, null for a non-OAuth provider" { try std.testing.expectEqual(@as(?*const Descriptor, null), descriptorFor(.openai)); } -test "token store save/load round-trips" { +test "token store save/load/delete round-trips" { const a = std.testing.allocator; var arena: std.heap.ArenaAllocator = .init(a); defer arena.deinit(); @@ -269,4 +290,7 @@ test "token store save/load round-trips" { try std.testing.expectEqualStrings("ref-1", loaded.refresh_token); try std.testing.expectEqualStrings("acct-9", loaded.account_id.?); try std.testing.expectEqual(@as(i64, 999), loaded.expires_at_ms); + + try storeDeleteAt(scratch, dir, "codex"); + try std.testing.expectEqual(@as(?TokenSet, null), try storeLoadAt(a, dir, "codex")); }