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"); +}