From c67622466b37c837aed2059163584aab30169f99 Mon Sep 17 00:00:00 2001 From: Pranit Date: Mon, 31 Aug 2026 16:27:13 -0400 Subject: [PATCH 01/12] Unify model provider authentication storage Store saved Gateway, fx login, Codex, and Grok credentials in one versioned auth document. Migrate legacy files and Keychain items after verification. Keep diagnostics read-only and scope refresh and logout updates to one credential. --- README.md | 4 +- src/core/auth/auth_store.zig | 352 +++++++ src/core/auth/chatgpt_oauth.zig | 12 +- src/core/auth/chatgpt_session.zig | 161 +-- src/core/auth/credentials.zig | 40 +- src/core/auth/grok_oauth.zig | 12 +- src/core/auth/grok_session.zig | 164 +-- src/core/auth/oauth_session.zig | 988 +----------------- src/core/hosts/host.zig | 14 + src/core/hosts/native_auth_store.zig | 1029 +++++++++++++++++++ src/core/hosts/native_keychain.zig | 6 +- src/core/hosts/native_secret_store.zig | 215 +--- tests/e2e/acp.test.ts | 1 + tests/e2e/auth-refresh.test.ts | 11 +- tests/e2e/cli.test.ts | 89 +- tests/e2e/oauth-keychain-migration.test.ts | 34 +- tests/e2e/tui-auth-source-selection.test.ts | 97 +- 17 files changed, 1802 insertions(+), 1427 deletions(-) create mode 100644 src/core/auth/auth_store.zig create mode 100644 src/core/hosts/native_auth_store.zig diff --git a/README.md b/README.md index 37f120eb0..43b4a64e3 100644 --- a/README.md +++ b/README.md @@ -49,9 +49,9 @@ fx `fx login codex` and `fx login grok` select that provider and a model from its authenticated catalog. Inside fx, open `/setup` and choose **Model provider** to move between Gateway, Codex, and Grok. `/model` lists the active provider's fetched models. Subscription model IDs are the raw IDs returned by each authenticated catalog. Use `/logout codex` or `/logout grok` to remove that subscription session without affecting other providers; choosing it again from **Model provider** starts sign-in. -The OpenAI Codex route uses ChatGPT subscription access directly and never sends its OAuth token to Vercel AI Gateway. The session is stored privately at `~/.fx/chatgpt-auth.json` and refreshed when needed. On supported Codex models, `/fast` requests OpenAI's priority service tier and consumes ChatGPT credits at the higher Fast mode rate. +The OpenAI Codex route uses ChatGPT subscription access directly and never sends its OAuth token to Vercel AI Gateway. The session is stored with the other model-provider credentials in fx's private auth store and refreshed when needed. On supported Codex models, `/fast` requests OpenAI's priority service tier and consumes ChatGPT credits at the higher Fast mode rate. -The Grok route uses subscription access directly at xAI and never sends its OAuth token to Vercel AI Gateway or OpenAI. Its session is stored privately at `~/.fx/grok-auth.json`, refreshed when needed, and used only with the authenticated xAI catalog and Responses API. +The Grok route uses subscription access directly at xAI and never sends its OAuth token to Vercel AI Gateway or OpenAI. Its session is stored in the same private auth store, refreshed when needed, and used only with the authenticated xAI catalog and Responses API. The portable backend keeps that store at `~/.fx/auth.json`. To use an AI Gateway API key instead: diff --git a/src/core/auth/auth_store.zig b/src/core/auth/auth_store.zig new file mode 100644 index 000000000..3c0a222dd --- /dev/null +++ b/src/core/auth/auth_store.zig @@ -0,0 +1,352 @@ +const std = @import("std"); +const secret = @import("secret.zig"); + +const Allocator = std.mem.Allocator; + +pub const StoredSource = enum { + stored_key, + fx_login, + chatgpt_subscription, + grok_subscription, +}; + +pub const StoreState = enum { + empty, + legacy, + current, + malformed_current, +}; + +pub const LoadIntent = enum { + inspect, + active, +}; + +pub const LoadDecision = enum { + missing, + use_legacy, + migrate_legacy, + use_current, + reject_current, +}; + +pub fn decide_load(state: StoreState, intent: LoadIntent) LoadDecision { + return switch (state) { + .empty => .missing, + .legacy => if (intent == .inspect) .use_legacy else .migrate_legacy, + .current => .use_current, + .malformed_current => .reject_current, + }; +} + +const slot_count = std.meta.fields(StoredSource).len; + +pub const Document = struct { + slots: [slot_count]?[]u8 = [_]?[]u8{null} ** slot_count, + + pub fn deinit(self: *Document, alloc: Allocator) void { + for (&self.slots) |*slot| { + if (slot.*) |value| secret.zeroAndFree(alloc, value); + slot.* = null; + } + self.* = .{}; + } + + pub fn get(self: *const Document, source: StoredSource) ?[]const u8 { + return self.slots[@intFromEnum(source)]; + } + + pub fn replaced( + self: *const Document, + alloc: Allocator, + source: StoredSource, + value: []const u8, + ) !Document { + if (source == .stored_key) { + try validate_entry(alloc, source, value); + return self.transformed(alloc, source, value); + } + var parsed = std.json.parseFromSlice(std.json.Value, alloc, value, .{}) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => return error.InvalidAuthDocument, + }; + defer parsed.deinit(); + if (parsed.value != .object) return error.InvalidAuthDocument; + const canonical = try stringify_value(alloc, parsed.value); + defer secret.zeroAndFree(alloc, canonical); + return self.transformed(alloc, source, canonical); + } + + pub fn removed( + self: *const Document, + alloc: Allocator, + source: StoredSource, + ) !Document { + return self.transformed(alloc, source, null); + } + + pub fn eql(self: *const Document, other: Document) bool { + for (std.meta.tags(StoredSource)) |source| { + const left = self.get(source); + const right = other.get(source); + if (left == null or right == null) { + if (left != null or right != null) return false; + continue; + } + if (!std.mem.eql(u8, left.?, right.?)) return false; + } + return true; + } + + pub fn parse(alloc: Allocator, bytes: []const u8) !Document { + var parsed = std.json.parseFromSlice(std.json.Value, alloc, bytes, .{}) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => return error.InvalidAuthDocument, + }; + defer parsed.deinit(); + if (parsed.value != .object or parsed.value.object.count() != 2) { + return error.InvalidAuthDocument; + } + const version = parsed.value.object.get("version") orelse return error.InvalidAuthDocument; + if (version != .integer or version.integer != 2) return error.InvalidAuthDocument; + const credentials = parsed.value.object.get("credentials") orelse return error.InvalidAuthDocument; + if (credentials != .object) return error.InvalidAuthDocument; + + var document: Document = .{}; + errdefer document.deinit(alloc); + var iterator = credentials.object.iterator(); + while (iterator.next()) |item| { + const source = std.meta.stringToEnum(StoredSource, item.key_ptr.*) orelse + return error.InvalidAuthDocument; + if (item.value_ptr.* != .object or item.value_ptr.object.count() != 1) { + return error.InvalidAuthDocument; + } + const value = if (source == .stored_key) value: { + const secret_value = item.value_ptr.object.get("secret") orelse + return error.InvalidAuthDocument; + if (secret_value != .string or secret_value.string.len == 0) { + return error.InvalidAuthDocument; + } + break :value try alloc.dupe(u8, secret_value.string); + } else value: { + const session_value = item.value_ptr.object.get("session") orelse + return error.InvalidAuthDocument; + if (session_value != .object) return error.InvalidAuthDocument; + break :value try stringify_value(alloc, session_value); + }; + document.slots[@intFromEnum(source)] = value; + } + return document; + } + + pub fn stringify(self: *const Document, alloc: Allocator) ![]u8 { + var out: std.Io.Writer.Allocating = .init(alloc); + errdefer out.deinit(); + try out.writer.writeAll("{\"version\":2,\"credentials\":{"); + var wrote_entry = false; + for (std.meta.tags(StoredSource)) |source| { + const value = self.get(source) orelse continue; + try validate_entry(alloc, source, value); + if (wrote_entry) try out.writer.writeByte(','); + wrote_entry = true; + try std.json.Stringify.value(@tagName(source), .{}, &out.writer); + if (source == .stored_key) { + try out.writer.writeAll(":{\"secret\":"); + try std.json.Stringify.value(value, .{}, &out.writer); + try out.writer.writeByte('}'); + } else { + try out.writer.writeAll(":{\"session\":"); + try out.writer.writeAll(value); + try out.writer.writeByte('}'); + } + } + try out.writer.writeAll("}}\n"); + return out.toOwnedSlice(); + } + + fn transformed( + self: *const Document, + alloc: Allocator, + changed_source: StoredSource, + replacement: ?[]const u8, + ) !Document { + var next: Document = .{}; + errdefer next.deinit(alloc); + + for (std.meta.tags(StoredSource)) |source| { + const value = if (source == changed_source) replacement else self.get(source); + if (value) |bytes| next.slots[@intFromEnum(source)] = try alloc.dupe(u8, bytes); + } + return next; + } +}; + +fn validate_entry(alloc: Allocator, source: StoredSource, value: []const u8) !void { + if (value.len == 0) return error.InvalidAuthDocument; + if (source == .stored_key) return; + var parsed = std.json.parseFromSlice(std.json.Value, alloc, value, .{}) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => return error.InvalidAuthDocument, + }; + defer parsed.deinit(); + if (parsed.value != .object) return error.InvalidAuthDocument; +} + +fn stringify_value(alloc: Allocator, value: std.json.Value) ![]u8 { + var out: std.Io.Writer.Allocating = .init(alloc); + errdefer out.deinit(); + std.json.Stringify.value(value, .{}, &out.writer) catch return error.OutOfMemory; + return out.toOwnedSlice() catch return error.OutOfMemory; +} + +fn check_parse_allocation_failures(alloc: Allocator) !void { + var document = try Document.parse( + alloc, + "{\"version\":2,\"credentials\":{\"stored_key\":{\"secret\":\"key\"},\"fx_login\":{\"session\":{\"version\":1}}}}", + ); + defer document.deinit(alloc); +} + +test "replacing one stored credential preserves the original and unrelated sources" { + const alloc = std.testing.allocator; + const empty: Document = .{}; + + var gateway = try empty.replaced(alloc, .stored_key, "gateway-secret"); + defer gateway.deinit(alloc); + var with_codex = try gateway.replaced(alloc, .chatgpt_subscription, "{\"version\":1}"); + defer with_codex.deinit(alloc); + var replaced_codex = try with_codex.replaced(alloc, .chatgpt_subscription, "{\"version\":2}"); + defer replaced_codex.deinit(alloc); + + try std.testing.expectEqualStrings("gateway-secret", gateway.get(.stored_key).?); + try std.testing.expect(gateway.get(.chatgpt_subscription) == null); + try std.testing.expectEqualStrings("gateway-secret", with_codex.get(.stored_key).?); + try std.testing.expectEqualStrings("{\"version\":1}", with_codex.get(.chatgpt_subscription).?); + try std.testing.expectEqualStrings("gateway-secret", replaced_codex.get(.stored_key).?); + try std.testing.expectEqualStrings("{\"version\":2}", replaced_codex.get(.chatgpt_subscription).?); +} + +test "removing one stored credential is idempotent and preserves unrelated sources" { + const alloc = std.testing.allocator; + const empty: Document = .{}; + + var gateway = try empty.replaced(alloc, .fx_login, "{\"version\":1}"); + defer gateway.deinit(alloc); + var with_grok = try gateway.replaced(alloc, .grok_subscription, "{\"version\":1}"); + defer with_grok.deinit(alloc); + var removed = try with_grok.removed(alloc, .grok_subscription); + defer removed.deinit(alloc); + var removed_again = try removed.removed(alloc, .grok_subscription); + defer removed_again.deinit(alloc); + + try std.testing.expectEqualStrings("{\"version\":1}", removed.get(.fx_login).?); + try std.testing.expect(removed.get(.grok_subscription) == null); + try std.testing.expect(removed.eql(removed_again)); +} + +test "version two auth document round trips every stored source" { + const alloc = std.testing.allocator; + var document: Document = .{}; + defer document.deinit(alloc); + + const fixtures = [_]struct { source: StoredSource, value: []const u8 }{ + .{ .source = .stored_key, .value = "gateway-secret" }, + .{ .source = .fx_login, .value = "{\"version\":1,\"access_token\":\"vercel\"}" }, + .{ .source = .chatgpt_subscription, .value = "{\"version\":1,\"access_token\":\"codex\"}" }, + .{ .source = .grok_subscription, .value = "{\"version\":1,\"access_token\":\"grok\"}" }, + }; + for (fixtures) |fixture| { + const next = try document.replaced(alloc, fixture.source, fixture.value); + document.deinit(alloc); + document = next; + } + + const encoded = try document.stringify(alloc); + defer secret.zeroAndFree(alloc, encoded); + var decoded = try Document.parse(alloc, encoded); + defer decoded.deinit(alloc); + + try std.testing.expect(document.eql(decoded)); +} + +test "stored session replacement canonicalizes insignificant JSON whitespace" { + const alloc = std.testing.allocator; + const empty: Document = .{}; + var document = try empty.replaced( + alloc, + .fx_login, + "{\"version\":1,\"access_token\":\"token\"}\n", + ); + defer document.deinit(alloc); + try std.testing.expectEqualStrings( + "{\"version\":1,\"access_token\":\"token\"}", + document.get(.fx_login).?, + ); +} + +test "auth document rejects malformed or unknown version two state" { + const alloc = std.testing.allocator; + const invalid = [_][]const u8{ + "{}", + "{\"version\":1,\"credentials\":{}}", + "{\"version\":2,\"credentials\":[]}", + "{\"version\":2,\"credentials\":{\"stored_key\":{\"secret\":\"\"}}}", + "{\"version\":2,\"credentials\":{\"fx_login\":\"not-an-object\"}}", + "{\"version\":2,\"credentials\":{\"unknown\":{}}}", + }; + for (invalid) |bytes| { + try std.testing.expectError(error.InvalidAuthDocument, Document.parse(alloc, bytes)); + } +} + +test "auth document parse cleans up allocation failures" { + try std.testing.checkAllAllocationFailures( + std.testing.allocator, + check_parse_allocation_failures, + .{}, + ); +} + +test "load decision keeps inspection pure and migrates only active legacy state" { + const cases = [_]struct { + state: StoreState, + intent: LoadIntent, + expected: LoadDecision, + }{ + .{ .state = .empty, .intent = .inspect, .expected = .missing }, + .{ .state = .empty, .intent = .active, .expected = .missing }, + .{ .state = .legacy, .intent = .inspect, .expected = .use_legacy }, + .{ .state = .legacy, .intent = .active, .expected = .migrate_legacy }, + .{ .state = .current, .intent = .inspect, .expected = .use_current }, + .{ .state = .current, .intent = .active, .expected = .use_current }, + .{ .state = .malformed_current, .intent = .inspect, .expected = .reject_current }, + .{ .state = .malformed_current, .intent = .active, .expected = .reject_current }, + }; + for (cases) |case| { + try std.testing.expectEqual(case.expected, decide_load(case.state, case.intent)); + } +} + +test "auth document parser handles arbitrary bytes" { + try std.testing.fuzz({}, fuzz_auth_document, .{ + .corpus = &.{ + "", + "{}", + "{\"version\":2,\"credentials\":{}}", + "{\"version\":2,\"credentials\":{\"stored_key\":{\"secret\":\"key\"}}}", + }, + }); +} + +fn fuzz_auth_document(_: void, smith: *std.testing.Smith) !void { + var buffer: [4096]u8 = undefined; + const len: usize = @intCast(smith.slice(&buffer)); + var document = Document.parse(std.testing.allocator, buffer[0..len]) catch return; + defer document.deinit(std.testing.allocator); + + const encoded = try document.stringify(std.testing.allocator); + defer secret.zeroAndFree(std.testing.allocator, encoded); + var reparsed = try Document.parse(std.testing.allocator, encoded); + defer reparsed.deinit(std.testing.allocator); + try std.testing.expect(document.eql(reparsed)); +} diff --git a/src/core/auth/chatgpt_oauth.zig b/src/core/auth/chatgpt_oauth.zig index f7d3594a3..5d0c3f721 100644 --- a/src/core/auth/chatgpt_oauth.zig +++ b/src/core/auth/chatgpt_oauth.zig @@ -383,7 +383,7 @@ pub fn logout() !chatgpt_session.DeleteOutcome { } pub fn sourceExists(alloc: Allocator) !bool { - var session = (try chatgpt_session.load(alloc)) orelse return false; + var session = (try chatgpt_session.loadStored(alloc)) orelse return false; defer session.deinit(alloc); return true; } @@ -394,7 +394,7 @@ pub fn loadAccess( mode: RefreshMode, ) !?Access { if (mode == .stored) { - var session = (try chatgpt_session.load(alloc)) orelse return null; + var session = (try chatgpt_session.loadStored(alloc)) orelse return null; defer session.deinit(alloc); return takeAccess(&session); } @@ -439,13 +439,15 @@ fn refreshSession( defer token.deinit(alloc); const account_id = try extractAccountId(alloc, token.access_token); - errdefer alloc.free(account_id); + var account_id_owned = true; + errdefer if (account_id_owned) alloc.free(account_id); if (!std.mem.eql(u8, account_id, session.account_id)) { return error.ChatGptAccountChanged; } const refresh_token = if (token.refresh_token) |rotated| rotated else try alloc.dupe(u8, session.refresh_token); + var refresh_token_owned = true; + errdefer if (refresh_token_owned) secret.zeroAndFree(alloc, refresh_token); if (token.refresh_token != null) token.refresh_token = null; - errdefer secret.zeroAndFree(alloc, refresh_token); const expires_at_ms = if (token.expires_in) |expires_in| blk: { const duration_ms = std.math.mul(i64, expires_in, std.time.ms_per_s) catch return error.InvalidChatGptOAuthResponse; @@ -459,6 +461,8 @@ fn refreshSession( .account_id = account_id, }; token.access_token = &.{}; + account_id_owned = false; + refresh_token_owned = false; errdefer replacement.deinit(alloc); try mutation.save(alloc, replacement); diff --git a/src/core/auth/chatgpt_session.zig b/src/core/auth/chatgpt_session.zig index 6c9ed7249..8a6ff56dc 100644 --- a/src/core/auth/chatgpt_session.zig +++ b/src/core/auth/chatgpt_session.zig @@ -1,19 +1,14 @@ const std = @import("std"); const debug_trace = @import("../shared/debug_trace.zig"); const host_target = @import("../hosts/target.zig"); -const io_mod = @import("../shared/io.zig"); -const profile_paths = @import("../shared/profile_paths.zig"); +const native_auth_store = if (host_target.is_wasm) struct {} else @import("../hosts/native_auth_store.zig"); const secret = @import("secret.zig"); const Allocator = std.mem.Allocator; const schema_version: i64 = 1; -const max_auth_file_bytes: usize = 64 * 1024; const expiry_skew_ms: i64 = 60 * 1000; -const mutation_lock_file_name = "chatgpt-auth.lock"; -const mutation_lock_deadline_ms: u64 = 2000; pub const issuer = "https://auth.openai.com"; -pub const auth_file_name = profile_paths.chatgpt_auth_file_name; pub fn refreshDeadlineMs(expires_at_ms: i64) i64 { return @max(expires_at_ms - expiry_skew_ms, 0); @@ -43,82 +38,79 @@ pub const DeleteOutcome = enum { deleted_not_durable, }; -pub const Mutation = struct { - fx_dir: io_mod.VerifiedDir, - lock: io_mod.TimedAdvisoryLock, +pub const Mutation = if (host_target.is_wasm) WasmMutation else NativeMutation; + +const WasmMutation = struct { + pub fn deinit(self: *WasmMutation) void { + self.* = undefined; + } + + pub fn load(_: *WasmMutation, _: Allocator) !?Session { + return null; + } + + pub fn save(_: *WasmMutation, _: Allocator, _: Session) !void { + return error.ChatGptOAuthUnavailable; + } + + pub fn delete(_: *WasmMutation) !DeleteOutcome { + return .missing; + } +}; + +const NativeMutation = struct { + inner: native_auth_store.EntryMutation, pub fn deinit(self: *Mutation) void { - self.lock.release(); - self.fx_dir.close(); + self.inner.deinit(); self.* = undefined; } pub fn load(self: *Mutation, alloc: Allocator) !?Session { - return loadFromDir(alloc, &self.fx_dir.dir, true); + const bytes = (try self.inner.load(alloc)) orelse return null; + defer secret.zeroAndFree(alloc, bytes); + return parse(alloc, bytes) catch |err| switch (err) { + error.OutOfMemory => return err, + else => { + debug_trace.logf("auth", "ChatGPT session load failed step=parse_common_mutation err={s}", .{@errorName(err)}); + return null; + }, + }; } pub fn save(self: *Mutation, alloc: Allocator, session: Session) !void { const text = try stringify(alloc, session); defer secret.zeroAndFree(alloc, text); - try io_mod.durableReplaceVerified(alloc, &self.fx_dir, auth_file_name, text); + try self.inner.save(alloc, text); } pub fn delete(self: *Mutation) !DeleteOutcome { - self.fx_dir.dir.deleteFile(io_mod.getIo(), auth_file_name) catch |err| switch (err) { - error.FileNotFound => return .missing, - else => return err, + return switch (try self.inner.delete(std.heap.c_allocator)) { + .deleted => .deleted, + .missing => .missing, + .deleted_not_durable => .deleted_not_durable, }; - const durable: io_mod.DurableOps = .{}; - durable.sync_dir(durable.ctx, self.fx_dir.dir) catch return .deleted_not_durable; - return .deleted; } }; pub fn load(alloc: Allocator) !?Session { if (comptime host_target.is_wasm) return null; - const home = io_mod.getenv("HOME") orelse return null; - var home_dir = std.Io.Dir.openDirAbsolute(io_mod.getIo(), home, .{ .iterate = true }) catch |err| { - debug_trace.logf("auth", "ChatGPT session load failed step=open_home err={s}", .{@errorName(err)}); - return null; - }; - defer home_dir.close(io_mod.getIo()); + return loadNative(alloc, .active); +} - var fx_dir = home_dir.openDir(io_mod.getIo(), profile_paths.root_dir_name, .{ - .iterate = true, - .follow_symlinks = false, - }) catch |err| { - if (err != error.FileNotFound) { - debug_trace.logf("auth", "ChatGPT session load failed step=open_profile err={s}", .{@errorName(err)}); - } - return null; - }; - defer fx_dir.close(io_mod.getIo()); - return loadFromDir(alloc, &fx_dir, false); +pub fn loadStored(alloc: Allocator) !?Session { + if (comptime host_target.is_wasm) return null; + return loadNative(alloc, .inspect); } -fn loadFromDir(alloc: Allocator, fx_dir: *std.Io.Dir, report_open_failure: bool) !?Session { - var file = fx_dir.openFile(io_mod.getIo(), auth_file_name, .{ - .mode = .read_only, - .allow_directory = false, - .follow_symlinks = false, - .resolve_beneath = true, - }) catch |err| switch (err) { - error.FileNotFound => return null, +fn loadNative(alloc: Allocator, intent: @import("auth_store.zig").LoadIntent) !?Session { + const bytes = (native_auth_store.load_entry(alloc, .chatgpt_subscription, intent) catch |err| switch (err) { + error.OutOfMemory => return err, else => { - debug_trace.logf("auth", "ChatGPT session load failed step=open_file err={s}", .{@errorName(err)}); - if (report_open_failure) return err; + debug_trace.logf("auth", "ChatGPT session load failed step=common_store err={s}", .{@errorName(err)}); return null; }, - }; - defer file.close(io_mod.getIo()); - - const stat = try file.stat(io_mod.getIo()); - if (stat.kind != .file or stat.permissions.toMode() & 0o077 != 0) { - debug_trace.logf("auth", "ChatGPT session load failed step=permissions err=InsecureAuthFile", .{}); - return null; - } - - const bytes = try io_mod.readFileToEnd(alloc, &file, max_auth_file_bytes); + }) orelse return null; defer secret.zeroAndFree(alloc, bytes); return parse(alloc, bytes) catch |err| switch (err) { error.OutOfMemory => return err, @@ -131,67 +123,14 @@ fn loadFromDir(alloc: Allocator, fx_dir: *std.Io.Dir, report_open_failure: bool) pub fn saveNewSession(alloc: Allocator, session: Session) !void { if (comptime host_target.is_wasm) return error.ChatGptOAuthUnavailable; - var mutation = try beginMutation(); + var mutation = try beginExistingMutation() orelse return error.HomeNotSet; defer mutation.deinit(); try mutation.save(alloc, session); } pub fn beginExistingMutation() !?Mutation { if (comptime host_target.is_wasm) return null; - const home = io_mod.getenv("HOME") orelse return error.HomeNotSet; - var home_dir = io_mod.VerifiedDir{ - .dir = try std.Io.Dir.openDirAbsolute(io_mod.getIo(), home, .{ .iterate = true }), - }; - defer home_dir.close(); - - const fx_dir = openExistingPrivateFxDir(&home_dir) catch |err| switch (err) { - error.FileNotFound => return null, - else => return err, - }; - return try lockMutation(fx_dir); -} - -fn beginMutation() !Mutation { - const home = io_mod.getenv("HOME") orelse return error.HomeNotSet; - var home_dir = io_mod.VerifiedDir{ - .dir = try std.Io.Dir.openDirAbsolute(io_mod.getIo(), home, .{ .iterate = true }), - }; - defer home_dir.close(); - - const fx_dir = try io_mod.openOrCreateVerifiedPrivateDir(&home_dir, profile_paths.root_dir_name); - return lockMutation(fx_dir); -} - -fn lockMutation(open_fx_dir: io_mod.VerifiedDir) !Mutation { - var fx_dir = open_fx_dir; - errdefer fx_dir.close(); - var lock = try io_mod.acquireTimedAdvisoryLock( - &fx_dir, - mutation_lock_file_name, - mutation_lock_deadline_ms, - ); - errdefer lock.release(); - return .{ .fx_dir = fx_dir, .lock = lock }; -} - -fn openExistingPrivateFxDir(home_dir: *io_mod.VerifiedDir) !io_mod.VerifiedDir { - var dir = try home_dir.dir.openDir(io_mod.getIo(), profile_paths.root_dir_name, .{ - .iterate = true, - .follow_symlinks = false, - }); - errdefer dir.close(io_mod.getIo()); - - const initial_stat = try dir.stat(io_mod.getIo()); - if (initial_stat.kind != .directory) return error.DurablePathUnsafe; - if (initial_stat.permissions.toMode() & 0o200 == 0) return error.PrivateStatePermissionsUnsupported; - dir.setPermissions(io_mod.getIo(), std.Io.File.Permissions.fromMode(0o700)) catch { - return error.PrivateStatePermissionsUnsupported; - }; - const stat = try dir.stat(io_mod.getIo()); - if (stat.kind != .directory or stat.permissions.toMode() & 0o777 != 0o700) { - return error.PrivateStatePermissionsUnsupported; - } - return .{ .dir = dir }; + return .{ .inner = try native_auth_store.begin_entry_mutation(.chatgpt_subscription) }; } pub fn parse(alloc: Allocator, bytes: []const u8) !Session { diff --git a/src/core/auth/credentials.zig b/src/core/auth/credentials.zig index 563a11c04..33ee26442 100644 --- a/src/core/auth/credentials.zig +++ b/src/core/auth/credentials.zig @@ -350,7 +350,7 @@ pub fn resolvePreferring( if (secret_store.isDisabled()) return .{ .fx_login_status = fx_login_status }; var status: StoredKeyReadStatus = .not_found; - const stored = loadSource(alloc, transport, secret_store, .stored_key) catch |err| blk: { + const stored = loadStoredKeyCredential(alloc, secret_store, mode) catch |err| blk: { if (err == error.OutOfMemory) return err; status = .unavailable; debug_trace.logf("auth", "stored key load failed err={s} status={t}", .{ @errorName(err), status }); @@ -394,6 +394,7 @@ fn loadPreferredSource( .stored => loadStoredGrokCredential(alloc), .refresh_if_needed => loadGrokCredential(alloc, transport, .if_needed), }, + .stored_key => loadStoredKeyCredential(alloc, secret_store, mode), else => loadSource(alloc, transport, secret_store, source), }; } @@ -408,7 +409,7 @@ pub fn loadSource( .vercel_oidc_token => loadEnvCredential(alloc, "VERCEL_OIDC_TOKEN", source), .ai_gateway_api_key => loadEnvCredential(alloc, "AI_GATEWAY_API_KEY", source), .fx_login => loadFxLoginCredential(alloc, transport), - .stored_key => loadStoredKeyCredential(alloc, secret_store), + .stored_key => loadStoredKeyCredential(alloc, secret_store, .refresh_if_needed), .chatgpt_subscription => loadChatGptCredential(alloc, transport, .if_needed), .grok_subscription => loadGrokCredential(alloc, transport, .if_needed), }; @@ -423,7 +424,7 @@ pub fn sourceExists( .vercel_oidc_token => nonEmptyEnvValue("VERCEL_OIDC_TOKEN") != null, .ai_gateway_api_key => nonEmptyEnvValue("AI_GATEWAY_API_KEY") != null, .fx_login => blk: { - const loaded = oauth_session.load(alloc) catch |err| switch (err) { + const loaded = oauth_session.loadStored(alloc) catch |err| switch (err) { error.OutOfMemory => return err, else => { debug_trace.logf("auth", "source probe failed source=fx_login err={s}", .{@errorName(err)}); @@ -438,7 +439,7 @@ pub fn sourceExists( .grok_subscription => grok_oauth.sourceExists(alloc), .stored_key => blk: { if (secret_store.isDisabled()) break :blk false; - const stored = secret_store.load(alloc) catch |err| switch (err) { + const stored = secret_store.loadStored(alloc) catch |err| switch (err) { error.OutOfMemory => return err, else => { debug_trace.logf("auth", "source probe failed source=stored_key err={s}", .{@errorName(err)}); @@ -467,9 +468,13 @@ fn loadEnvCredential( fn loadStoredKeyCredential( alloc: std.mem.Allocator, secret_store: host.SecretStore, + mode: LoadMode, ) !?Credential { if (secret_store.isDisabled()) return null; - const value = (try secret_store.load(alloc)) orelse return null; + const value = (try if (mode == .stored) + secret_store.loadStored(alloc) + else + secret_store.load(alloc)) orelse return null; return .{ .token = value, .source = .stored_key }; } @@ -544,7 +549,7 @@ pub fn loadFxLoginCredential( } fn loadStoredFxLoginCredential(alloc: std.mem.Allocator) !?Credential { - var session = (try oauth_session.load(alloc)) orelse return null; + var session = (try oauth_session.loadStored(alloc)) orelse return null; defer session.deinit(alloc); return takeCredentialFromSession(&session, null); } @@ -855,6 +860,7 @@ const SecretStoreFixture = struct { disabled: bool = false, unreadable: bool = false, load_calls: usize = 0, + stored_load_calls: usize = 0, fn provider(self: *@This()) host.SecretStore { return .{ @@ -862,6 +868,7 @@ const SecretStoreFixture = struct { .backend_label = "test credential store", .is_disabled_fn = isDisabled, .load_fn = load, + .load_stored_fn = loadStored, .store_fn = store, .store_interactive_fn = storeInteractive, }; @@ -878,6 +885,19 @@ const SecretStoreFixture = struct { ) host.SecretStoreLoadError!?[]u8 { const self: *@This() = @ptrCast(@alignCast(raw_context.?)); self.load_calls += 1; + return self.loadValue(alloc); + } + + fn loadStored( + raw_context: ?*anyopaque, + alloc: std.mem.Allocator, + ) host.SecretStoreLoadError!?[]u8 { + const self: *@This() = @ptrCast(@alignCast(raw_context.?)); + self.stored_load_calls += 1; + return self.loadValue(alloc); + } + + fn loadValue(self: *@This(), alloc: std.mem.Allocator) host.SecretStoreLoadError!?[]u8 { if (self.unreadable) return error.StoredKeyUnreadable; const value = self.value orelse return null; return try alloc.dupe(u8, value); @@ -1003,6 +1023,7 @@ test "a disabled stored key is reported as never attempted, not as absent" { try std.testing.expectEqual(StoredKeyReadStatus.not_attempted, resolution.stored_key_status); } try std.testing.expectEqual(@as(usize, 0), store_fixture.load_calls); + try std.testing.expectEqual(@as(usize, 0), store_fixture.stored_load_calls); } test "credential resolution loads a stored key only through the injected host port" { @@ -1019,7 +1040,8 @@ test "credential resolution loads a stored key only through the injected host po ); defer if (resolution.credential) |*credential| credential.deinit(alloc); - try std.testing.expectEqual(@as(usize, 1), store_fixture.load_calls); + try std.testing.expectEqual(@as(usize, 0), store_fixture.load_calls); + try std.testing.expectEqual(@as(usize, 1), store_fixture.stored_load_calls); try std.testing.expectEqual(StoredKeyReadStatus.not_attempted, resolution.stored_key_status); try std.testing.expectEqual(Source.stored_key, resolution.credential.?.source); try std.testing.expectEqualStrings("injected-test-value", resolution.credential.?.token); @@ -1038,7 +1060,8 @@ test "credential resolution preserves unreadable store classification" { .stored, ); - try std.testing.expectEqual(@as(usize, 1), store_fixture.load_calls); + try std.testing.expectEqual(@as(usize, 0), store_fixture.load_calls); + try std.testing.expectEqual(@as(usize, 1), store_fixture.stored_load_calls); try std.testing.expect(resolution.credential == null); try std.testing.expectEqual(StoredKeyReadStatus.unavailable, resolution.stored_key_status); } @@ -1063,6 +1086,7 @@ test "a failed fx-login refresh falls through to the stored key" { try std.testing.expectEqualStrings("stored-key-that-works", credential.token); try std.testing.expectEqual(FxLoginReadStatus.unavailable, resolution.fx_login_status); try std.testing.expectEqual(@as(usize, 1), store_fixture.load_calls); + try std.testing.expectEqual(@as(usize, 0), store_fixture.stored_load_calls); } test "a failed fx-login refresh is still reported when nothing else resolves" { diff --git a/src/core/auth/grok_oauth.zig b/src/core/auth/grok_oauth.zig index 699980425..37c8633ef 100644 --- a/src/core/auth/grok_oauth.zig +++ b/src/core/auth/grok_oauth.zig @@ -484,7 +484,7 @@ pub fn logout(alloc: Allocator, transport: oauth_transport.Provider) !LogoutResu } pub fn sourceExists(alloc: Allocator) !bool { - var session = (try grok_session.load(alloc)) orelse return false; + var session = (try grok_session.loadStored(alloc)) orelse return false; defer session.deinit(alloc); return true; } @@ -495,7 +495,7 @@ pub fn loadAccess( mode: RefreshMode, ) !?Access { if (mode == .stored) { - var session = (try grok_session.load(alloc)) orelse return null; + var session = (try grok_session.loadStored(alloc)) orelse return null; defer session.deinit(alloc); return takeAccess(&session); } @@ -539,13 +539,15 @@ fn refreshSession( defer token.deinit(alloc); const account_id = try fetchAccountId(alloc, transport, token.access_token); - errdefer alloc.free(account_id); + var account_id_owned = true; + errdefer if (account_id_owned) alloc.free(account_id); if (!std.mem.eql(u8, account_id, session.account_id)) { return error.GrokAccountChanged; } const refresh_token = if (token.refresh_token) |rotated| rotated else try alloc.dupe(u8, session.refresh_token); + var refresh_token_owned = true; + errdefer if (refresh_token_owned) secret.zeroAndFree(alloc, refresh_token); if (token.refresh_token != null) token.refresh_token = null; - errdefer secret.zeroAndFree(alloc, refresh_token); const expires_at_ms = if (token.expires_in) |expires_in| blk: { const duration_ms = std.math.mul(i64, expires_in, std.time.ms_per_s) catch return error.InvalidGrokOAuthResponse; @@ -559,6 +561,8 @@ fn refreshSession( .account_id = account_id, }; token.access_token = &.{}; + account_id_owned = false; + refresh_token_owned = false; errdefer replacement.deinit(alloc); try mutation.save(alloc, replacement); diff --git a/src/core/auth/grok_session.zig b/src/core/auth/grok_session.zig index ac362338b..648689b2b 100644 --- a/src/core/auth/grok_session.zig +++ b/src/core/auth/grok_session.zig @@ -1,20 +1,14 @@ const std = @import("std"); const debug_trace = @import("../shared/debug_trace.zig"); const host_target = @import("../hosts/target.zig"); -const io_mod = @import("../shared/io.zig"); -const profile_paths = @import("../shared/profile_paths.zig"); +const native_auth_store = if (host_target.is_wasm) struct {} else @import("../hosts/native_auth_store.zig"); const secret = @import("secret.zig"); const Allocator = std.mem.Allocator; const schema_version: i64 = 1; -const max_auth_file_bytes: usize = 64 * 1024; const expiry_skew_ms: i64 = 60 * 1000; -const mutation_lock_file_name = "grok-auth.lock"; -const mutation_lock_deadline_ms: u64 = 2000; const max_account_id_bytes: usize = 1024; -const auth_file_name = profile_paths.grok_auth_file_name; - pub fn refreshDeadlineMs(expires_at_ms: i64) i64 { return @max(expires_at_ms - expiry_skew_ms, 0); } @@ -51,82 +45,79 @@ pub const DeleteOutcome = enum { deleted_not_durable, }; -pub const Mutation = struct { - fx_dir: io_mod.VerifiedDir, - lock: io_mod.TimedAdvisoryLock, +pub const Mutation = if (host_target.is_wasm) WasmMutation else NativeMutation; + +const WasmMutation = struct { + pub fn deinit(self: *WasmMutation) void { + self.* = undefined; + } + + pub fn load(_: *WasmMutation, _: Allocator) !?Session { + return null; + } + + pub fn save(_: *WasmMutation, _: Allocator, _: Session) !void { + return error.GrokOAuthUnavailable; + } + + pub fn delete(_: *WasmMutation) !DeleteOutcome { + return .missing; + } +}; + +const NativeMutation = struct { + inner: native_auth_store.EntryMutation, pub fn deinit(self: *Mutation) void { - self.lock.release(); - self.fx_dir.close(); + self.inner.deinit(); self.* = undefined; } pub fn load(self: *Mutation, alloc: Allocator) !?Session { - return loadFromDir(alloc, &self.fx_dir.dir, true); + const bytes = (try self.inner.load(alloc)) orelse return null; + defer secret.zeroAndFree(alloc, bytes); + return parse(alloc, bytes) catch |err| switch (err) { + error.OutOfMemory => return err, + else => { + debug_trace.logf("auth", "Grok session load failed step=parse_common_mutation err={s}", .{@errorName(err)}); + return null; + }, + }; } pub fn save(self: *Mutation, alloc: Allocator, session: Session) !void { const text = try stringify(alloc, session); defer secret.zeroAndFree(alloc, text); - try io_mod.durableReplaceVerified(alloc, &self.fx_dir, auth_file_name, text); + try self.inner.save(alloc, text); } pub fn delete(self: *Mutation) !DeleteOutcome { - self.fx_dir.dir.deleteFile(io_mod.getIo(), auth_file_name) catch |err| switch (err) { - error.FileNotFound => return .missing, - else => return err, + return switch (try self.inner.delete(std.heap.c_allocator)) { + .deleted => .deleted, + .missing => .missing, + .deleted_not_durable => .deleted_not_durable, }; - const durable: io_mod.DurableOps = .{}; - durable.sync_dir(durable.ctx, self.fx_dir.dir) catch return .deleted_not_durable; - return .deleted; } }; pub fn load(alloc: Allocator) !?Session { if (comptime host_target.is_wasm) return null; - const home = io_mod.getenv("HOME") orelse return null; - var home_dir = std.Io.Dir.openDirAbsolute(io_mod.getIo(), home, .{ .iterate = true }) catch |err| { - debug_trace.logf("auth", "Grok session load failed step=open_home err={s}", .{@errorName(err)}); - return null; - }; - defer home_dir.close(io_mod.getIo()); - - var fx_dir = home_dir.openDir(io_mod.getIo(), profile_paths.root_dir_name, .{ - .iterate = true, - .follow_symlinks = false, - }) catch |err| { - if (err != error.FileNotFound) { - debug_trace.logf("auth", "Grok session load failed step=open_profile err={s}", .{@errorName(err)}); - } - return null; - }; - defer fx_dir.close(io_mod.getIo()); - return loadFromDir(alloc, &fx_dir, false); + return loadNative(alloc, .active); +} + +pub fn loadStored(alloc: Allocator) !?Session { + if (comptime host_target.is_wasm) return null; + return loadNative(alloc, .inspect); } -fn loadFromDir(alloc: Allocator, fx_dir: *std.Io.Dir, report_open_failure: bool) !?Session { - var file = fx_dir.openFile(io_mod.getIo(), auth_file_name, .{ - .mode = .read_only, - .allow_directory = false, - .follow_symlinks = false, - .resolve_beneath = true, - }) catch |err| switch (err) { - error.FileNotFound => return null, +fn loadNative(alloc: Allocator, intent: @import("auth_store.zig").LoadIntent) !?Session { + const bytes = (native_auth_store.load_entry(alloc, .grok_subscription, intent) catch |err| switch (err) { + error.OutOfMemory => return err, else => { - debug_trace.logf("auth", "Grok session load failed step=open_file err={s}", .{@errorName(err)}); - if (report_open_failure) return err; + debug_trace.logf("auth", "Grok session load failed step=common_store err={s}", .{@errorName(err)}); return null; }, - }; - defer file.close(io_mod.getIo()); - - const stat = try file.stat(io_mod.getIo()); - if (stat.kind != .file or stat.permissions.toMode() & 0o077 != 0) { - debug_trace.logf("auth", "Grok session load failed step=permissions err=InsecureAuthFile", .{}); - return null; - } - - const bytes = try io_mod.readFileToEnd(alloc, &file, max_auth_file_bytes); + }) orelse return null; defer secret.zeroAndFree(alloc, bytes); return parse(alloc, bytes) catch |err| switch (err) { error.OutOfMemory => return err, @@ -139,67 +130,14 @@ fn loadFromDir(alloc: Allocator, fx_dir: *std.Io.Dir, report_open_failure: bool) pub fn saveNewSession(alloc: Allocator, session: Session) !void { if (comptime host_target.is_wasm) return error.GrokOAuthUnavailable; - var mutation = try beginMutation(); + var mutation = try beginExistingMutation() orelse return error.HomeNotSet; defer mutation.deinit(); try mutation.save(alloc, session); } pub fn beginExistingMutation() !?Mutation { if (comptime host_target.is_wasm) return null; - const home = io_mod.getenv("HOME") orelse return error.HomeNotSet; - var home_dir = io_mod.VerifiedDir{ - .dir = try std.Io.Dir.openDirAbsolute(io_mod.getIo(), home, .{ .iterate = true }), - }; - defer home_dir.close(); - - const fx_dir = openExistingPrivateFxDir(&home_dir) catch |err| switch (err) { - error.FileNotFound => return null, - else => return err, - }; - return try lockMutation(fx_dir); -} - -fn beginMutation() !Mutation { - const home = io_mod.getenv("HOME") orelse return error.HomeNotSet; - var home_dir = io_mod.VerifiedDir{ - .dir = try std.Io.Dir.openDirAbsolute(io_mod.getIo(), home, .{ .iterate = true }), - }; - defer home_dir.close(); - - const fx_dir = try io_mod.openOrCreateVerifiedPrivateDir(&home_dir, profile_paths.root_dir_name); - return lockMutation(fx_dir); -} - -fn lockMutation(open_fx_dir: io_mod.VerifiedDir) !Mutation { - var fx_dir = open_fx_dir; - errdefer fx_dir.close(); - var lock = try io_mod.acquireTimedAdvisoryLock( - &fx_dir, - mutation_lock_file_name, - mutation_lock_deadline_ms, - ); - errdefer lock.release(); - return .{ .fx_dir = fx_dir, .lock = lock }; -} - -fn openExistingPrivateFxDir(home_dir: *io_mod.VerifiedDir) !io_mod.VerifiedDir { - var dir = try home_dir.dir.openDir(io_mod.getIo(), profile_paths.root_dir_name, .{ - .iterate = true, - .follow_symlinks = false, - }); - errdefer dir.close(io_mod.getIo()); - - const initial_stat = try dir.stat(io_mod.getIo()); - if (initial_stat.kind != .directory) return error.DurablePathUnsafe; - if (initial_stat.permissions.toMode() & 0o200 == 0) return error.PrivateStatePermissionsUnsupported; - dir.setPermissions(io_mod.getIo(), std.Io.File.Permissions.fromMode(0o700)) catch { - return error.PrivateStatePermissionsUnsupported; - }; - const stat = try dir.stat(io_mod.getIo()); - if (stat.kind != .directory or stat.permissions.toMode() & 0o777 != 0o700) { - return error.PrivateStatePermissionsUnsupported; - } - return .{ .dir = dir }; + return .{ .inner = try native_auth_store.begin_entry_mutation(.grok_subscription) }; } pub fn parse(alloc: Allocator, bytes: []const u8) !Session { diff --git a/src/core/auth/oauth_session.zig b/src/core/auth/oauth_session.zig index cc052877c..beda311e0 100644 --- a/src/core/auth/oauth_session.zig +++ b/src/core/auth/oauth_session.zig @@ -1,10 +1,8 @@ const std = @import("std"); -const builtin = @import("builtin"); const debug_trace = @import("../shared/debug_trace.zig"); const host_target = @import("../hosts/target.zig"); -const native_keychain = @import("../hosts/native_keychain.zig"); +const native_auth_store = if (host_target.is_wasm) struct {} else @import("../hosts/native_auth_store.zig"); const io_mod = @import("../shared/io.zig"); -const profile_paths = @import("../shared/profile_paths.zig"); const js_host_auth = @import("js_host_auth.zig"); const secret = @import("secret.zig"); @@ -14,130 +12,13 @@ pub const issuer = "https://vercel.com"; pub const client_id_env = "FX_OAUTH_CLIENT_ID"; pub const default_client_id = "cl_zzh5hiOZbwJ9bfqEcYqPIJv3TaPaEYL0"; const e2e_issuer_url_env = "FX_E2E_OAUTH_ISSUER_URL"; -pub const auth_file_name = profile_paths.auth_file_name; const schema_version: i64 = 1; -const max_auth_file_bytes: usize = 64 * 1024; const expiry_skew_ms: i64 = 60 * 1000; -const mutation_lock_file_name = "auth.lock"; -const mutation_lock_deadline_ms: u64 = 2000; -const e2e_lock_contention_file_name = "auth-lock-contention"; -const LoadMode = enum { tolerate_open_failure, report_open_failure }; - -const StorageBackend = enum { - profile_file, - macos_keychain, -}; - -const FileState = enum { absent, valid, unusable }; -const KeychainState = enum { absent, valid, invalid, unavailable }; -const Resolution = enum { - missing, - file_migrate, - file_defer_migration, - keychain, - storage_error, -}; - -const Authority = enum { unresolved, missing, profile_file, keychain }; - -const KeychainError = Allocator.Error || native_keychain.Error; - -const KeychainBackend = struct { - context: ?*anyopaque = null, - load_fn: *const fn (?*anyopaque, Allocator) KeychainError!?[]u8, - store_fn: *const fn (?*anyopaque, []const u8) KeychainError!void, - delete_fn: *const fn (?*anyopaque, Allocator) KeychainError!bool, - - fn load(self: KeychainBackend, alloc: Allocator) KeychainError!?[]u8 { - return self.load_fn(self.context, alloc); - } - - fn store(self: KeychainBackend, value: []const u8) KeychainError!void { - return self.store_fn(self.context, value); - } - - fn delete(self: KeychainBackend, alloc: Allocator) KeychainError!bool { - return self.delete_fn(self.context, alloc); - } -}; - -const native_keychain_backend: KeychainBackend = .{ - .load_fn = nativeKeychainLoad, - .store_fn = nativeKeychainStore, - .delete_fn = nativeKeychainDelete, -}; - -fn nativeKeychainLoad(_: ?*anyopaque, alloc: Allocator) KeychainError!?[]u8 { - return native_keychain.loadOAuthSession(alloc); -} - -fn nativeKeychainStore(_: ?*anyopaque, value: []const u8) KeychainError!void { - return native_keychain.storeOAuthSession(value); -} - -fn nativeKeychainDelete(_: ?*anyopaque, alloc: Allocator) KeychainError!bool { - return native_keychain.deleteOAuthSession(alloc); -} - -fn selectStorageBackend(os_tag: std.Target.Os.Tag, keychain_disabled: bool) StorageBackend { - if (os_tag == .macos and !keychain_disabled) return .macos_keychain; - return .profile_file; -} - -fn storageBackend() StorageBackend { - // A temporary HOME does not isolate the host account's macOS Keychain. - // Keychain-specific tests inject their backend explicitly. - if (comptime builtin.is_test) return .profile_file; - return selectStorageBackend(builtin.os.tag, native_keychain.isDisabled()); -} - -fn selectResolution(file: FileState, keychain: KeychainState) Resolution { - return switch (file) { - .valid => if (keychain == .unavailable) .file_defer_migration else .file_migrate, - .absent, .unusable => switch (keychain) { - .absent => .missing, - .valid => .keychain, - .invalid, .unavailable => .storage_error, - }, - }; -} pub fn refresh_deadline_ms(expires_at_ms: i64) i64 { return expires_at_ms -| expiry_skew_ms; } -const MutationLockProbe = struct { - fx_dir: std.Io.Dir, - signaled: bool = false, - - fn tryLock(raw_ctx: ?*anyopaque, file: std.Io.File) anyerror!bool { - const locked = try file.tryLock(io_mod.getIo(), .exclusive); - const self: *MutationLockProbe = @ptrCast(@alignCast(raw_ctx.?)); - if (!locked and !self.signaled) { - signalE2ELockContention(self.fx_dir); - self.signaled = true; - } - return locked; - } -}; - -fn signalE2ELockContention(fx_dir: std.Io.Dir) void { - const enabled = io_mod.getenv("FX_E2E_AUTH_LOCK_CONTENTION") orelse return; - if (!std.mem.eql(u8, enabled, "1")) return; - var file = fx_dir.createFile(io_mod.getIo(), e2e_lock_contention_file_name, .{ - .truncate = true, - .permissions = std.Io.File.Permissions.fromMode(0o600), - }) catch return; - defer file.close(io_mod.getIo()); - file.writeStreamingAll(io_mod.getIo(), "contended\n") catch {}; -} - -const DeleteOutcome = enum { - deleted, - missing, - deleted_not_durable, -}; - pub const DeleteResult = struct { session_deleted: bool = false, local_cleanup_failed: bool = false, @@ -171,295 +52,42 @@ pub const Session = struct { } }; -const FileObservation = union(enum) { - absent, - valid: Session, - unusable, +pub const Mutation = if (host_target.is_wasm) HostMutation else CommonNativeMutation; - fn state(self: FileObservation) FileState { - return switch (self) { - .absent => .absent, - .valid => .valid, - .unusable => .unusable, - }; - } - - fn takeSession(self: *FileObservation) ?Session { - return switch (self.*) { - .valid => |session| blk: { - self.* = .absent; - break :blk session; - }, - else => null, - }; - } - - fn deinit(self: *FileObservation, alloc: Allocator) void { - switch (self.*) { - .valid => |*session| session.deinit(alloc), - else => {}, - } - self.* = .absent; - } -}; - -const KeychainObservation = union(enum) { - absent, - valid: Session, - invalid, - unavailable: KeychainError, +const CommonNativeMutation = struct { + inner: native_auth_store.EntryMutation, - fn state(self: KeychainObservation) KeychainState { - return switch (self) { - .absent => .absent, - .valid => .valid, - .invalid => .invalid, - .unavailable => .unavailable, - }; + pub fn deinit(self: *CommonNativeMutation) void { + self.inner.deinit(); + self.* = undefined; } - fn takeSession(self: *KeychainObservation) ?Session { - return switch (self.*) { - .valid => |session| blk: { - self.* = .absent; - break :blk session; + pub fn load(self: *CommonNativeMutation, alloc: Allocator) !?Session { + const bytes = (try self.inner.load(alloc)) orelse return null; + defer secret.zeroAndFree(alloc, bytes); + return parse(alloc, bytes) catch |err| switch (err) { + error.OutOfMemory => return err, + else => { + debug_trace.logf("auth", "session load failed step=parse_common_mutation err={s}", .{@errorName(err)}); + return null; }, - else => null, }; } - fn storageError(self: KeychainObservation) (KeychainError || error{ - InvalidOAuthKeychainSession, - InvalidOAuthStorageState, - }) { - return switch (self) { - .invalid => error.InvalidOAuthKeychainSession, - .unavailable => |err| err, - else => error.InvalidOAuthStorageState, - }; - } - - fn deinit(self: *KeychainObservation, alloc: Allocator) void { - switch (self.*) { - .valid => |*session| session.deinit(alloc), - else => {}, - } - self.* = .absent; - } -}; - -fn observeAuthFile(alloc: Allocator, fx_dir: *std.Io.Dir) !FileObservation { - var file = fx_dir.openFile(io_mod.getIo(), auth_file_name, .{ - .mode = .read_only, - .allow_directory = false, - .follow_symlinks = false, - .resolve_beneath = true, - }) catch |err| switch (err) { - error.FileNotFound => return .absent, - else => { - debug_trace.logf("auth", "session load failed source=file step=open err={s}", .{@errorName(err)}); - return .unusable; - }, - }; - defer file.close(io_mod.getIo()); - - const stat = file.stat(io_mod.getIo()) catch |err| { - debug_trace.logf("auth", "session load failed source=file step=stat err={s}", .{@errorName(err)}); - return .unusable; - }; - if (stat.kind != .file or stat.nlink != 1 or stat.permissions.toMode() & 0o077 != 0) { - debug_trace.logf("auth", "session load failed source=file step=permissions err=InsecureAuthFile", .{}); - return .unusable; - } - - const bytes = io_mod.readFileToEnd(alloc, &file, max_auth_file_bytes) catch |err| switch (err) { - error.OutOfMemory => return err, - else => { - debug_trace.logf("auth", "session load failed source=file step=read err={s}", .{@errorName(err)}); - return .unusable; - }, - }; - defer secret.zeroAndFree(alloc, bytes); - const session = parse(alloc, bytes) catch |err| switch (err) { - error.OutOfMemory => return err, - else => { - debug_trace.logf("auth", "session load failed source=file step=parse err={s}", .{@errorName(err)}); - return .unusable; - }, - }; - return .{ .valid = session }; -} - -fn observeKeychain(alloc: Allocator, keychain: KeychainBackend) !KeychainObservation { - const maybe_bytes = keychain.load(alloc) catch |err| switch (err) { - error.OutOfMemory => return err, - error.KeychainItemNotFound => return .absent, - else => return .{ .unavailable = err }, - }; - const bytes = maybe_bytes orelse return .absent; - defer secret.zeroAndFree(alloc, bytes); - if (bytes.len > max_auth_file_bytes) return .invalid; - const session = parse(alloc, bytes) catch |err| switch (err) { - error.OutOfMemory => return err, - else => return .invalid, - }; - return .{ .valid = session }; -} - -fn publishAndVerifyKeychain(alloc: Allocator, keychain: KeychainBackend, session: Session) !void { - const bytes = try stringify(alloc, session); - defer secret.zeroAndFree(alloc, bytes); - try keychain.store(bytes); - const persisted = keychain.load(alloc) catch |err| switch (err) { - error.KeychainItemNotFound => return error.OAuthSessionKeychainWriteMismatch, - else => return err, - } orelse return error.OAuthSessionKeychainWriteMismatch; - defer secret.zeroAndFree(alloc, persisted); - if (!std.mem.eql(u8, bytes, persisted)) return error.OAuthSessionKeychainWriteMismatch; -} - -fn authFileExists(fx_dir: *std.Io.Dir) !bool { - _ = fx_dir.statFile(io_mod.getIo(), auth_file_name, .{}) catch |err| switch (err) { - error.FileNotFound => return false, - else => return err, - }; - return true; -} - -pub const Mutation = if (host_target.is_wasm) HostMutation else NativeMutation; - -const NativeMutation = struct { - fx_dir: io_mod.VerifiedDir, - lock: io_mod.TimedAdvisoryLock, - backend: StorageBackend, - keychain: KeychainBackend, - authority: Authority = .unresolved, - - pub fn deinit(self: *Mutation) void { - self.lock.release(); - self.fx_dir.close(); - self.* = undefined; - } - - pub fn load(self: *Mutation, alloc: Allocator) !?Session { - if (self.backend == .profile_file) { - self.authority = .profile_file; - return loadFromDir(alloc, &self.fx_dir.dir, .report_open_failure); - } - return self.loadKeychainResolved(alloc); - } - - pub fn save(self: *Mutation, alloc: Allocator, session: Session) !void { - if (self.backend == .macos_keychain) { - return self.saveKeychainResolved(alloc, session); - } - self.authority = .profile_file; - return self.saveFile(alloc, session); - } - - fn saveFile(self: *Mutation, alloc: Allocator, session: Session) !void { + pub fn save(self: *CommonNativeMutation, alloc: Allocator, session: Session) !void { const text = try stringify(alloc, session); defer secret.zeroAndFree(alloc, text); - try io_mod.durableReplaceVerified(alloc, &self.fx_dir, auth_file_name, text); - } - - pub fn delete(self: *Mutation, alloc: Allocator) !DeleteResult { - var result: DeleteResult = .{}; - if (self.backend == .macos_keychain) { - const deleted = self.keychain.delete(alloc) catch blk: { - result.local_cleanup_failed = true; - break :blk false; - }; - result.session_deleted = result.session_deleted or deleted; - } - - const file_outcome = deleteAuthFile(&self.fx_dir.dir, .{}) catch { - result.local_cleanup_failed = true; - return result; - }; - switch (file_outcome) { - .missing => {}, - .deleted => result.session_deleted = true, - .deleted_not_durable => { - result.session_deleted = true; - result.local_cleanup_failed = true; - }, - } - self.authority = .missing; - return result; - } - - fn loadKeychainResolved(self: *Mutation, alloc: Allocator) !?Session { - var file = try observeAuthFile(alloc, &self.fx_dir.dir); - defer file.deinit(alloc); - var keychain = try observeKeychain(alloc, self.keychain); - defer keychain.deinit(alloc); - - switch (selectResolution(file.state(), keychain.state())) { - .missing => { - self.authority = .missing; - return null; - }, - .keychain => { - self.authority = .keychain; - return keychain.takeSession().?; - }, - .file_defer_migration => { - self.authority = .profile_file; - return file.takeSession().?; - }, - .file_migrate => { - var session = file.takeSession().?; - errdefer session.deinit(alloc); - const migrated = try self.publishAndCleanup(alloc, session); - self.authority = if (migrated) .keychain else .profile_file; - return session; - }, - .storage_error => return keychain.storageError(), - } - } - - fn saveKeychainResolved(self: *Mutation, alloc: Allocator, session: Session) !void { - if (self.authority == .unresolved) { - self.authority = if (try authFileExists(&self.fx_dir.dir)) .profile_file else .keychain; - } - - switch (self.authority) { - .profile_file => { - try self.saveFile(alloc, session); - const migrated = try self.publishAndCleanup(alloc, session); - if (migrated) self.authority = .keychain; - }, - .keychain, .missing => { - try publishAndVerifyKeychain(alloc, self.keychain, session); - self.authority = .keychain; - }, - .unresolved => unreachable, - } + try self.inner.save(alloc, text); } - fn publishAndCleanup(self: *Mutation, alloc: Allocator, session: Session) !bool { - publishAndVerifyKeychain(alloc, self.keychain, session) catch |err| { - debug_trace.logf("auth", "session migration deferred step=publish err={s}", .{@errorName(err)}); - return false; - }; - - const outcome = deleteAuthFile(&self.fx_dir.dir, .{}) catch |err| { - const stat = self.fx_dir.dir.statFile(io_mod.getIo(), auth_file_name, .{}) catch |stat_err| switch (stat_err) { - error.FileNotFound => return error.OAuthSessionCleanupUncertain, - else => return stat_err, - }; - if (stat.kind != .file) return error.OAuthSessionCleanupUncertain; - debug_trace.logf("auth", "session migration deferred step=delete err={s}", .{@errorName(err)}); - return false; - }; - return switch (outcome) { - .deleted => true, - .missing => blk: { - try io_mod.syncVerifiedDir(self.fx_dir.dir); - break :blk true; + pub fn delete(self: *CommonNativeMutation, alloc: Allocator) !DeleteResult { + return switch (try self.inner.delete(alloc)) { + .deleted => .{ .session_deleted = true }, + .missing => .{}, + .deleted_not_durable => .{ + .session_deleted = true, + .local_cleanup_failed = true, }, - .deleted_not_durable => error.OAuthSessionCleanupUncertain, }; } }; @@ -574,72 +202,36 @@ fn isLoopbackHttpUrl(url: []const u8, require_origin: bool) bool { pub fn load(alloc: Allocator) !?Session { if (comptime host_target.is_wasm) return loadFromHost(alloc, js_host_auth.oauth_session_store); - const home = io_mod.getenv("HOME") orelse { - debug_trace.logf("auth", "session load skipped step=home err=HomeNotSet", .{}); - return null; - }; - if (storageBackend() == .macos_keychain) { - if (try beginExistingNativeMutation()) |existing| { - var mutation = existing; - defer mutation.deinit(); - return mutation.load(alloc); - } - return loadKeychainWithoutProfile(alloc); - } - var home_dir = std.Io.Dir.openDirAbsolute(io_mod.getIo(), home, .{ .iterate = true }) catch |err| { - debug_trace.logf("auth", "session load failed step=open_home err={s}", .{@errorName(err)}); - return null; - }; - defer home_dir.close(io_mod.getIo()); - - var fx_dir = home_dir.openDir(io_mod.getIo(), profile_paths.root_dir_name, .{ - .iterate = true, - .follow_symlinks = false, - }) catch |err| { - debug_trace.logf("auth", "session load failed step=open_profile err={s}", .{@errorName(err)}); - return null; - }; - defer fx_dir.close(io_mod.getIo()); + return loadNative(alloc, .active); +} - return loadFromDir(alloc, &fx_dir, .tolerate_open_failure); +pub fn loadStored(alloc: Allocator) !?Session { + if (comptime host_target.is_wasm) return loadFromHost(alloc, js_host_auth.oauth_session_store); + return loadNative(alloc, .inspect); } -fn loadFromHost(alloc: Allocator, store: js_host_auth.SessionStore) !?Session { - var stored = (try store.load(alloc)) orelse return null; - defer stored.deinit(alloc); - return parse(alloc, stored.bytes) catch |err| switch (err) { +fn loadNative(alloc: Allocator, intent: @import("auth_store.zig").LoadIntent) !?Session { + const bytes = (native_auth_store.load_entry(alloc, .fx_login, intent) catch |err| switch (err) { error.OutOfMemory => return err, else => { - debug_trace.logf("auth", "session load failed step=parse err={s}", .{@errorName(err)}); + debug_trace.logf("auth", "session load failed step=common_store err={s}", .{@errorName(err)}); return null; }, - }; -} - -fn loadFromDir(alloc: Allocator, fx_dir: *std.Io.Dir, mode: LoadMode) !?Session { - var file = fx_dir.openFile(io_mod.getIo(), auth_file_name, .{ - .mode = .read_only, - .allow_directory = false, - .follow_symlinks = false, - .resolve_beneath = true, - }) catch |err| switch (err) { - error.FileNotFound => return null, + }) orelse return null; + defer secret.zeroAndFree(alloc, bytes); + return parse(alloc, bytes) catch |err| switch (err) { + error.OutOfMemory => return err, else => { - debug_trace.logf("auth", "session load failed step=open_file err={s}", .{@errorName(err)}); - if (mode == .tolerate_open_failure) return null else return err; + debug_trace.logf("auth", "session load failed step=parse_common err={s}", .{@errorName(err)}); + return null; }, }; - defer file.close(io_mod.getIo()); - - const stat = try file.stat(io_mod.getIo()); - if (stat.kind != .file or stat.permissions.toMode() & 0o077 != 0) { - debug_trace.logf("auth", "session load failed step=permissions err=InsecureAuthFile", .{}); - return null; - } +} - const bytes = try io_mod.readFileToEnd(alloc, &file, max_auth_file_bytes); - defer secret.zeroAndFree(alloc, bytes); - return parse(alloc, bytes) catch |err| switch (err) { +fn loadFromHost(alloc: Allocator, store: js_host_auth.SessionStore) !?Session { + var stored = (try store.load(alloc)) orelse return null; + defer stored.deinit(alloc); + return parse(alloc, stored.bytes) catch |err| switch (err) { error.OutOfMemory => return err, else => { debug_trace.logf("auth", "session load failed step=parse err={s}", .{@errorName(err)}); @@ -655,7 +247,7 @@ pub fn saveNewSession(alloc: Allocator, session: Session) !void { try mutation.captureRevision(alloc); return mutation.save(alloc, session); } - var mutation = try beginMutation(); + var mutation = try beginExistingMutation() orelse return error.HomeNotSet; defer mutation.deinit(); try mutation.save(alloc, session); } @@ -664,133 +256,7 @@ pub fn beginExistingMutation() !?Mutation { if (comptime host_target.is_wasm) { return @as(?Mutation, HostMutation.init(js_host_auth.oauth_session_store)); } - if (storageBackend() == .macos_keychain) { - return @as(?Mutation, try beginMutation()); - } - return beginExistingNativeMutation(); -} - -fn beginExistingNativeMutation() !?Mutation { - const home = io_mod.getenv("HOME") orelse return error.HomeNotSet; - var home_dir = io_mod.VerifiedDir{ - .dir = try std.Io.Dir.openDirAbsolute(io_mod.getIo(), home, .{ .iterate = true }), - }; - defer home_dir.close(); - - const fx_dir = openExistingPrivateFxDir(&home_dir) catch |err| switch (err) { - error.FileNotFound => return null, - else => return err, - }; - return @as(?Mutation, try lockMutation(fx_dir)); -} - -fn loadKeychainWithoutProfile(alloc: Allocator) !?Session { - var keychain = try observeKeychain(alloc, native_keychain_backend); - defer keychain.deinit(alloc); - - if (try beginExistingNativeMutation()) |existing| { - var mutation = existing; - defer mutation.deinit(); - return mutation.load(alloc); - } - - return switch (selectResolution(.absent, keychain.state())) { - .missing => null, - .keychain => keychain.takeSession().?, - .storage_error => keychain.storageError(), - .file_migrate, .file_defer_migration => unreachable, - }; -} - -fn beginMutation() !Mutation { - const home = io_mod.getenv("HOME") orelse return error.HomeNotSet; - var home_dir = io_mod.VerifiedDir{ - .dir = try std.Io.Dir.openDirAbsolute(io_mod.getIo(), home, .{ .iterate = true }), - }; - defer home_dir.close(); - - const fx_dir = try io_mod.openOrCreateVerifiedPrivateDir(&home_dir, profile_paths.root_dir_name); - return lockMutation(fx_dir); -} - -fn lockMutation(open_fx_dir: io_mod.VerifiedDir) !Mutation { - var probe = MutationLockProbe{ .fx_dir = open_fx_dir.dir }; - return lockMutationWithOps(open_fx_dir, mutation_lock_deadline_ms, .{ - .ctx = &probe, - .try_lock = MutationLockProbe.tryLock, - }); -} - -fn lockMutationWithOps( - open_fx_dir: io_mod.VerifiedDir, - deadline_ms: u64, - ops: io_mod.LockOps, -) !Mutation { - return lockMutationWithBackend( - open_fx_dir, - deadline_ms, - ops, - storageBackend(), - native_keychain_backend, - ); -} - -fn lockMutationWithBackend( - open_fx_dir: io_mod.VerifiedDir, - deadline_ms: u64, - ops: io_mod.LockOps, - backend: StorageBackend, - keychain: KeychainBackend, -) !Mutation { - var fx_dir = open_fx_dir; - errdefer fx_dir.close(); - - var lock = try io_mod.acquireTimedAdvisoryLockWithOps( - &fx_dir, - mutation_lock_file_name, - deadline_ms, - ops, - ); - errdefer lock.release(); - - return .{ - .fx_dir = fx_dir, - .lock = lock, - .backend = backend, - .keychain = keychain, - }; -} - -fn openExistingPrivateFxDir(home_dir: *io_mod.VerifiedDir) !io_mod.VerifiedDir { - var dir = try home_dir.dir.openDir(io_mod.getIo(), profile_paths.root_dir_name, .{ - .iterate = true, - .follow_symlinks = false, - }); - errdefer dir.close(io_mod.getIo()); - - const initial_stat = try dir.stat(io_mod.getIo()); - if (initial_stat.kind != .directory) return error.DurablePathUnsafe; - if (initial_stat.permissions.toMode() & 0o200 == 0) { - return error.PrivateStatePermissionsUnsupported; - } - dir.setPermissions(io_mod.getIo(), std.Io.File.Permissions.fromMode(0o700)) catch { - return error.PrivateStatePermissionsUnsupported; - }; - const stat = try dir.stat(io_mod.getIo()); - if (stat.kind != .directory) return error.DurablePathUnsafe; - if (stat.permissions.toMode() & 0o777 != 0o700) { - return error.PrivateStatePermissionsUnsupported; - } - return .{ .dir = dir }; -} - -fn deleteAuthFile(fx_dir: *std.Io.Dir, ops: io_mod.DurableOps) !DeleteOutcome { - fx_dir.deleteFile(io_mod.getIo(), auth_file_name) catch |err| switch (err) { - error.FileNotFound => return .missing, - else => return err, - }; - ops.sync_dir(ops.ctx, fx_dir.*) catch return .deleted_not_durable; - return .deleted; + return .{ .inner = try native_auth_store.begin_entry_mutation(.fx_login) }; } pub fn parse(alloc: Allocator, bytes: []const u8) !Session { @@ -953,11 +419,6 @@ fn check_parse_allocation_failures(alloc: Allocator) !void { defer session.deinit(alloc); } -fn check_load_allocation_failures(alloc: Allocator, dir: *std.Io.Dir) !void { - var session = (try loadFromDir(alloc, dir, .report_open_failure)) orelse return error.TestUnexpectedMissingSession; - defer session.deinit(alloc); -} - test "oauth session stringifies and parses" { var session = Session{ .issuer = try std.testing.allocator.dupe(u8, issuer), @@ -983,254 +444,6 @@ test "oauth session stringifies and parses" { try std.testing.expectEqualStrings("team_123", parsed.team_id.?); } -test "OAuth storage backend selection is platform scoped and explicitly disableable" { - try std.testing.expectEqual(StorageBackend.macos_keychain, selectStorageBackend(.macos, false)); - try std.testing.expectEqual(StorageBackend.profile_file, selectStorageBackend(.macos, true)); - try std.testing.expectEqual(StorageBackend.profile_file, selectStorageBackend(.linux, false)); - try std.testing.expectEqual(StorageBackend.profile_file, selectStorageBackend(.windows, false)); -} - -test "OAuth test builds use profile file storage" { - try std.testing.expectEqual(StorageBackend.profile_file, storageBackend()); -} - -test "OAuth source resolution covers every file and Keychain state" { - const cases = [_]struct { - file: FileState, - keychain: KeychainState, - expected: Resolution, - }{ - .{ .file = .absent, .keychain = .absent, .expected = .missing }, - .{ .file = .absent, .keychain = .valid, .expected = .keychain }, - .{ .file = .absent, .keychain = .invalid, .expected = .storage_error }, - .{ .file = .absent, .keychain = .unavailable, .expected = .storage_error }, - .{ .file = .valid, .keychain = .absent, .expected = .file_migrate }, - .{ .file = .valid, .keychain = .valid, .expected = .file_migrate }, - .{ .file = .valid, .keychain = .invalid, .expected = .file_migrate }, - .{ .file = .valid, .keychain = .unavailable, .expected = .file_defer_migration }, - .{ .file = .unusable, .keychain = .valid, .expected = .keychain }, - .{ .file = .unusable, .keychain = .absent, .expected = .missing }, - .{ .file = .unusable, .keychain = .invalid, .expected = .storage_error }, - .{ .file = .unusable, .keychain = .unavailable, .expected = .storage_error }, - }; - - for (cases) |case| { - try std.testing.expectEqual(case.expected, selectResolution(case.file, case.keychain)); - } -} - -const alternate_test_session_json = "{\"version\":1,\"issuer\":\"https://vercel.com\",\"client_id\":\"client\",\"access_token\":\"keychain-access\",\"refresh_token\":\"keychain-refresh\",\"expires_at_ms\":2,\"scope\":\"openid offline_access\",\"token_type\":\"Bearer\"}"; - -const FakeOAuthKeychain = struct { - alloc: Allocator, - value: ?[]u8 = null, - store_calls: usize = 0, - delete_calls: usize = 0, - fail_store: bool = false, - fail_delete: bool = false, - - fn deinit(self: *FakeOAuthKeychain) void { - if (self.value) |value| secret.zeroAndFree(self.alloc, value); - self.* = undefined; - } - - fn backend(self: *FakeOAuthKeychain) KeychainBackend { - return .{ - .context = self, - .load_fn = loadCallback, - .store_fn = storeCallback, - .delete_fn = deleteCallback, - }; - } - - fn loadCallback(raw: ?*anyopaque, alloc: Allocator) KeychainError!?[]u8 { - const self: *FakeOAuthKeychain = @ptrCast(@alignCast(raw.?)); - const value = self.value orelse return null; - return try alloc.dupe(u8, value); - } - - fn storeCallback(raw: ?*anyopaque, value: []const u8) KeychainError!void { - const self: *FakeOAuthKeychain = @ptrCast(@alignCast(raw.?)); - self.store_calls += 1; - if (self.fail_store) return error.KeychainWriteFailed; - const replacement = try self.alloc.dupe(u8, value); - if (self.value) |previous| secret.zeroAndFree(self.alloc, previous); - self.value = replacement; - } - - fn deleteCallback(raw: ?*anyopaque, _: Allocator) KeychainError!bool { - const self: *FakeOAuthKeychain = @ptrCast(@alignCast(raw.?)); - self.delete_calls += 1; - if (self.fail_delete) return error.KeychainDeleteFailed; - const previous = self.value orelse return false; - secret.zeroAndFree(self.alloc, previous); - self.value = null; - return true; - } -}; - -fn writeTestAuthFile(dir: std.Io.Dir, contents: []const u8) !void { - var file = try dir.createFile(std.testing.io, auth_file_name, .{ - .truncate = true, - .permissions = std.Io.File.Permissions.fromMode(0o600), - }); - defer file.close(std.testing.io); - try file.writeStreamingAll(std.testing.io, contents); -} - -fn testKeychainMutation(dir: std.Io.Dir, keychain: KeychainBackend) !Mutation { - return lockMutationWithBackend( - .{ .dir = try dir.openDir(std.testing.io, ".", .{ .iterate = true }) }, - 0, - .{}, - .macos_keychain, - keychain, - ); -} - -test "OAuth migration keeps a valid file authoritative until verified cleanup" { - const alloc = std.testing.allocator; - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - try writeTestAuthFile(tmp.dir, test_session_json); - - var fake = FakeOAuthKeychain{ - .alloc = alloc, - .value = try alloc.dupe(u8, alternate_test_session_json), - }; - defer fake.deinit(); - var mutation = try testKeychainMutation(tmp.dir, fake.backend()); - defer mutation.deinit(); - - var loaded = (try mutation.load(alloc)).?; - defer loaded.deinit(alloc); - try std.testing.expectEqualStrings("access", loaded.access_token); - try std.testing.expectEqual(@as(usize, 1), fake.store_calls); - try std.testing.expectEqual(Authority.keychain, mutation.authority); - try std.testing.expectError( - error.FileNotFound, - tmp.dir.statFile(std.testing.io, auth_file_name, .{}), - ); - - var persisted = try parse(alloc, fake.value.?); - defer persisted.deinit(alloc); - try std.testing.expectEqualStrings("access", persisted.access_token); - - secret.zeroAndFree(alloc, loaded.access_token); - loaded.access_token = try alloc.dupe(u8, "keychain-only-update"); - try mutation.save(alloc, loaded); - try std.testing.expectError( - error.FileNotFound, - tmp.dir.statFile(std.testing.io, auth_file_name, .{}), - ); - var updated = try parse(alloc, fake.value.?); - defer updated.deinit(alloc); - try std.testing.expectEqualStrings("keychain-only-update", updated.access_token); -} - -test "new OAuth login replaces an existing file before deferred migration" { - const alloc = std.testing.allocator; - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - try writeTestAuthFile(tmp.dir, test_session_json); - - var fake = FakeOAuthKeychain{ .alloc = alloc, .fail_store = true }; - defer fake.deinit(); - var mutation = try testKeychainMutation(tmp.dir, fake.backend()); - defer mutation.deinit(); - var replacement = try parse(alloc, alternate_test_session_json); - defer replacement.deinit(alloc); - - try mutation.save(alloc, replacement); - var persisted = (try loadFromDir(alloc, &tmp.dir, .report_open_failure)).?; - defer persisted.deinit(alloc); - try std.testing.expectEqualStrings("keychain-access", persisted.access_token); - try std.testing.expectEqual(Authority.profile_file, mutation.authority); -} - -test "OAuth migration preserves and updates the file when Keychain publication fails" { - const alloc = std.testing.allocator; - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - try writeTestAuthFile(tmp.dir, test_session_json); - - var fake = FakeOAuthKeychain{ .alloc = alloc, .fail_store = true }; - defer fake.deinit(); - var mutation = try testKeychainMutation(tmp.dir, fake.backend()); - defer mutation.deinit(); - - var loaded = (try mutation.load(alloc)).?; - defer loaded.deinit(alloc); - try std.testing.expectEqual(Authority.profile_file, mutation.authority); - secret.zeroAndFree(alloc, loaded.access_token); - loaded.access_token = try alloc.dupe(u8, "updated-access"); - try mutation.save(alloc, loaded); - - var persisted = (try loadFromDir(alloc, &tmp.dir, .report_open_failure)).?; - defer persisted.deinit(alloc); - try std.testing.expectEqualStrings("updated-access", persisted.access_token); - try std.testing.expectEqual(@as(usize, 2), fake.store_calls); -} - -test "OAuth resolver uses valid Keychain state without deleting an unusable file" { - const alloc = std.testing.allocator; - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - try writeTestAuthFile(tmp.dir, "not-json"); - - var fake = FakeOAuthKeychain{ - .alloc = alloc, - .value = try alloc.dupe(u8, alternate_test_session_json), - }; - defer fake.deinit(); - var mutation = try testKeychainMutation(tmp.dir, fake.backend()); - defer mutation.deinit(); - - var loaded = (try mutation.load(alloc)).?; - defer loaded.deinit(alloc); - try std.testing.expectEqualStrings("keychain-access", loaded.access_token); - _ = try tmp.dir.statFile(std.testing.io, auth_file_name, .{}); -} - -test "OAuth resolver reports invalid Keychain state when no valid file exists" { - const alloc = std.testing.allocator; - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - var fake = FakeOAuthKeychain{ - .alloc = alloc, - .value = try alloc.dupe(u8, "not-json"), - }; - defer fake.deinit(); - var mutation = try testKeychainMutation(tmp.dir, fake.backend()); - defer mutation.deinit(); - - try std.testing.expectError(error.InvalidOAuthKeychainSession, mutation.load(alloc)); -} - -test "OAuth logout deletion attempts Keychain and file cleanup independently" { - const alloc = std.testing.allocator; - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - try writeTestAuthFile(tmp.dir, test_session_json); - var fake = FakeOAuthKeychain{ - .alloc = alloc, - .value = try alloc.dupe(u8, alternate_test_session_json), - .fail_delete = true, - }; - defer fake.deinit(); - var mutation = try testKeychainMutation(tmp.dir, fake.backend()); - defer mutation.deinit(); - - const result = try mutation.delete(alloc); - try std.testing.expect(result.session_deleted); - try std.testing.expect(result.local_cleanup_failed); - try std.testing.expectEqual(@as(usize, 1), fake.delete_calls); - try std.testing.expectError( - error.FileNotFound, - tmp.dir.statFile(std.testing.io, auth_file_name, .{}), - ); -} - test "JS host OAuth session load commit and remove preserve the native format and revision" { var state: HostStoreTestState = .{}; var loaded = (try loadFromHost(std.testing.allocator, state.provider())).?; @@ -1278,39 +491,6 @@ test "oauth session parse rejects non-object JSON" { try std.testing.expectError(error.InvalidAuthSession, parse(std.testing.allocator, "[]")); } -test "oauth session loading propagates allocation failures" { - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - - var file = try tmp.dir.createFile(std.testing.io, auth_file_name, .{ - .permissions = std.Io.File.Permissions.fromMode(0o600), - }); - try file.writeStreamingAll( - std.testing.io, - test_session_json, - ); - file.close(std.testing.io); - - try std.testing.checkAllAllocationFailures( - std.testing.allocator, - check_load_allocation_failures, - .{&tmp.dir}, - ); -} - -test "OAuth mutation loads report auth file open failures" { - if (comptime @import("builtin").os.tag == .windows) return error.SkipZigTest; - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - try tmp.dir.symLink(std.testing.io, "missing-auth-target", auth_file_name, .{ .is_directory = false }); - - try std.testing.expect((try loadFromDir(std.testing.allocator, &tmp.dir, .tolerate_open_failure)) == null); - try std.testing.expectError( - error.SymLinkLoop, - loadFromDir(std.testing.allocator, &tmp.dir, .report_open_failure), - ); -} - test "oauth session rejects invalid saved issuers" { try std.testing.expectError( error.InvalidAuthSession, @@ -1337,84 +517,6 @@ test "oauth session treats near-expiry as expired" { try std.testing.expect(session.expired(std.math.maxInt(i64))); } -const DeleteSyncProbe = struct { - sync_count: usize = 0, - fail: bool = false, - - fn syncDir(raw_ctx: ?*anyopaque, _: std.Io.Dir) anyerror!void { - const self: *DeleteSyncProbe = @ptrCast(@alignCast(raw_ctx.?)); - self.sync_count += 1; - if (self.fail) return error.InjectedSyncFailure; - } -}; - -test "OAuth session deletion reports deleted and missing files" { - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - - var file = try tmp.dir.createFile(std.testing.io, auth_file_name, .{}); - file.close(std.testing.io); - - var probe: DeleteSyncProbe = .{}; - const ops = io_mod.DurableOps{ - .ctx = &probe, - .sync_dir = DeleteSyncProbe.syncDir, - }; - const deleted = try deleteAuthFile(&tmp.dir, ops); - try std.testing.expectEqual(DeleteOutcome.deleted, deleted); - try std.testing.expectEqual(@as(usize, 1), probe.sync_count); - const missing = try deleteAuthFile(&tmp.dir, ops); - try std.testing.expectEqual(DeleteOutcome.missing, missing); - try std.testing.expectEqual(@as(usize, 1), probe.sync_count); -} - -test "OAuth session deletion reports directory sync failure after unlink" { - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - - var file = try tmp.dir.createFile(std.testing.io, auth_file_name, .{}); - file.close(std.testing.io); - - var probe = DeleteSyncProbe{ .fail = true }; - const ops = io_mod.DurableOps{ - .ctx = &probe, - .sync_dir = DeleteSyncProbe.syncDir, - }; - const outcome = try deleteAuthFile(&tmp.dir, ops); - try std.testing.expectEqual(DeleteOutcome.deleted_not_durable, outcome); - try std.testing.expectEqual(@as(usize, 1), probe.sync_count); - try std.testing.expectError( - error.FileNotFound, - tmp.dir.statFile(std.testing.io, auth_file_name, .{}), - ); - - probe.fail = false; - const missing = try deleteAuthFile(&tmp.dir, ops); - try std.testing.expectEqual(DeleteOutcome.missing, missing); - try std.testing.expectEqual(@as(usize, 1), probe.sync_count); -} - -test "OAuth session mutation lock serializes independent handles" { - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - - var first = try lockMutationWithOps( - .{ .dir = try tmp.dir.openDir(std.testing.io, ".", .{ .iterate = true }) }, - 0, - .{}, - ); - defer first.deinit(); - - try std.testing.expectError( - error.LockBusy, - lockMutationWithOps( - .{ .dir = try tmp.dir.openDir(std.testing.io, ".", .{ .iterate = true }) }, - 0, - .{}, - ), - ); -} - test "oauth E2E issuer override accepts loopback HTTP only" { try std.testing.expectEqualStrings( "http://127.0.0.1:43123", diff --git a/src/core/hosts/host.zig b/src/core/hosts/host.zig index 10b3eeb4d..0bee45caf 100644 --- a/src/core/hosts/host.zig +++ b/src/core/hosts/host.zig @@ -95,6 +95,10 @@ pub const SecretStore = struct { ?*anyopaque, std.mem.Allocator, ) SecretStoreLoadError!?[]u8, + load_stored_fn: ?*const fn ( + ?*anyopaque, + std.mem.Allocator, + ) SecretStoreLoadError!?[]u8 = null, store_fn: *const fn ( ?*anyopaque, std.mem.Allocator, @@ -117,6 +121,15 @@ pub const SecretStore = struct { return self.load_fn(self.context, alloc); } + pub fn loadStored( + self: SecretStore, + alloc: std.mem.Allocator, + ) SecretStoreLoadError!?[]u8 { + // Hosts with migration-sensitive storage provide a read-only path. + const load_stored = self.load_stored_fn orelse self.load_fn; + return load_stored(self.context, alloc); + } + /// Borrows `value` for this call. The caller retains ownership. pub fn store( self: SecretStore, @@ -362,6 +375,7 @@ test "unavailable URL opener keeps the manual fallback available" { test "unavailable secret store reports absence and refuses writes" { try std.testing.expect(!unavailable_secret_store.isDisabled()); try std.testing.expect((try unavailable_secret_store.load(std.testing.allocator)) == null); + try std.testing.expect((try unavailable_secret_store.loadStored(std.testing.allocator)) == null); try std.testing.expectError( error.StoredKeyWriteFailed, unavailable_secret_store.store(std.testing.allocator, "secret"), diff --git a/src/core/hosts/native_auth_store.zig b/src/core/hosts/native_auth_store.zig new file mode 100644 index 000000000..64e2fd395 --- /dev/null +++ b/src/core/hosts/native_auth_store.zig @@ -0,0 +1,1029 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const auth_store = @import("../auth/auth_store.zig"); +const debug_trace = @import("../shared/debug_trace.zig"); +const io_mod = @import("../shared/io.zig"); +const native_keychain = @import("native_keychain.zig"); +const profile_paths = @import("../shared/profile_paths.zig"); +const secret = @import("../auth/secret.zig"); + +const Allocator = std.mem.Allocator; +const max_auth_document_bytes: usize = 256 * 1024; +const max_legacy_api_key_bytes: usize = 8 * 1024; +const max_legacy_session_bytes: usize = 64 * 1024; +const mutation_lock_file_name = "auth.lock"; +const mutation_lock_deadline_ms: u64 = 2000; +const e2e_lock_contention_file_name = "auth-lock-contention"; + +const MutationLockProbe = struct { + fx_dir: std.Io.Dir, + signaled: bool = false, + + fn try_lock(raw: ?*anyopaque, file: std.Io.File) anyerror!bool { + const locked = try file.tryLock(io_mod.getIo(), .exclusive); + const self: *MutationLockProbe = @ptrCast(@alignCast(raw.?)); + if (!locked and !self.signaled) { + signal_e2e_lock_contention(self.fx_dir); + self.signaled = true; + } + return locked; + } +}; + +fn signal_e2e_lock_contention(fx_dir: std.Io.Dir) void { + const enabled = io_mod.getenv("FX_E2E_AUTH_LOCK_CONTENTION") orelse return; + if (!std.mem.eql(u8, enabled, "1")) return; + var file = fx_dir.createFile(io_mod.getIo(), e2e_lock_contention_file_name, .{ + .truncate = true, + .permissions = std.Io.File.Permissions.fromMode(0o600), + }) catch return; + defer file.close(io_mod.getIo()); + file.writeStreamingAll(io_mod.getIo(), "contended\n") catch {}; +} + +const StorageBackend = enum { + profile_file, + macos_keychain, +}; + +const KeychainError = Allocator.Error || native_keychain.Error; + +fn storage_backend() StorageBackend { + if (comptime builtin.is_test) return .profile_file; + if (comptime builtin.os.tag == .macos) { + if (!native_keychain.isDisabled()) return .macos_keychain; + } + return .profile_file; +} + +const KeychainBackend = struct { + context: ?*anyopaque = null, + load_document_fn: *const fn (?*anyopaque, Allocator) KeychainError!?[]u8, + store_document_fn: *const fn (?*anyopaque, []const u8) KeychainError!void, + load_api_key_fn: *const fn (?*anyopaque, Allocator) KeychainError!?[]u8, + delete_api_key_fn: *const fn (?*anyopaque, Allocator) KeychainError!bool, + + fn load_document(self: KeychainBackend, alloc: Allocator) KeychainError!?[]u8 { + return self.load_document_fn(self.context, alloc); + } + + fn store_document(self: KeychainBackend, value: []const u8) KeychainError!void { + return self.store_document_fn(self.context, value); + } + + fn load_api_key(self: KeychainBackend, alloc: Allocator) KeychainError!?[]u8 { + return self.load_api_key_fn(self.context, alloc); + } + + fn delete_api_key(self: KeychainBackend, alloc: Allocator) KeychainError!bool { + return self.delete_api_key_fn(self.context, alloc); + } +}; + +const native_keychain_backend = KeychainBackend{ + .load_document_fn = native_load_document, + .store_document_fn = native_store_document, + .load_api_key_fn = native_load_api_key, + .delete_api_key_fn = native_delete_api_key, +}; + +fn native_load_document(_: ?*anyopaque, alloc: Allocator) KeychainError!?[]u8 { + return native_keychain.loadOAuthSession(alloc) catch |err| switch (err) { + error.KeychainItemNotFound => null, + else => return err, + }; +} + +fn native_store_document(_: ?*anyopaque, value: []const u8) KeychainError!void { + return native_keychain.storeOAuthSession(value); +} + +fn native_load_api_key(_: ?*anyopaque, alloc: Allocator) KeychainError!?[]u8 { + return native_keychain.load(alloc) catch |err| switch (err) { + error.KeychainItemNotFound => null, + else => return err, + }; +} + +fn native_delete_api_key(_: ?*anyopaque, alloc: Allocator) KeychainError!bool { + return native_keychain.delete(alloc); +} + +const ProfileMutation = struct { + fx_dir: io_mod.VerifiedDir, + lock: io_mod.TimedAdvisoryLock, + + fn deinit(self: *ProfileMutation) void { + self.lock.release(); + self.fx_dir.close(); + self.* = undefined; + } + + fn load(self: *ProfileMutation, alloc: Allocator) !?auth_store.Document { + var file = self.fx_dir.dir.openFile(io_mod.getIo(), profile_paths.auth_file_name, .{ + .mode = .read_only, + .allow_directory = false, + .follow_symlinks = false, + .resolve_beneath = true, + }) catch |err| switch (err) { + error.FileNotFound => return null, + else => return err, + }; + defer file.close(io_mod.getIo()); + + const stat = try file.stat(io_mod.getIo()); + if (stat.kind != .file or stat.nlink != 1 or stat.permissions.toMode() & 0o077 != 0) { + return error.AuthDocumentInsecure; + } + const bytes = try io_mod.readFileToEnd(alloc, &file, max_auth_document_bytes); + defer secret.zeroAndFree(alloc, bytes); + return try auth_store.Document.parse(alloc, bytes); + } + + fn commit( + self: *ProfileMutation, + alloc: Allocator, + document: auth_store.Document, + ) !void { + const bytes = try document.stringify(alloc); + defer secret.zeroAndFree(alloc, bytes); + try io_mod.durableReplaceVerified( + alloc, + &self.fx_dir, + profile_paths.auth_file_name, + bytes, + ); + } +}; + +fn begin_profile_mutation(home: []const u8) !ProfileMutation { + var home_dir = io_mod.VerifiedDir{ + .dir = try std.Io.Dir.openDirAbsolute(io_mod.getIo(), home, .{ .iterate = true }), + }; + defer home_dir.close(); + + var fx_dir = try io_mod.openOrCreateVerifiedPrivateDir(&home_dir, profile_paths.root_dir_name); + errdefer fx_dir.close(); + var probe = MutationLockProbe{ .fx_dir = fx_dir.dir }; + var lock = try io_mod.acquireTimedAdvisoryLockWithOps( + &fx_dir, + mutation_lock_file_name, + mutation_lock_deadline_ms, + .{ .ctx = &probe, .try_lock = MutationLockProbe.try_lock }, + ); + errdefer lock.release(); + return .{ .fx_dir = fx_dir, .lock = lock }; +} + +const ProfileObservation = struct { + state: auth_store.StoreState, + document: ?auth_store.Document = null, + + fn deinit(self: *ProfileObservation, alloc: Allocator) void { + if (self.document) |*document| document.deinit(alloc); + self.* = .{ .state = .empty }; + } + + fn take_document(self: *ProfileObservation) ?auth_store.Document { + const document = self.document; + self.document = null; + return document; + } +}; + +pub const DeleteOutcome = enum { + deleted, + missing, + deleted_not_durable, +}; + +pub const EntryMutation = struct { + profile: ProfileMutation, + source: auth_store.StoredSource, + backend: StorageBackend = .profile_file, + keychain: KeychainBackend = native_keychain_backend, + + pub fn deinit(self: *EntryMutation) void { + self.profile.deinit(); + self.* = undefined; + } + + pub fn load(self: *EntryMutation, alloc: Allocator) !?[]u8 { + var observation = switch (self.backend) { + .profile_file => try observe_profile(alloc, &self.profile.fx_dir.dir), + .macos_keychain => try observe_keychain_profile(alloc, &self.profile.fx_dir.dir, self.keychain), + }; + defer observation.deinit(alloc); + const document = observation.document orelse return null; + const value = document.get(self.source) orelse return null; + return try alloc.dupe(u8, value); + } + + pub fn save(self: *EntryMutation, alloc: Allocator, value: []const u8) !void { + var observation = switch (self.backend) { + .profile_file => try observe_profile(alloc, &self.profile.fx_dir.dir), + .macos_keychain => try observe_keychain_profile(alloc, &self.profile.fx_dir.dir, self.keychain), + }; + defer observation.deinit(alloc); + const empty: auth_store.Document = .{}; + const document = if (observation.document) |*current| current else ∅ + var next = try document.replaced(alloc, self.source, value); + defer next.deinit(alloc); + switch (self.backend) { + .profile_file => { + try commit_and_verify(&self.profile, alloc, next); + delete_legacy_profile_files(&self.profile.fx_dir.dir) catch |err| { + debug_trace.logf("auth", "common auth cleanup incomplete backend=profile err={s}", .{@errorName(err)}); + }; + }, + .macos_keychain => { + try commit_and_verify_keychain(alloc, next, self.keychain); + delete_keychain_legacy_profile_files(&self.profile.fx_dir.dir) catch |err| { + debug_trace.logf("auth", "common auth cleanup incomplete backend=keychain source=profile err={s}", .{@errorName(err)}); + }; + _ = self.keychain.delete_api_key(alloc) catch |err| failed: { + debug_trace.logf("auth", "common auth cleanup incomplete backend=keychain source=stored_key err={s}", .{@errorName(err)}); + break :failed false; + }; + }, + } + } + + pub fn delete(self: *EntryMutation, alloc: Allocator) !DeleteOutcome { + var observation = switch (self.backend) { + .profile_file => try observe_profile(alloc, &self.profile.fx_dir.dir), + .macos_keychain => try observe_keychain_profile(alloc, &self.profile.fx_dir.dir, self.keychain), + }; + defer observation.deinit(alloc); + const document = observation.document orelse return .missing; + if (document.get(self.source) == null) return .missing; + var next = try document.removed(alloc, self.source); + defer next.deinit(alloc); + var cleanup_failed = false; + switch (self.backend) { + .profile_file => { + commit_and_verify(&self.profile, alloc, next) catch |err| switch (err) { + error.DurableReplacePostRenameFailed => return .deleted_not_durable, + else => return err, + }; + delete_legacy_profile_files(&self.profile.fx_dir.dir) catch |err| { + debug_trace.logf("auth", "common auth cleanup incomplete backend=profile err={s}", .{@errorName(err)}); + cleanup_failed = true; + }; + }, + .macos_keychain => { + try commit_and_verify_keychain(alloc, next, self.keychain); + delete_keychain_legacy_profile_files(&self.profile.fx_dir.dir) catch |err| { + debug_trace.logf("auth", "common auth cleanup incomplete backend=keychain source=profile err={s}", .{@errorName(err)}); + cleanup_failed = true; + }; + _ = self.keychain.delete_api_key(alloc) catch |err| failed: { + debug_trace.logf("auth", "common auth cleanup incomplete backend=keychain source=stored_key err={s}", .{@errorName(err)}); + cleanup_failed = true; + break :failed false; + }; + }, + } + return if (cleanup_failed) .deleted_not_durable else .deleted; + } +}; + +pub fn load_entry( + alloc: Allocator, + source: auth_store.StoredSource, + intent: auth_store.LoadIntent, +) !?[]u8 { + const home = io_mod.getenv("HOME") orelse return null; + return switch (storage_backend()) { + .profile_file => load_profile_entry(alloc, home, source, intent), + .macos_keychain => blk: { + var document = (try load_keychain_document( + alloc, + home, + intent, + native_keychain_backend, + )) orelse break :blk null; + defer document.deinit(alloc); + const value = document.get(source) orelse break :blk null; + break :blk try alloc.dupe(u8, value); + }, + }; +} + +pub fn begin_entry_mutation(source: auth_store.StoredSource) !EntryMutation { + const home = io_mod.getenv("HOME") orelse return error.HomeNotSet; + var mutation = try begin_profile_entry_mutation(home, source); + mutation.backend = storage_backend(); + return mutation; +} + +fn begin_profile_entry_mutation( + home: []const u8, + source: auth_store.StoredSource, +) !EntryMutation { + return .{ + .profile = try begin_profile_mutation(home), + .source = source, + }; +} + +fn load_profile_entry( + alloc: Allocator, + home: []const u8, + source: auth_store.StoredSource, + intent: auth_store.LoadIntent, +) !?[]u8 { + var document = (try load_profile_document(alloc, home, intent)) orelse return null; + defer document.deinit(alloc); + const value = document.get(source) orelse return null; + return try alloc.dupe(u8, value); +} + +fn commit_and_verify( + mutation: *ProfileMutation, + alloc: Allocator, + document: auth_store.Document, +) !void { + try mutation.commit(alloc, document); + var verified = (try mutation.load(alloc)) orelse return error.AuthDocumentWriteMismatch; + defer verified.deinit(alloc); + if (!document.eql(verified)) return error.AuthDocumentWriteMismatch; +} + +fn load_profile_document( + alloc: Allocator, + home: []const u8, + intent: auth_store.LoadIntent, +) !?auth_store.Document { + var initial = try observe_profile_home(alloc, home); + defer initial.deinit(alloc); + switch (auth_store.decide_load(initial.state, intent)) { + .missing => return null, + .use_legacy, .use_current => return initial.take_document(), + .reject_current => unreachable, + .migrate_legacy => {}, + } + + var mutation = try begin_profile_mutation(home); + defer mutation.deinit(); + var observation = try observe_profile(alloc, &mutation.fx_dir.dir); + defer observation.deinit(alloc); + return switch (auth_store.decide_load(observation.state, intent)) { + .missing => null, + .use_current => observation.take_document(), + .migrate_legacy => migrated: { + const document = observation.document orelse return error.InvalidAuthDocument; + commit_and_verify(&mutation, alloc, document) catch |err| { + debug_trace.logf("auth", "common auth migration deferred backend=profile step=publish err={s}", .{@errorName(err)}); + break :migrated observation.take_document(); + }; + delete_legacy_profile_files(&mutation.fx_dir.dir) catch |err| { + debug_trace.logf("auth", "common auth migration cleanup incomplete backend=profile err={s}", .{@errorName(err)}); + }; + break :migrated observation.take_document(); + }, + .use_legacy, .reject_current => unreachable, + }; +} + +fn observe_profile_home(alloc: Allocator, home: []const u8) !ProfileObservation { + var home_dir = std.Io.Dir.openDirAbsolute(io_mod.getIo(), home, .{ .iterate = true }) catch |err| switch (err) { + error.FileNotFound => return .{ .state = .empty }, + else => return err, + }; + defer home_dir.close(io_mod.getIo()); + var fx_dir = home_dir.openDir(io_mod.getIo(), profile_paths.root_dir_name, .{ + .iterate = true, + .follow_symlinks = false, + }) catch |err| switch (err) { + error.FileNotFound => return .{ .state = .empty }, + else => return err, + }; + defer fx_dir.close(io_mod.getIo()); + return observe_profile(alloc, &fx_dir); +} + +fn load_keychain_document( + alloc: Allocator, + home: []const u8, + intent: auth_store.LoadIntent, + keychain: KeychainBackend, +) !?auth_store.Document { + var initial = try observe_keychain_home(alloc, home, keychain); + defer initial.deinit(alloc); + switch (auth_store.decide_load(initial.state, intent)) { + .missing => return null, + .use_legacy, .use_current => return initial.take_document(), + .reject_current => unreachable, + .migrate_legacy => {}, + } + + var mutation = try begin_profile_mutation(home); + defer mutation.deinit(); + var observation = try observe_keychain_profile(alloc, &mutation.fx_dir.dir, keychain); + defer observation.deinit(alloc); + return switch (auth_store.decide_load(observation.state, intent)) { + .missing => null, + .use_current => observation.take_document(), + .migrate_legacy => migrated: { + const document = observation.document orelse return error.InvalidAuthDocument; + commit_and_verify_keychain(alloc, document, keychain) catch |err| { + debug_trace.logf("auth", "common auth migration deferred backend=keychain step=publish err={s}", .{@errorName(err)}); + break :migrated observation.take_document(); + }; + delete_keychain_legacy_profile_files(&mutation.fx_dir.dir) catch |err| { + debug_trace.logf("auth", "common auth migration cleanup incomplete backend=keychain source=profile err={s}", .{@errorName(err)}); + }; + _ = keychain.delete_api_key(alloc) catch |err| failed: { + debug_trace.logf("auth", "common auth migration cleanup incomplete backend=keychain source=stored_key err={s}", .{@errorName(err)}); + break :failed false; + }; + break :migrated observation.take_document(); + }, + .use_legacy, .reject_current => unreachable, + }; +} + +fn observe_keychain_home( + alloc: Allocator, + home: []const u8, + keychain: KeychainBackend, +) !ProfileObservation { + var home_dir = std.Io.Dir.openDirAbsolute(io_mod.getIo(), home, .{ .iterate = true }) catch |err| switch (err) { + error.FileNotFound => return observe_keychain_without_profile(alloc, keychain), + else => return err, + }; + defer home_dir.close(io_mod.getIo()); + var fx_dir = home_dir.openDir(io_mod.getIo(), profile_paths.root_dir_name, .{ + .iterate = true, + .follow_symlinks = false, + }) catch |err| switch (err) { + error.FileNotFound => return observe_keychain_without_profile(alloc, keychain), + else => return err, + }; + defer fx_dir.close(io_mod.getIo()); + return observe_keychain_profile(alloc, &fx_dir, keychain); +} + +fn observe_keychain_without_profile( + alloc: Allocator, + keychain: KeychainBackend, +) !ProfileObservation { + var document: auth_store.Document = .{}; + errdefer document.deinit(alloc); + var found = false; + const stored = try keychain.load_document(alloc); + defer if (stored) |bytes| secret.zeroAndFree(alloc, bytes); + if (stored) |bytes| { + const version = (try declared_version(alloc, bytes)) orelse return error.InvalidAuthDocument; + switch (version) { + 2 => return .{ .state = .current, .document = try auth_store.Document.parse(alloc, bytes) }, + 1 => { + const next = try document.replaced(alloc, .fx_login, bytes); + document.deinit(alloc); + document = next; + found = true; + }, + else => return error.InvalidAuthDocument, + } + } + if (try keychain.load_api_key(alloc)) |key| { + defer secret.zeroAndFree(alloc, key); + const next = try document.replaced(alloc, .stored_key, key); + document.deinit(alloc); + document = next; + found = true; + } + return if (found) + .{ .state = .legacy, .document = document } + else blk: { + document.deinit(alloc); + break :blk .{ .state = .empty }; + }; +} + +fn observe_keychain_profile( + alloc: Allocator, + fx_dir: *std.Io.Dir, + keychain: KeychainBackend, +) !ProfileObservation { + const stored = try keychain.load_document(alloc); + defer if (stored) |bytes| secret.zeroAndFree(alloc, bytes); + const stored_version = if (stored) |bytes| try declared_version(alloc, bytes) else null; + if (stored) |bytes| { + if (stored_version == 2) { + return .{ .state = .current, .document = try auth_store.Document.parse(alloc, bytes) }; + } + if (stored_version != null and stored_version != 1) return error.InvalidAuthDocument; + } + + var profile = try observe_profile(alloc, fx_dir); + defer profile.deinit(alloc); + var document = profile.take_document() orelse auth_store.Document{}; + errdefer document.deinit(alloc); + var found = profile.state != .empty; + if (stored) |bytes| { + if (document.get(.fx_login) == null) { + if (stored_version == 1) { + const next = try document.replaced(alloc, .fx_login, bytes); + document.deinit(alloc); + document = next; + found = true; + } + } + if (stored_version == null and !found) return error.InvalidAuthDocument; + } + if (try keychain.load_api_key(alloc)) |key| { + defer secret.zeroAndFree(alloc, key); + const next = try document.replaced(alloc, .stored_key, key); + document.deinit(alloc); + document = next; + found = true; + } + return if (found) + .{ .state = .legacy, .document = document } + else blk: { + document.deinit(alloc); + break :blk .{ .state = .empty }; + }; +} + +fn commit_and_verify_keychain( + alloc: Allocator, + document: auth_store.Document, + keychain: KeychainBackend, +) !void { + const bytes = try document.stringify(alloc); + defer secret.zeroAndFree(alloc, bytes); + try keychain.store_document(bytes); + const persisted = (try keychain.load_document(alloc)) orelse return error.AuthDocumentWriteMismatch; + defer secret.zeroAndFree(alloc, persisted); + var verified = try auth_store.Document.parse(alloc, persisted); + defer verified.deinit(alloc); + if (!document.eql(verified)) return error.AuthDocumentWriteMismatch; +} + +fn observe_profile(alloc: Allocator, fx_dir: *std.Io.Dir) !ProfileObservation { + const auth_bytes = try read_optional_private_file( + alloc, + fx_dir, + profile_paths.auth_file_name, + max_auth_document_bytes, + ); + defer if (auth_bytes) |bytes| secret.zeroAndFree(alloc, bytes); + if (auth_bytes) |bytes| { + const version = (try declared_version(alloc, bytes)) orelse return error.InvalidAuthDocument; + if (version == 2) { + return .{ .state = .current, .document = try auth_store.Document.parse(alloc, bytes) }; + } + if (version != 1) return error.InvalidAuthDocument; + } + + var document: auth_store.Document = .{}; + errdefer document.deinit(alloc); + var found_legacy = false; + if (auth_bytes) |bytes| { + if ((try declared_version(alloc, bytes)) == 1) { + const next = try document.replaced(alloc, .fx_login, bytes); + document.deinit(alloc); + document = next; + found_legacy = true; + } + } + if (try read_optional_private_file( + alloc, + fx_dir, + profile_paths.api_key_file_name, + max_legacy_api_key_bytes, + )) |raw_key| { + defer secret.zeroAndFree(alloc, raw_key); + const key = std.mem.trim(u8, raw_key, "\r\n"); + if (key.len > 0) { + const next = try document.replaced(alloc, .stored_key, key); + document.deinit(alloc); + document = next; + found_legacy = true; + } + } + if (try read_optional_private_file( + alloc, + fx_dir, + profile_paths.chatgpt_auth_file_name, + max_legacy_session_bytes, + )) |bytes| { + defer secret.zeroAndFree(alloc, bytes); + if ((try declared_version(alloc, bytes)) == 1) { + const next = try document.replaced(alloc, .chatgpt_subscription, bytes); + document.deinit(alloc); + document = next; + found_legacy = true; + } + } + if (try read_optional_private_file( + alloc, + fx_dir, + profile_paths.grok_auth_file_name, + max_legacy_session_bytes, + )) |bytes| { + defer secret.zeroAndFree(alloc, bytes); + if ((try declared_version(alloc, bytes)) == 1) { + const next = try document.replaced(alloc, .grok_subscription, bytes); + document.deinit(alloc); + document = next; + found_legacy = true; + } + } + + return if (found_legacy) + .{ .state = .legacy, .document = document } + else blk: { + document.deinit(alloc); + break :blk .{ .state = .empty }; + }; +} + +fn declared_version(alloc: Allocator, bytes: []const u8) !?i64 { + var parsed = std.json.parseFromSlice(std.json.Value, alloc, bytes, .{}) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => return null, + }; + defer parsed.deinit(); + if (parsed.value != .object) return null; + const version = parsed.value.object.get("version") orelse return null; + return if (version == .integer) version.integer else null; +} + +fn read_optional_private_file( + alloc: Allocator, + dir: *std.Io.Dir, + name: []const u8, + max_bytes: usize, +) !?[]u8 { + var file = dir.openFile(io_mod.getIo(), name, .{ + .mode = .read_only, + .allow_directory = false, + .follow_symlinks = false, + .resolve_beneath = true, + }) catch |err| switch (err) { + error.FileNotFound => return null, + else => return err, + }; + defer file.close(io_mod.getIo()); + const stat = try file.stat(io_mod.getIo()); + if (stat.kind != .file or stat.nlink != 1 or stat.permissions.toMode() & 0o077 != 0) { + return error.AuthDocumentInsecure; + } + return try io_mod.readFileToEnd(alloc, &file, max_bytes); +} + +fn read_profile_file(alloc: Allocator, home: []const u8, name: []const u8) ![]u8 { + var home_dir = try std.Io.Dir.openDirAbsolute(io_mod.getIo(), home, .{ .iterate = true }); + defer home_dir.close(io_mod.getIo()); + var fx_dir = try home_dir.openDir(io_mod.getIo(), profile_paths.root_dir_name, .{ + .iterate = true, + .follow_symlinks = false, + }); + defer fx_dir.close(io_mod.getIo()); + return (try read_optional_private_file(alloc, &fx_dir, name, max_auth_document_bytes)) orelse + error.FileNotFound; +} + +fn delete_legacy_profile_files(fx_dir: *std.Io.Dir) !void { + var deleted = false; + for ([_][]const u8{ + profile_paths.api_key_file_name, + profile_paths.chatgpt_auth_file_name, + profile_paths.grok_auth_file_name, + }) |name| { + fx_dir.deleteFile(io_mod.getIo(), name) catch |err| switch (err) { + error.FileNotFound => continue, + else => return err, + }; + deleted = true; + } + if (deleted) try io_mod.syncVerifiedDir(fx_dir.*); +} + +fn delete_keychain_legacy_profile_files(fx_dir: *std.Io.Dir) !void { + var deleted = false; + for ([_][]const u8{ + profile_paths.auth_file_name, + profile_paths.api_key_file_name, + profile_paths.chatgpt_auth_file_name, + profile_paths.grok_auth_file_name, + }) |name| { + fx_dir.deleteFile(io_mod.getIo(), name) catch |err| switch (err) { + error.FileNotFound => continue, + else => return err, + }; + deleted = true; + } + if (deleted) try io_mod.syncVerifiedDir(fx_dir.*); +} + +test "profile auth store commits one private document and reloads every source" { + const alloc = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const home = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "."); + defer alloc.free(home); + + var document: auth_store.Document = .{}; + defer document.deinit(alloc); + const with_key = try document.replaced(alloc, .stored_key, "gateway-secret"); + document.deinit(alloc); + document = with_key; + const with_codex = try document.replaced( + alloc, + .chatgpt_subscription, + "{\"version\":1,\"access_token\":\"codex\"}", + ); + document.deinit(alloc); + document = with_codex; + + var mutation = try begin_profile_mutation(home); + try mutation.commit(alloc, document); + mutation.deinit(); + + var fx_dir = try tmp.dir.openDir(std.testing.io, ".fx", .{}); + defer fx_dir.close(std.testing.io); + const stat = try fx_dir.statFile(std.testing.io, "auth.json", .{}); + try std.testing.expectEqual(@as(std.posix.mode_t, 0o600), stat.permissions.toMode() & 0o777); + + var reopened = try begin_profile_mutation(home); + defer reopened.deinit(); + var loaded = (try reopened.load(alloc)) orelse return error.TestExpectedAuthDocument; + defer loaded.deinit(alloc); + try std.testing.expect(document.eql(loaded)); +} + +test "empty active load stays read only" { + const alloc = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const home = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "."); + defer alloc.free(home); + + try std.testing.expect((try load_profile_document(alloc, home, .active)) == null); + try std.testing.expectError( + error.FileNotFound, + tmp.dir.statFile(std.testing.io, profile_paths.root_dir_name, .{}), + ); +} + +test "malformed common document blocks legacy fallback and migration" { + const alloc = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const home = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "."); + defer alloc.free(home); + + var verified_home = io_mod.VerifiedDir{ + .dir = try tmp.dir.openDir(std.testing.io, ".", .{ .iterate = true }), + }; + defer verified_home.close(); + var fx_dir = try io_mod.openOrCreateVerifiedPrivateDir(&verified_home, profile_paths.root_dir_name); + defer fx_dir.close(); + try io_mod.durableReplaceVerified( + alloc, + &fx_dir, + profile_paths.auth_file_name, + "{\"version\":2,\"credentials\":[]}", + ); + try io_mod.durableReplaceVerified( + alloc, + &fx_dir, + profile_paths.api_key_file_name, + "gateway-secret", + ); + + try std.testing.expectError( + error.InvalidAuthDocument, + load_profile_document(alloc, home, .active), + ); + _ = try fx_dir.dir.statFile(std.testing.io, profile_paths.api_key_file_name, .{}); +} + +test "legacy inspection is read only and active load publishes one common document" { + const alloc = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const home = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "."); + defer alloc.free(home); + + var verified_home = io_mod.VerifiedDir{ + .dir = try tmp.dir.openDir(std.testing.io, ".", .{ .iterate = true }), + }; + defer verified_home.close(); + var fx_dir = try io_mod.openOrCreateVerifiedPrivateDir(&verified_home, profile_paths.root_dir_name); + defer fx_dir.close(); + + const vercel = + "{\"version\":1,\"issuer\":\"https://vercel.com\",\"client_id\":\"client\",\"access_token\":\"vercel-access\",\"refresh_token\":\"vercel-refresh\",\"expires_at_ms\":4102444800000,\"scope\":\"openid offline_access\",\"token_type\":\"Bearer\",\"team_slug\":\"team-slug\",\"team_id\":\"team-id\"}"; + const codex = + "{\"version\":1,\"access_token\":\"codex-access\",\"refresh_token\":\"codex-refresh\",\"expires_at_ms\":4102444800000,\"account_id\":\"codex-account\"}"; + const grok = + "{\"version\":1,\"access_token\":\"grok-access\",\"refresh_token\":\"grok-refresh\",\"expires_at_ms\":4102444800000,\"account_id\":\"grok-account\"}"; + try io_mod.durableReplaceVerified(alloc, &fx_dir, profile_paths.auth_file_name, vercel); + try io_mod.durableReplaceVerified(alloc, &fx_dir, profile_paths.api_key_file_name, "gateway-secret"); + try io_mod.durableReplaceVerified(alloc, &fx_dir, profile_paths.chatgpt_auth_file_name, codex); + try io_mod.durableReplaceVerified(alloc, &fx_dir, profile_paths.grok_auth_file_name, grok); + + var inspected = (try load_profile_document(alloc, home, .inspect)) orelse + return error.TestExpectedLegacyDocument; + defer inspected.deinit(alloc); + try std.testing.expectEqualStrings("gateway-secret", inspected.get(.stored_key).?); + try std.testing.expectEqualStrings(vercel, inspected.get(.fx_login).?); + try std.testing.expectEqualStrings(codex, inspected.get(.chatgpt_subscription).?); + try std.testing.expectEqualStrings(grok, inspected.get(.grok_subscription).?); + _ = try fx_dir.dir.statFile(std.testing.io, profile_paths.api_key_file_name, .{}); + _ = try fx_dir.dir.statFile(std.testing.io, profile_paths.chatgpt_auth_file_name, .{}); + _ = try fx_dir.dir.statFile(std.testing.io, profile_paths.grok_auth_file_name, .{}); + + const before = try read_profile_file(alloc, home, profile_paths.auth_file_name); + defer secret.zeroAndFree(alloc, before); + try std.testing.expect(std.mem.find(u8, before, "\"version\":1") != null); + + var migrated = (try load_profile_document(alloc, home, .active)) orelse + return error.TestExpectedMigratedDocument; + defer migrated.deinit(alloc); + try std.testing.expect(inspected.eql(migrated)); + + const after = try read_profile_file(alloc, home, profile_paths.auth_file_name); + defer secret.zeroAndFree(alloc, after); + var parsed_after = try auth_store.Document.parse(alloc, after); + defer parsed_after.deinit(alloc); + try std.testing.expect(migrated.eql(parsed_after)); + + var migrated_fx_dir = try tmp.dir.openDir(std.testing.io, ".fx", .{}); + defer migrated_fx_dir.close(std.testing.io); + for ([_][]const u8{ + profile_paths.api_key_file_name, + profile_paths.chatgpt_auth_file_name, + profile_paths.grok_auth_file_name, + }) |name| { + try std.testing.expectError( + error.FileNotFound, + migrated_fx_dir.statFile(std.testing.io, name, .{}), + ); + } +} + +test "source mutations share one document without losing unrelated credentials" { + const alloc = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const home = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "."); + defer alloc.free(home); + + var key_mutation = try begin_profile_entry_mutation(home, .stored_key); + try key_mutation.save(alloc, "gateway-secret"); + key_mutation.deinit(); + + var codex_mutation = try begin_profile_entry_mutation(home, .chatgpt_subscription); + try codex_mutation.save(alloc, "{\"version\":1,\"access_token\":\"codex\"}"); + codex_mutation.deinit(); + + const key = (try load_profile_entry(alloc, home, .stored_key, .inspect)) orelse + return error.TestExpectedStoredKey; + defer secret.zeroAndFree(alloc, key); + try std.testing.expectEqualStrings("gateway-secret", key); + + var delete_codex = try begin_profile_entry_mutation(home, .chatgpt_subscription); + try std.testing.expectEqual(DeleteOutcome.deleted, try delete_codex.delete(alloc)); + delete_codex.deinit(); + + const preserved_key = (try load_profile_entry(alloc, home, .stored_key, .inspect)) orelse + return error.TestExpectedPreservedKey; + defer secret.zeroAndFree(alloc, preserved_key); + try std.testing.expectEqualStrings("gateway-secret", preserved_key); + try std.testing.expect((try load_profile_entry( + alloc, + home, + .chatgpt_subscription, + .inspect, + )) == null); +} + +test "Keychain migration publishes every source before removing legacy credentials" { + const alloc = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const home = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "."); + defer alloc.free(home); + + var verified_home = io_mod.VerifiedDir{ + .dir = try tmp.dir.openDir(std.testing.io, ".", .{ .iterate = true }), + }; + defer verified_home.close(); + var fx_dir = try io_mod.openOrCreateVerifiedPrivateDir(&verified_home, profile_paths.root_dir_name); + defer fx_dir.close(); + const codex = + "{\"version\":1,\"access_token\":\"codex-access\",\"refresh_token\":\"codex-refresh\",\"expires_at_ms\":4102444800000,\"account_id\":\"codex-account\"}"; + const grok = + "{\"version\":1,\"access_token\":\"grok-access\",\"refresh_token\":\"grok-refresh\",\"expires_at_ms\":4102444800000,\"account_id\":\"grok-account\"}"; + try io_mod.durableReplaceVerified(alloc, &fx_dir, profile_paths.chatgpt_auth_file_name, codex); + try io_mod.durableReplaceVerified(alloc, &fx_dir, profile_paths.grok_auth_file_name, grok); + + var fake = FakeKeychain{ + .alloc = alloc, + .document = try alloc.dupe( + u8, + "{\"version\":1,\"issuer\":\"https://vercel.com\",\"client_id\":\"client\",\"access_token\":\"vercel-access\",\"refresh_token\":\"vercel-refresh\",\"expires_at_ms\":4102444800000,\"scope\":\"openid offline_access\",\"token_type\":\"Bearer\",\"team_slug\":\"team-slug\",\"team_id\":\"team-id\"}", + ), + .api_key = try alloc.dupe(u8, "gateway-secret"), + }; + defer fake.deinit(); + + var loaded = (try load_keychain_document(alloc, home, .active, fake.backend())) orelse + return error.TestExpectedKeychainDocument; + defer loaded.deinit(alloc); + try std.testing.expect(loaded.get(.stored_key) != null); + try std.testing.expect(loaded.get(.fx_login) != null); + try std.testing.expect(loaded.get(.chatgpt_subscription) != null); + try std.testing.expect(loaded.get(.grok_subscription) != null); + try std.testing.expect(fake.api_key == null); + + var stored = try auth_store.Document.parse(alloc, fake.document.?); + defer stored.deinit(alloc); + try std.testing.expect(loaded.eql(stored)); +} + +test "Keychain publication failure keeps legacy credentials authoritative" { + const alloc = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const home = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "."); + defer alloc.free(home); + + var verified_home = io_mod.VerifiedDir{ + .dir = try tmp.dir.openDir(std.testing.io, ".", .{ .iterate = true }), + }; + defer verified_home.close(); + var fx_dir = try io_mod.openOrCreateVerifiedPrivateDir(&verified_home, profile_paths.root_dir_name); + defer fx_dir.close(); + const codex = + "{\"version\":1,\"access_token\":\"codex-access\",\"refresh_token\":\"codex-refresh\",\"expires_at_ms\":4102444800000,\"account_id\":\"codex-account\"}"; + try io_mod.durableReplaceVerified(alloc, &fx_dir, profile_paths.chatgpt_auth_file_name, codex); + + var fake = FakeKeychain{ .alloc = alloc, .fail_store = true }; + defer fake.deinit(); + var loaded = (try load_keychain_document(alloc, home, .active, fake.backend())) orelse + return error.TestExpectedLegacyDocument; + defer loaded.deinit(alloc); + + try std.testing.expectEqualStrings(codex, loaded.get(.chatgpt_subscription).?); + try std.testing.expect(fake.document == null); + _ = try fx_dir.dir.statFile(std.testing.io, profile_paths.chatgpt_auth_file_name, .{}); +} + +const FakeKeychain = struct { + alloc: Allocator, + document: ?[]u8 = null, + api_key: ?[]u8 = null, + fail_store: bool = false, + + fn deinit(self: *FakeKeychain) void { + if (self.document) |value| secret.zeroAndFree(self.alloc, value); + if (self.api_key) |value| secret.zeroAndFree(self.alloc, value); + self.* = undefined; + } + + fn backend(self: *FakeKeychain) KeychainBackend { + return .{ + .context = self, + .load_document_fn = load_document, + .store_document_fn = store_document, + .load_api_key_fn = load_api_key, + .delete_api_key_fn = delete_api_key, + }; + } + + fn load_document(raw: ?*anyopaque, alloc: Allocator) KeychainError!?[]u8 { + const self: *FakeKeychain = @ptrCast(@alignCast(raw.?)); + const value = self.document orelse return null; + return try alloc.dupe(u8, value); + } + + fn store_document(raw: ?*anyopaque, value: []const u8) KeychainError!void { + const self: *FakeKeychain = @ptrCast(@alignCast(raw.?)); + if (self.fail_store) return error.KeychainWriteFailed; + const replacement = try self.alloc.dupe(u8, value); + if (self.document) |old| secret.zeroAndFree(self.alloc, old); + self.document = replacement; + } + + fn load_api_key(raw: ?*anyopaque, alloc: Allocator) KeychainError!?[]u8 { + const self: *FakeKeychain = @ptrCast(@alignCast(raw.?)); + const value = self.api_key orelse return null; + return try alloc.dupe(u8, value); + } + + fn delete_api_key(raw: ?*anyopaque, _: Allocator) KeychainError!bool { + const self: *FakeKeychain = @ptrCast(@alignCast(raw.?)); + const value = self.api_key orelse return false; + secret.zeroAndFree(self.alloc, value); + self.api_key = null; + return true; + } +}; diff --git a/src/core/hosts/native_keychain.zig b/src/core/hosts/native_keychain.zig index e894dccbc..72d743d2d 100644 --- a/src/core/hosts/native_keychain.zig +++ b/src/core/hosts/native_keychain.zig @@ -13,7 +13,7 @@ pub const AccountBuffer = [256]u8; const passwd_scratch_bytes = 2048; const max_mcp_credentials_bytes: usize = 1024 * 1024; -const max_oauth_session_bytes: usize = 64 * 1024; +const max_oauth_session_bytes: usize = 256 * 1024; const keychain_process_timeout: std.Io.Timeout = .{ .duration = .{ .raw = .{ .nanoseconds = 10 * std.time.ns_per_s }, @@ -355,6 +355,10 @@ pub fn deleteMcpCredentials(alloc: std.mem.Allocator) Error!bool { return deleteMcpValueMac(alloc, mcp_credentials_service_name); } +pub fn delete(alloc: std.mem.Allocator) Error!bool { + return deleteServiceItem(alloc, service_name); +} + pub fn deleteOAuthSession(alloc: std.mem.Allocator) Error!bool { return deleteMcpValueMac(alloc, oauth_session_service_name); } diff --git a/src/core/hosts/native_secret_store.zig b/src/core/hosts/native_secret_store.zig index d8b8719fe..77daba893 100644 --- a/src/core/hosts/native_secret_store.zig +++ b/src/core/hosts/native_secret_store.zig @@ -1,10 +1,10 @@ const std = @import("std"); const builtin = @import("builtin"); +const auth_store = @import("../auth/auth_store.zig"); const debug_trace = @import("../shared/debug_trace.zig"); const host = @import("host.zig"); -const io_mod = @import("../shared/io.zig"); const keychain = @import("native_keychain.zig"); -const profile_paths = @import("../shared/profile_paths.zig"); +const native_auth_store = @import("native_auth_store.zig"); const secret = @import("../auth/secret.zig"); const Allocator = std.mem.Allocator; @@ -13,8 +13,6 @@ const Allocator = std.mem.Allocator; /// stored key lives without knowing how the backend is selected. const backend_label = if (builtin.os.tag == .macos) "macOS Keychain" else "profile file"; -const max_key_file_bytes: usize = 8 * 1024; - const LoadError = host.SecretStoreLoadError; const StoreError = host.SecretStoreWriteError; @@ -22,6 +20,7 @@ pub const provider: host.SecretStore = .{ .backend_label = backend_label, .is_disabled_fn = isDisabledCallback, .load_fn = loadCallback, + .load_stored_fn = loadStoredCallback, .store_fn = storeCallback, .store_interactive_fn = storeInteractiveCallback, }; @@ -34,17 +33,31 @@ fn isDisabled() bool { /// Returns the stored key, or null when no key is stored. An error means the store /// could not be read, which callers must keep distinct from absence. fn load(alloc: Allocator) LoadError!?[]u8 { - if (comptime builtin.os.tag == .macos) return loadFromKeychain(alloc); - return loadFromProfile(alloc); + return loadWithIntent(alloc, .active); +} + +fn loadStored(alloc: Allocator) LoadError!?[]u8 { + return loadWithIntent(alloc, .inspect); +} + +fn loadWithIntent( + alloc: Allocator, + intent: auth_store.LoadIntent, +) LoadError!?[]u8 { + return native_auth_store.load_entry(alloc, .stored_key, intent) catch |err| switch (err) { + error.OutOfMemory => error.OutOfMemory, + error.AuthDocumentInsecure => error.StoredKeyInsecure, + else => error.StoredKeyUnreadable, + }; } fn store(alloc: Allocator, value: []const u8) StoreError!void { if (value.len == 0) return error.StoredKeyWriteFailed; - if (comptime builtin.os.tag == .macos) { - keychain.storeValue(value) catch |err| return writeFailed("keychain", err); - return; - } - return storeInProfile(alloc, value); + var mutation = native_auth_store.begin_entry_mutation(.stored_key) catch |err| { + return writeFailed("begin_auth_store", err); + }; + defer mutation.deinit(); + mutation.save(alloc, value) catch |err| return writeFailed("commit_auth_store", err); } /// Let the platform credential store own terminal input when it supports a @@ -52,6 +65,15 @@ fn store(alloc: Allocator, value: []const u8) StoreError!void { fn storeInteractive() StoreError!bool { if (comptime builtin.os.tag == .macos) { keychain.storeInteractive() catch |err| return writeFailed("keychain_interactive", err); + const alloc = std.heap.c_allocator; + const value = (keychain.load(alloc) catch |err| return writeFailed("keychain_interactive_readback", err)) orelse + return writeFailed("keychain_interactive_readback", error.KeychainItemNotFound); + defer secret.zeroAndFree(alloc, value); + var mutation = native_auth_store.begin_entry_mutation(.stored_key) catch |err| { + return writeFailed("keychain_interactive_begin", err); + }; + defer mutation.deinit(); + mutation.save(alloc, value) catch |err| return writeFailed("keychain_interactive_commit", err); return true; } return false; @@ -65,6 +87,10 @@ fn loadCallback(_: ?*anyopaque, alloc: Allocator) LoadError!?[]u8 { return load(alloc); } +fn loadStoredCallback(_: ?*anyopaque, alloc: Allocator) LoadError!?[]u8 { + return loadStored(alloc); +} + fn storeCallback( _: ?*anyopaque, alloc: Allocator, @@ -77,109 +103,6 @@ fn storeInteractiveCallback(_: ?*anyopaque) StoreError!bool { return storeInteractive(); } -fn loadFromKeychain(alloc: Allocator) LoadError!?[]u8 { - return keychain.load(alloc) catch |err| switch (err) { - error.OutOfMemory => error.OutOfMemory, - error.KeychainItemNotFound => null, - else => error.StoredKeyUnreadable, - }; -} - -fn loadFromProfile(alloc: Allocator) LoadError!?[]u8 { - const home = io_mod.getenv("HOME") orelse { - debug_trace.logf("stored_key", "load failed step=home err=HomeNotSet", .{}); - return error.StoredKeyUnreadable; - }; - var home_dir = std.Io.Dir.openDirAbsolute(io_mod.getIo(), home, .{ .iterate = true }) catch |err| { - debug_trace.logf("stored_key", "load failed step=open_home err={s}", .{@errorName(err)}); - return error.StoredKeyUnreadable; - }; - defer home_dir.close(io_mod.getIo()); - - var fx_dir = home_dir.openDir(io_mod.getIo(), profile_paths.root_dir_name, .{ - .iterate = true, - .follow_symlinks = false, - }) catch |err| switch (err) { - error.FileNotFound => return null, - else => { - debug_trace.logf("stored_key", "load failed step=open_profile err={s}", .{@errorName(err)}); - return error.StoredKeyUnreadable; - }, - }; - defer fx_dir.close(io_mod.getIo()); - - return loadFromDir(alloc, &fx_dir); -} - -fn loadFromDir(alloc: Allocator, fx_dir: *std.Io.Dir) LoadError!?[]u8 { - var file = fx_dir.openFile(io_mod.getIo(), profile_paths.api_key_file_name, .{ - .mode = .read_only, - .allow_directory = false, - .follow_symlinks = false, - .resolve_beneath = true, - }) catch |err| switch (err) { - error.FileNotFound => return null, - else => { - debug_trace.logf("stored_key", "load failed step=open_file err={s}", .{@errorName(err)}); - return error.StoredKeyUnreadable; - }, - }; - defer file.close(io_mod.getIo()); - - const stat = file.stat(io_mod.getIo()) catch |err| { - debug_trace.logf("stored_key", "load failed step=stat err={s}", .{@errorName(err)}); - return error.StoredKeyUnreadable; - }; - if (stat.kind != .file or stat.permissions.toMode() & 0o077 != 0) { - debug_trace.logf("stored_key", "load failed step=permissions err=StoredKeyInsecure", .{}); - return error.StoredKeyInsecure; - } - - const bytes = io_mod.readFileToEnd(alloc, &file, max_key_file_bytes) catch |err| switch (err) { - error.OutOfMemory => return error.OutOfMemory, - else => { - debug_trace.logf("stored_key", "load failed step=read err={s}", .{@errorName(err)}); - return error.StoredKeyUnreadable; - }, - }; - var borrowed = false; - defer if (!borrowed) secret.zeroAndFree(alloc, bytes); - - const trimmed = std.mem.trim(u8, bytes, "\r\n"); - if (trimmed.len == 0) return null; - if (trimmed.len == bytes.len) { - borrowed = true; - return bytes; - } - return try alloc.dupe(u8, trimmed); -} - -fn storeInProfile(alloc: Allocator, value: []const u8) StoreError!void { - const home = io_mod.getenv("HOME") orelse return writeFailed("home", error.HomeNotSet); - var home_dir = io_mod.VerifiedDir{ - .dir = std.Io.Dir.openDirAbsolute(io_mod.getIo(), home, .{ .iterate = true }) catch |err| { - return writeFailed("open_home", err); - }, - }; - defer home_dir.close(); - - var fx_dir = io_mod.openOrCreateVerifiedPrivateDir(&home_dir, profile_paths.root_dir_name) catch |err| { - return writeFailed("open_profile", err); - }; - defer fx_dir.close(); - - return storeInDir(alloc, &fx_dir, value); -} - -/// `durableReplaceVerified` creates the file at 0600 and re-stats it after the rename, -/// so the mode this store depends on is enforced rather than assumed. -fn storeInDir(alloc: Allocator, fx_dir: *io_mod.VerifiedDir, value: []const u8) StoreError!void { - io_mod.durableReplaceVerified(alloc, fx_dir, profile_paths.api_key_file_name, value) catch |err| switch (err) { - error.OutOfMemory => return error.OutOfMemory, - else => return writeFailed("replace", err), - }; -} - fn writeFailed(step: []const u8, err: anyerror) StoreError { debug_trace.logf("stored_key", "store failed step={s} err={s}", .{ step, @errorName(err) }); return error.StoredKeyWriteFailed; @@ -194,68 +117,6 @@ test "stored key backend label names the platform store" { try std.testing.expectEqualStrings(backend_label, provider.backend_label); } -test "stored key file round-trips byte-identically at mode 0600" { - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - var fx_dir = io_mod.VerifiedDir{ - .dir = try tmp.dir.openDir(io_mod.getIo(), ".", .{ .iterate = true, .follow_symlinks = false }), - }; - defer fx_dir.close(); - - const written = "vt1-file-round-trip-value"; - try storeInDir(std.testing.allocator, &fx_dir, written); - - const stat = try tmp.dir.statFile(std.testing.io, profile_paths.api_key_file_name, .{}); - try std.testing.expect(stat.permissions.toMode() & 0o777 == 0o600); - - const read_back = (try loadFromDir(std.testing.allocator, &fx_dir.dir)) orelse - return error.TestUnexpectedMissingStoredKey; - defer secret.zeroAndFree(std.testing.allocator, read_back); - try std.testing.expectEqualStrings(written, read_back); -} - -test "stored key file refusal stays distinguishable from absence" { - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - var fx_dir = io_mod.VerifiedDir{ - .dir = try tmp.dir.openDir(io_mod.getIo(), ".", .{ .iterate = true, .follow_symlinks = false }), - }; - defer fx_dir.close(); - - try std.testing.expect((try loadFromDir(std.testing.allocator, &fx_dir.dir)) == null); - - try storeInDir(std.testing.allocator, &fx_dir, "vt2-secret-value"); - for ([_]std.posix.mode_t{ 0o640, 0o604, 0o644 }) |mode| { - var file = try tmp.dir.openFile(std.testing.io, profile_paths.api_key_file_name, .{ .mode = .read_write }); - try file.setPermissions(std.testing.io, std.Io.File.Permissions.fromMode(mode)); - file.close(std.testing.io); - - try std.testing.expectError( - error.StoredKeyInsecure, - loadFromDir(std.testing.allocator, &fx_dir.dir), - ); - } - - try tmp.dir.deleteFile(std.testing.io, profile_paths.api_key_file_name); - try std.testing.expect((try loadFromDir(std.testing.allocator, &fx_dir.dir)) == null); -} - -test "stored key file tolerates a trailing newline and rejects an empty value" { - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - var fx_dir = io_mod.VerifiedDir{ - .dir = try tmp.dir.openDir(io_mod.getIo(), ".", .{ .iterate = true, .follow_symlinks = false }), - }; - defer fx_dir.close(); - - try storeInDir(std.testing.allocator, &fx_dir, "hand-edited-value\n"); - const read_back = (try loadFromDir(std.testing.allocator, &fx_dir.dir)) orelse - return error.TestUnexpectedMissingStoredKey; - defer secret.zeroAndFree(std.testing.allocator, read_back); - try std.testing.expectEqualStrings("hand-edited-value", read_back); - - try storeInDir(std.testing.allocator, &fx_dir, "\n\n"); - try std.testing.expect((try loadFromDir(std.testing.allocator, &fx_dir.dir)) == null); - +test "stored key rejects an empty value" { try std.testing.expectError(error.StoredKeyWriteFailed, store(std.testing.allocator, "")); } diff --git a/tests/e2e/acp.test.ts b/tests/e2e/acp.test.ts index 6515e43ee..e104435d2 100644 --- a/tests/e2e/acp.test.ts +++ b/tests/e2e/acp.test.ts @@ -199,6 +199,7 @@ function fakeGatewayEnv( FX_GATEWAY_CHAT_URL: gateway.chatUrl, FX_MODEL: FAKE_GATEWAY_MODEL, FX_AUTO_UPGRADE: "0", + FX_DISABLE_KEYCHAIN: "1", }; } diff --git a/tests/e2e/auth-refresh.test.ts b/tests/e2e/auth-refresh.test.ts index 0dd55fff1..05372bdbe 100644 --- a/tests/e2e/auth-refresh.test.ts +++ b/tests/e2e/auth-refresh.test.ts @@ -200,7 +200,10 @@ test( ).toBe(0); expect(logoutResult.stdout).toBe("Signed out of fx.\n"); expect(tokenRequestCount).toBe(1); - expect(existsSync(join(home, ".fx", "auth.json"))).toBe(false); + const afterLogout = JSON.parse( + readFileSync(join(home, ".fx", "auth.json"), "utf8"), + ); + expect(afterLogout.credentials.fx_login).toBeUndefined(); const revocations = oauth.requests.filter( (request) => request.path === "/oauth/revoke", ); @@ -281,7 +284,9 @@ test( const persisted = JSON.parse( readFileSync(join(home, ".fx", "auth.json"), "utf8"), ); - expect(persisted.access_token).toBe(RETRY_REFRESH_TOKEN); + expect(persisted.credentials.fx_login.session.access_token).toBe( + RETRY_REFRESH_TOKEN, + ); expect(result.stdout).not.toContain(EXPIRED_REFRESH_TOKEN); expect(result.stdout).not.toContain(RETRY_REFRESH_TOKEN); expect(result.stderr).not.toContain(EXPIRED_REFRESH_TOKEN); @@ -569,7 +574,7 @@ test( const persisted = JSON.parse( readFileSync(join(home, ".fx", "auth.json"), "utf8"), ); - expect(persisted).toMatchObject({ + expect(persisted.credentials.fx_login.session).toMatchObject({ issuer: issuerA.issuerUrl, access_token: ISSUER_A_ACCESS_TOKEN, refresh_token: "rotated-refresh-token", diff --git a/tests/e2e/cli.test.ts b/tests/e2e/cli.test.ts index e221b5bb7..5b71fce1f 100644 --- a/tests/e2e/cli.test.ts +++ b/tests/e2e/cli.test.ts @@ -68,6 +68,20 @@ function doctorSessionDiagnosticsLimit(): number { } const SEEDED_GATEWAY_TOKEN = "seeded-access-token"; +const OAUTH_KEYCHAIN_SERVICE = "FX_OAUTH_SESSION_V1"; + +function commonCredential(home: string, source: string) { + const path = join(home, ".fx", "auth.json"); + if (!existsSync(path)) return undefined; + const parsed = JSON.parse(readFileSync(path, "utf8")); + return parsed.version === 2 ? parsed.credentials?.[source] : parsed; +} + +function fxLoginPresentAtPath(path: string): boolean { + if (!existsSync(path)) return false; + const parsed = JSON.parse(readFileSync(path, "utf8")); + return parsed.version === 2 ? parsed.credentials?.fx_login !== undefined : true; +} function writeSeededFxAuth( home: string, @@ -172,7 +186,7 @@ function startLogoutIssuer( typeof tokenTypeHint === "string" ? tokenTypeHint : "missing", validForm, ...(authPath - ? { localSessionPresent: existsSync(authPath) } + ? { localSessionPresent: fxLoginPresentAtPath(authPath) } : {}), }); const configuredStatus = revokeStatuses[revokeAttempt] ?? 200; @@ -1547,7 +1561,7 @@ describe("cli: logout", () => { expect(logout.code).toBe(0); expect(logout.stdout).toBe("Signed out of fx.\n"); expect(logout.stderr).toBe(""); - expect(existsSync(authPath)).toBe(false); + expect(commonCredential(home, "fx_login")).toBeUndefined(); expect(issuer.requests).toEqual([ { method: "GET", path: "/.well-known/openid-configuration" }, { @@ -1603,7 +1617,7 @@ describe("cli: logout", () => { expect(logout.stderr).toBe( "Warning: signed out locally, but the remote session could not be revoked.\n", ); - expect(existsSync(authPath)).toBe(false); + expect(commonCredential(home, "fx_login")).toBeUndefined(); expect(issuer.requests).toEqual([ { method: "GET", path: "/.well-known/openid-configuration" }, ]); @@ -1639,7 +1653,7 @@ describe("cli: logout", () => { expect(logout.stderr).toBe( "Warning: signed out locally, but the remote session could not be revoked.\n", ); - expect(existsSync(authPath)).toBe(false); + expect(commonCredential(home, "fx_login")).toBeUndefined(); expect(issuer.requests).toEqual([ { method: "GET", path: "/.well-known/openid-configuration" }, ]); @@ -1653,7 +1667,7 @@ describe("cli: logout", () => { ); test( - "fx logout removes a saved login rejected for unsafe permissions", + "fx logout fails closed for a common auth document with unsafe permissions", async () => { const home = mkdtempSync(join(tmpdir(), "fx-e2e-logout-rejected-login-")); const issuer = startLogoutIssuer([200, 200]); @@ -1670,10 +1684,13 @@ describe("cli: logout", () => { }, }); - expect(logout.code).toBe(0); - expect(logout.stdout).toBe("Signed out of fx.\n"); - expect(logout.stderr).toBe(""); - expect(existsSync(authPath)).toBe(false); + expect(logout.code).toBe(1); + expect(logout.stdout).toBe(""); + expect(logout.stderr).toBe( + "fx logout: failed to durably remove saved fx login\n" + + "Warning: signed out locally, but the remote session could not be revoked.\n", + ); + expect(existsSync(authPath)).toBe(true); expect(issuer.requests).toEqual([]); for (const secret of [ SEEDED_GATEWAY_TOKEN, @@ -1700,7 +1717,8 @@ describe("cli: logout", () => { const authPath = join(fxDir, "auth.json"); try { writeSeededFxAuth(home, undefined, issuer.issuerUrl); - chmodSync(fxDir, 0o500); + rmSync(authPath); + mkdirSync(authPath, { mode: 0o700 }); const env = { ...NO_GATEWAY_AUTH, @@ -1713,13 +1731,14 @@ describe("cli: logout", () => { expect(logout.code).toBe(1); expect(logout.stdout).toBe(""); expect(logout.stderr).toBe( - "fx logout: failed to durably remove saved fx login\n", + "fx logout: failed to durably remove saved fx login\n" + + "Warning: signed out locally, but the remote session could not be revoked.\n", ); expect(existsSync(authPath)).toBe(true); - expect(JSON.parse(status.stdout).auth).toBe("fx login"); + expect(JSON.parse(status.stdout).auth).toBe("missing"); expect(issuer.requests).toEqual([]); } finally { - chmodSync(fxDir, 0o700); + rmSync(authPath, { recursive: true, force: true }); issuer.stop(); rmSync(home, { recursive: true, force: true }); } @@ -1750,7 +1769,7 @@ describe("cli: logout", () => { expect(logout.stderr).toBe( "Warning: signed out locally, but the remote session could not be revoked.\n", ); - expect(existsSync(join(home, ".fx", "auth.json"))).toBe(false); + expect(commonCredential(home, "fx_login")).toBeUndefined(); expect(issuer.requests).toEqual([ { method: "GET", path: "/.well-known/openid-configuration" }, { @@ -1843,7 +1862,7 @@ describe("cli: logout", () => { ); test.skipIf(platform() !== "darwin")( - "fx logout leaves the macOS Keychain API key untouched", + "fx logout preserves the macOS Keychain API key in the common store", async () => { const runId = `${process.pid}-${Date.now()}`; const account = `fx-e2e-logout-${runId}`; @@ -1883,9 +1902,8 @@ describe("cli: logout", () => { expect(logout.code).toBe(0); expect(logout.stderr).toBe(""); expect(existsSync(join(home, ".fx", "auth.json"))).toBe(false); - expect(stored.status).toBe(0); - expect(stored.stdout.trim()).toBe(keychainToken); - expect(JSON.parse(status.stdout).auth).not.toBe("fx login"); + expect(stored.status).not.toBe(0); + expect(JSON.parse(status.stdout).auth).toBe("stored API key (macOS Keychain)"); expect(logout.stdout).not.toContain(keychainToken); expect(status.stdout).not.toContain(keychainToken); } finally { @@ -1895,6 +1913,11 @@ describe("cli: logout", () => { ["delete-generic-password", "-a", account, "-s", KEYCHAIN_SERVICE], { encoding: "utf8" }, ); + spawnSync( + "/usr/bin/security", + ["delete-generic-password", "-a", account, "-s", OAUTH_KEYCHAIN_SERVICE], + { encoding: "utf8" }, + ); rmSync(root, { recursive: true, force: true }); } }, @@ -1965,6 +1988,7 @@ describe("cli: stored key file backend", () => { mkdirSync(fxDir, { recursive: true, mode: 0o700 }); chmodSync(fxDir, 0o700); const keyPath = join(fxDir, "api-key"); + const authPath = join(fxDir, "auth.json"); writeFileSync(keyPath, "vca_file_backend_key", { mode: 0o600 }); chmodSync(keyPath, 0o600); const env = { ...NO_GATEWAY_AUTH, HOME: realpathSync(home) }; @@ -1976,6 +2000,8 @@ describe("cli: stored key file backend", () => { expect(readableJson.auth).toBe("stored API key (profile file)"); expect(readableJson.auth_help).toBeUndefined(); expect(readable.stdout).not.toContain("vca_file_backend_key"); + expect(existsSync(keyPath)).toBe(true); + expect(existsSync(authPath)).toBe(false); chmodSync(keyPath, 0o644); const refused = await runFx(["status", "--json"], { env }); @@ -2089,7 +2115,21 @@ describe("cli: Keychain authentication", () => { "Keychain ask complete", ); expect(result.stdout).not.toContain(fakeKey); - expect(existsSync(join(home, ".fx"))).toBe(false); + expect(existsSync(join(home, ".fx", "auth.json"))).toBe(false); + const legacy = spawnSync( + "/usr/bin/security", + ["find-generic-password", "-a", account, "-s", KEYCHAIN_SERVICE, "-w"], + { encoding: "utf8", env: { ...process.env, HOME: realpathSync(home), USER: account } }, + ); + expect(legacy.status).not.toBe(0); + const status = await runFx(["status", "--json"], { + env: { + ...NO_GATEWAY_AUTH, + HOME: realpathSync(home), + USER: account, + }, + }); + expect(JSON.parse(status.stdout).auth).toBe("stored API key (macOS Keychain)"); expect(gateway.requests).toHaveLength(1); expect(gateway.requests[0]!.headers.get("authorization")).toBe( `Bearer ${fakeKey}`, @@ -2107,6 +2147,17 @@ describe("cli: Keychain authentication", () => { ], { encoding: "utf8" }, ); + spawnSync( + "/usr/bin/security", + [ + "delete-generic-password", + "-a", + account, + "-s", + OAUTH_KEYCHAIN_SERVICE, + ], + { encoding: "utf8" }, + ); rmSync(root, { recursive: true, force: true }); } }, diff --git a/tests/e2e/oauth-keychain-migration.test.ts b/tests/e2e/oauth-keychain-migration.test.ts index 67297126e..a51e04c73 100644 --- a/tests/e2e/oauth-keychain-migration.test.ts +++ b/tests/e2e/oauth-keychain-migration.test.ts @@ -219,14 +219,8 @@ keychainTest( const first = await runFx(["status", "--json"], { env, timeoutMs: TIMEOUT }); expect(first.code, `stdout: ${first.stdout}\nstderr: ${first.stderr}`).toBe(0); expect(JSON.parse(first.stdout).auth).toBe("fx login"); - expect( - existsSync(join(home, ".fx", "auth.json")), - readFileSync(join(home, "oauth-keychain-trace.log"), "utf8"), - ).toBe(false); - - const stored = loadKeychainItem(account, home); - expect(stored).not.toBeNull(); - expect(JSON.parse(stored!).access_token).toBe(`keychain-access-${account}`); + expect(existsSync(join(home, ".fx", "auth.json"))).toBe(true); + expect(loadKeychainItem(account, home)).toBeNull(); const refreshed = await runFx( ["ask", "--json", "--no-save", "Refresh the saved login."], @@ -239,10 +233,17 @@ keychainTest( expect(JSON.parse(refreshed.stdout).output).toContain( "Keychain refresh complete", ); - expect(existsSync(join(home, ".fx", "auth.json"))).toBe(false); - expect(JSON.parse(loadKeychainItem(account, home)!).access_token).toBe( + expect( + existsSync(join(home, ".fx", "auth.json")), + readFileSync(join(home, "oauth-keychain-trace.log"), "utf8"), + ).toBe(false); + + const stored = loadKeychainItem(account, home); + expect(stored).not.toBeNull(); + expect(JSON.parse(stored!).credentials.fx_login.session.access_token).toBe( "keychain-refreshed-access", ); + expect(existsSync(join(home, ".fx", "auth.json"))).toBe(false); expect( issuer.requests.filter((request) => request.path === "/oauth/token"), ).toHaveLength(1); @@ -256,7 +257,7 @@ keychainTest( const logout = await runFx(["logout"], { env, timeoutMs: TIMEOUT }); expect(logout.code, `stdout: ${logout.stdout}\nstderr: ${logout.stderr}`).toBe(0); expect(logout.stdout).toBe("Signed out of fx.\n"); - expect(loadKeychainItem(account, home)).toBeNull(); + expect(JSON.parse(loadKeychainItem(account, home)!).credentials.fx_login).toBeUndefined(); expect(issuer.requests.filter((request) => request.path === "/oauth/revoke")).toHaveLength(2); } finally { cleanup(); @@ -279,6 +280,7 @@ keychainTest( const home = mkdtempSync(join(tmpdir(), "fx-oauth-keychain-failure-")); attachSystemKeychain(home); const issuer = startOAuthIssuer(); + const gateway = startFakeGateway([fakeGatewayFinalText("migration complete")]); const cleanup = () => deleteKeychainItem(account); activeCleanups.add(cleanup); cleanup(); @@ -286,8 +288,13 @@ keychainTest( let injectedFailureObserved = false; try { - const status = await runFx(["status", "--json"], { - env: keychainEnv(home, account, issuer.issuer), + const status = await runFx(["ask", "--json", "--no-save", "Migrate auth."], { + env: { + ...keychainEnv(home, account, issuer.issuer), + FX_GATEWAY_BASE_URL: gateway.baseUrl, + FX_GATEWAY_CHAT_URL: gateway.chatUrl, + FX_MODEL: FAKE_GATEWAY_MODEL, + }, timeoutMs: TIMEOUT, }); expect(status.code).toBe(0); @@ -299,6 +306,7 @@ keychainTest( } finally { cleanup(); activeCleanups.delete(cleanup); + gateway.stop(); issuer.stop(); } diff --git a/tests/e2e/tui-auth-source-selection.test.ts b/tests/e2e/tui-auth-source-selection.test.ts index bac87d5b9..6e58e0f20 100644 --- a/tests/e2e/tui-auth-source-selection.test.ts +++ b/tests/e2e/tui-auth-source-selection.test.ts @@ -165,6 +165,30 @@ function writeSeededGrokLogin(testHome: string, accessToken: string, accountId = chmodSync(authPath, 0o600); } +function readCommonAuth(testHome: string): { + version: number; + credentials: Record }>; +} { + return JSON.parse(readFileSync(join(testHome, ".fx", "auth.json"), "utf8")); +} + +function commonSession(testHome: string, source: "fx_login" | "chatgpt_subscription" | "grok_subscription") { + return readCommonAuth(testHome).credentials[source]?.session; +} + +function persistedSession( + testHome: string, + source: "fx_login" | "chatgpt_subscription" | "grok_subscription", + legacyFile: string, +) { + const commonPath = join(testHome, ".fx", "auth.json"); + if (existsSync(commonPath)) { + const session = commonSession(testHome, source); + if (session) return session; + } + return JSON.parse(readFileSync(join(testHome, ".fx", legacyFile), "utf8")); +} + function readSingleUsageSnapshot(testHome: string): { billing: string; next_sequence: number; @@ -1171,7 +1195,7 @@ tmuxTest( await session.waitForComposer(TIMEOUT); expect(session.isAlive()).toBe(true); - expect(existsSync(join(home, ".fx", "chatgpt-auth.json"))).toBe(false); + expect(existsSync(join(home, ".fx", "auth.json"))).toBe(false); expect(await session.captureFullScrollback()).not.toContain("Signed in with Codex."); expect(readFileSync(stderrPath, "utf8")).toBe(""); }, @@ -1212,9 +1236,10 @@ tmuxTest( await completeDisplayedCodexLogin(session, chatgptOauth); await session.waitForText("Switched to Codex subscription with gpt-5.6-sol.", TIMEOUT); - const authPath = join(home, ".fx", "chatgpt-auth.json"); + const authPath = join(home, ".fx", "auth.json"); expect(existsSync(authPath)).toBe(true); expect(statSync(authPath).mode & 0o077).toBe(0); + expect(commonSession(home, "chatgpt_subscription")).toBeDefined(); await session.sendText("/status"); await session.waitForText( @@ -1329,7 +1354,7 @@ tmuxTest( .toHaveLength(authorizeRequestsBeforeRoundTrip); await session.sendText("/logout codex"); await session.waitForText("Signed out of Codex.", TIMEOUT); - expect(existsSync(authPath)).toBe(false); + expect(commonSession(home, "chatgpt_subscription")).toBeUndefined(); await session.sendText("/status"); await session.waitForText("model_source=Codex subscription", TIMEOUT); chatgptOauth.setModels([ @@ -1791,7 +1816,7 @@ tmuxTest( await session.waitForText("auth=fx login", TIMEOUT); expect(savedCredentialSource(home)).toBe("fx_login"); - const savedAuth = JSON.parse(readFileSync(join(home, ".fx", "auth.json"), "utf8")) as { + const savedAuth = commonSession(home, "fx_login") as { team_id?: string; team_slug?: string; }; @@ -1855,12 +1880,13 @@ tmuxTest( await session.sendKeys("Enter"); await session.sendText("/status"); await session.waitForText("auth=fx login", TIMEOUT); - expect(readFileSync(authPath, "utf8")).toBe(seededAuthFile); + const migratedAuthFile = readFileSync(authPath, "utf8"); + expect(readCommonAuth(home).version).toBe(2); await session.sendText("use the selected login credential"); await session.waitForText(LOGIN_RESPONSE, TIMEOUT); expect(gateway.requests).toHaveLength(2); expect(gateway.requests[1].headers.get("authorization")).toBe(`Bearer ${LOGIN_TOKEN}`); - expect(readFileSync(authPath, "utf8")).toBe(seededAuthFile); + expect(readFileSync(authPath, "utf8")).toBe(migratedAuthFile); const firstRunOutput = await session.captureFullScrollback(); const firstRunStderr = readFileSync(stderrPath, "utf8"); @@ -1871,7 +1897,7 @@ tmuxTest( // The switch above is remembered, so the restart keeps fx login rather than // letting AI_GATEWAY_API_KEY reclaim it through precedence. await session.waitForText("auth=fx login", TIMEOUT); - expect(readFileSync(authPath, "utf8")).toBe(seededAuthFile); + expect(readFileSync(authPath, "utf8")).toBe(migratedAuthFile); await session.sendText("use the remembered credential after restart"); await session.waitForText(RESTART_RESPONSE, TIMEOUT); expect(gateway.requests).toHaveLength(3); @@ -1893,7 +1919,7 @@ tmuxTest( expect(oauth.requests[3].authorization).toBe(`Bearer ${ACQUIRED_LOGIN_TOKEN}`); expect(oauth.requests[1].clientId).toBe("test-client"); expect(oauth.requests[2].clientId).toBe("test-client"); - const acquiredAuth = JSON.parse(readFileSync(authPath, "utf8")) as { + const acquiredAuth = commonSession(home, "fx_login") as { issuer: string; client_id: string; access_token: string; @@ -1918,7 +1944,7 @@ tmuxTest( await session.sendText("/logout"); const loggedOut = await session.waitForText("Signed out of fx.", TIMEOUT); expect(loggedOut).not.toContain("remote session could not be revoked"); - expect(existsSync(authPath)).toBe(false); + expect(commonSession(home, "fx_login")).toBeUndefined(); expect( oauth.requests .filter((request) => request.path === "/oauth/revoke") @@ -2168,7 +2194,7 @@ test( expect(tokenRequests).toHaveLength(1); expect(tokenRequests[0].clientId).toBe(fallbackClientId); - const persisted = JSON.parse(readFileSync(authPath, "utf8")) as { + const persisted = commonSession(home, "fx_login") as { client_id: string; access_token: string; }; @@ -2207,9 +2233,10 @@ test( expect(login.stdout).not.toContain("Code:"); expect(login.stderr).toBe(""); - const authPath = join(home, ".fx", "chatgpt-auth.json"); + const authPath = join(home, ".fx", "auth.json"); expect(existsSync(authPath)).toBe(true); expect(statSync(authPath).mode & 0o077).toBe(0); + expect(commonSession(home, "chatgpt_subscription")).toBeDefined(); const settingsPath = join(home, ".fx", "settings.json"); const selected = JSON.parse(readFileSync(settingsPath, "utf8")); expect(selected.provider).toBe("codex"); @@ -2274,7 +2301,7 @@ test( const logout = await runFx(["logout", "codex"], { env, timeoutMs: TIMEOUT }); expect(logout.code).toBe(0); expect(logout.stdout).toContain("Signed out of Codex."); - expect(existsSync(authPath)).toBe(false); + expect(commonSession(home, "chatgpt_subscription")).toBeUndefined(); }, 60_000, ); @@ -2304,9 +2331,10 @@ test( expect(login.stdout).toContain("Signed in with Grok."); expect(login.stderr).toBe(""); - const authPath = join(home, ".fx", "grok-auth.json"); + const authPath = join(home, ".fx", "auth.json"); expect(existsSync(authPath)).toBe(true); expect(statSync(authPath).mode & 0o077).toBe(0); + expect(commonSession(home, "grok_subscription")).toBeDefined(); const settings = JSON.parse(readFileSync(join(home, ".fx", "settings.json"), "utf8")); expect(settings.provider).toBe("grok"); expect(settings.models.grok).toBe("grok-4.20"); @@ -2365,7 +2393,7 @@ test( expect(logout.code, `stdout: ${logout.stdout}\nstderr: ${logout.stderr}`).toBe(0); expect(logout.stdout).toContain("Signed out of Grok."); expect(grok.requests.some((request) => request.path === "/oauth2/revoke")).toBe(true); - expect(existsSync(authPath)).toBe(false); + expect(commonSession(home, "grok_subscription")).toBeUndefined(); } finally { grok.stop(); } @@ -2398,7 +2426,7 @@ test( expect(result.stdout).not.toContain("grok-code"); expect(result.stderr).toBe(""); expect(grok.tokenCalls()).toBe(1); - expect(existsSync(join(home, ".fx", "grok-auth.json"))).toBe(true); + expect(commonSession(home, "grok_subscription")).toBeDefined(); } finally { grok.stop(); } @@ -2416,7 +2444,6 @@ test("Grok logout removes local credentials when remote revocation fails", async JSON.stringify({ provider: "grok", grok_model: "grok-4.20" }) + "\n", { mode: 0o600 }, ); - const authPath = join(home, ".fx", "grok-auth.json"); const result = await runFx(["logout", "grok"], { env: { HOME: home, @@ -2429,7 +2456,7 @@ test("Grok logout removes local credentials when remote revocation fails", async expect(result.code, `stdout: ${result.stdout}\nstderr: ${result.stderr}`).toBe(0); expect(result.stdout).toContain("Signed out of Grok."); expect(result.stderr).toContain("remote revocation could not be confirmed"); - expect(existsSync(authPath)).toBe(false); + expect(commonSession(home, "grok_subscription")).toBeUndefined(); expect(JSON.parse(readFileSync(join(home, ".fx", "settings.json"), "utf8")).provider) .toBe("grok"); const ask = await runFx(["ask", "--json", "--no-save", "Still Grok?"], { @@ -2470,7 +2497,7 @@ test("Grok 401 replay refuses a different account before the second provider sen }); expect(ask.code).toBe(1); expect(grok.requests.filter((request) => request.path === "/v1/responses")).toHaveLength(1); - const saved = JSON.parse(readFileSync(join(home, ".fx", "grok-auth.json"), "utf8")) as { + const saved = persistedSession(home, "grok_subscription", "grok-auth.json") as { access_token: string; account_id: string; }; @@ -2665,7 +2692,7 @@ tmuxTest( const scrollback = await session.captureFullScrollback(); expect(scrollback).not.toContain("grok-code"); expect(grok.tokenCalls()).toBe(1); - expect(existsSync(join(home, ".fx", "grok-auth.json"))).toBe(true); + expect(commonSession(home, "grok_subscription")).toBeDefined(); expect(readFileSync(stderrPath, "utf8")).toBe(""); } finally { grok.stop(); @@ -2915,7 +2942,7 @@ test( expect(login.code).toBe(1); expect(login.stdout).not.toContain("Signed in with Codex."); expect(login.stderr).toContain("fx login: could not load the target model catalog (malformed_response)"); - expect(existsSync(join(home, ".fx", "chatgpt-auth.json"))).toBe(true); + expect(commonSession(home, "chatgpt_subscription")).toBeDefined(); const settingsPath = join(home, ".fx", "settings.json"); expect(existsSync(settingsPath)).toBe(false); }, @@ -2947,7 +2974,7 @@ test( expect(login.code).toBe(1); expect(login.stdout).not.toContain("Signed in with Grok."); expect(login.stderr).toContain("fx login: target model catalog is empty"); - expect(existsSync(join(home, ".fx", "grok-auth.json"))).toBe(true); + expect(commonSession(home, "grok_subscription")).toBeDefined(); expect(existsSync(join(home, ".fx", "settings.json"))).toBe(false); } finally { grok.stop(); @@ -3065,7 +3092,7 @@ test( ); test( - "saved provider switching publishes Gateway, Codex, and Grok usage to one profile ledger", + "saved provider switching uses one common auth document and one profile ledger", async () => { home = mkdtempSync(join(tmpdir(), "fx-provider-usage-ledger-")); const workspace = join(home, "workspace"); @@ -3118,11 +3145,12 @@ test( 5, ); try { + writeSeededFxLogin(home); writeSeededChatGptLogin(home, chatgptAccessToken("acct_usage")); writeSeededGrokLogin(home, "grok-usage-token", "acct_usage"); const env = { HOME: home, - AI_GATEWAY_API_KEY: "gateway-usage-key", + AI_GATEWAY_API_KEY: undefined, VERCEL_OIDC_TOKEN: undefined, FX_DISABLE_KEYCHAIN: "1", FX_AUTO_UPGRADE: "0", @@ -3177,6 +3205,13 @@ test( expect(gateway.requests).toHaveLength(1); expect(codex.responses).toBe(1); expect(grok.responses).toBe(1); + const auth = readCommonAuth(home); + expect(auth.version).toBe(2); + expect(auth.credentials.fx_login?.session).toBeDefined(); + expect(auth.credentials.chatgpt_subscription?.session).toBeDefined(); + expect(auth.credentials.grok_subscription?.session).toBeDefined(); + expect(existsSync(join(home, ".fx", "chatgpt-auth.json"))).toBe(false); + expect(existsSync(join(home, ".fx", "grok-auth.json"))).toBe(false); } finally { codex.stop(); grok.stop(); @@ -3495,7 +3530,7 @@ tmuxTest( expect( loggedOut.match(/remote session could not be revoked/g) ?? [], ).toHaveLength(1); - expect(existsSync(join(home, ".fx", "auth.json"))).toBe(false); + expect(commonSession(home, "fx_login")).toBeUndefined(); await session.sendText("/status"); await session.waitForText("auth=AI_GATEWAY_API_KEY", TIMEOUT); @@ -3552,7 +3587,7 @@ tmuxTest( await session.waitForComposer(TIMEOUT); await session.sendText("/logout"); await session.waitForText("Signed out of fx.", TIMEOUT); - expect(existsSync(join(home, ".fx", "auth.json"))).toBe(false); + expect(commonSession(home, "fx_login")).toBeUndefined(); await session.sendText("/status"); await session.waitForText("auth=AI_GATEWAY_API_KEY", TIMEOUT); @@ -3566,7 +3601,7 @@ tmuxTest( ); tmuxTest( - "logout removes an fx login rejected for unsafe permissions", + "logout fails closed for a common auth document with unsafe permissions", async () => { home = mkdtempSync(join(tmpdir(), "fx-tui-logout-rejected-login-")); stderrPath = join(home, "stderr.log"); @@ -3580,8 +3615,12 @@ tmuxTest( session = await startFx(home, stderrPath, gateway, oauth.issuerUrl); await session.waitForComposer(TIMEOUT); await session.sendText("/logout"); - const loggedOut = await session.waitForText("Signed out of fx.", TIMEOUT); - expect(existsSync(authPath)).toBe(false); + const failed = await session.waitForText( + "Could not confirm durable fx logout. The active source was recalculated.", + TIMEOUT, + ); + expect(failed).not.toContain("Signed out of fx."); + expect(existsSync(authPath)).toBe(true); await session.sendText("/status"); await session.waitForText("auth=AI_GATEWAY_API_KEY", TIMEOUT); @@ -3592,7 +3631,7 @@ tmuxTest( "seeded-refresh-token", oauth.providerDetail, ]) { - expect(loggedOut).not.toContain(secret); + expect(failed).not.toContain(secret); } }, 60_000, From e88be6968796da2a4e13baa792a825070fe47b25 Mon Sep 17 00:00:00 2001 From: Pranit Date: Mon, 31 Aug 2026 16:47:10 -0400 Subject: [PATCH 02/12] Update stored key E2E for common auth document --- tests/e2e/tui-auth-source-selection.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/e2e/tui-auth-source-selection.test.ts b/tests/e2e/tui-auth-source-selection.test.ts index 6e58e0f20..a7fd3a8db 100644 --- a/tests/e2e/tui-auth-source-selection.test.ts +++ b/tests/e2e/tui-auth-source-selection.test.ts @@ -1693,9 +1693,10 @@ profileStoredKeyTmuxTest( await session.waitForText("auth=stored API key (profile file)", TIMEOUT); expect(savedCredentialSource(home)).toBe("stored_key"); - const keyPath = join(home, ".fx", "api-key"); - expect(readFileSync(keyPath, "utf8")).toBe(STORED_TOKEN); - expect(statSync(keyPath).mode & 0o777).toBe(0o600); + const authPath = join(home, ".fx", "auth.json"); + expect(readCommonAuth(home).credentials.stored_key?.secret).toBe(STORED_TOKEN); + expect(statSync(authPath).mode & 0o777).toBe(0o600); + expect(existsSync(join(home, ".fx", "api-key"))).toBe(false); await session.kill(); session = await startFx(home, stderrPath, gateway, undefined, undefined, { From 1ab450412a6ce5f8ee09773e94175b9382f85ef3 Mon Sep 17 00:00:00 2001 From: Pranit Date: Mon, 31 Aug 2026 17:32:26 -0400 Subject: [PATCH 03/12] Migrate active subscription reads to common auth store --- src/core/hosts/native_auth_store.zig | 125 ++++++++++++++++++++++----- tests/e2e/acp.test.ts | 16 ++++ 2 files changed, 120 insertions(+), 21 deletions(-) diff --git a/src/core/hosts/native_auth_store.zig b/src/core/hosts/native_auth_store.zig index 64e2fd395..3ac899a62 100644 --- a/src/core/hosts/native_auth_store.zig +++ b/src/core/hosts/native_auth_store.zig @@ -214,11 +214,37 @@ pub const EntryMutation = struct { .macos_keychain => try observe_keychain_profile(alloc, &self.profile.fx_dir.dir, self.keychain), }; defer observation.deinit(alloc); + try self.migrate_observation(alloc, &observation); const document = observation.document orelse return null; const value = document.get(self.source) orelse return null; return try alloc.dupe(u8, value); } + fn migrate_observation( + self: *EntryMutation, + alloc: Allocator, + observation: *ProfileObservation, + ) !void { + switch (auth_store.decide_load(observation.state, .active)) { + .missing, .use_current => return, + .migrate_legacy => {}, + .use_legacy, .reject_current => unreachable, + } + const document = observation.document orelse return error.InvalidAuthDocument; + const migrated = switch (self.backend) { + .profile_file => publish_profile_migration(&self.profile, alloc, document), + .macos_keychain => publish_keychain_migration( + alloc, + &self.profile.fx_dir.dir, + document, + self.keychain, + ), + }; + if (migrated) { + observation.state = .current; + } + } + pub fn save(self: *EntryMutation, alloc: Allocator, value: []const u8) !void { var observation = switch (self.backend) { .profile_file => try observe_profile(alloc, &self.profile.fx_dir.dir), @@ -350,6 +376,21 @@ fn commit_and_verify( if (!document.eql(verified)) return error.AuthDocumentWriteMismatch; } +fn publish_profile_migration( + mutation: *ProfileMutation, + alloc: Allocator, + document: auth_store.Document, +) bool { + commit_and_verify(mutation, alloc, document) catch |err| { + debug_trace.logf("auth", "common auth migration deferred backend=profile step=publish err={s}", .{@errorName(err)}); + return false; + }; + delete_legacy_profile_files(&mutation.fx_dir.dir) catch |err| { + debug_trace.logf("auth", "common auth migration cleanup incomplete backend=profile err={s}", .{@errorName(err)}); + }; + return true; +} + fn load_profile_document( alloc: Allocator, home: []const u8, @@ -373,13 +414,7 @@ fn load_profile_document( .use_current => observation.take_document(), .migrate_legacy => migrated: { const document = observation.document orelse return error.InvalidAuthDocument; - commit_and_verify(&mutation, alloc, document) catch |err| { - debug_trace.logf("auth", "common auth migration deferred backend=profile step=publish err={s}", .{@errorName(err)}); - break :migrated observation.take_document(); - }; - delete_legacy_profile_files(&mutation.fx_dir.dir) catch |err| { - debug_trace.logf("auth", "common auth migration cleanup incomplete backend=profile err={s}", .{@errorName(err)}); - }; + _ = publish_profile_migration(&mutation, alloc, document); break :migrated observation.take_document(); }, .use_legacy, .reject_current => unreachable, @@ -427,17 +462,7 @@ fn load_keychain_document( .use_current => observation.take_document(), .migrate_legacy => migrated: { const document = observation.document orelse return error.InvalidAuthDocument; - commit_and_verify_keychain(alloc, document, keychain) catch |err| { - debug_trace.logf("auth", "common auth migration deferred backend=keychain step=publish err={s}", .{@errorName(err)}); - break :migrated observation.take_document(); - }; - delete_keychain_legacy_profile_files(&mutation.fx_dir.dir) catch |err| { - debug_trace.logf("auth", "common auth migration cleanup incomplete backend=keychain source=profile err={s}", .{@errorName(err)}); - }; - _ = keychain.delete_api_key(alloc) catch |err| failed: { - debug_trace.logf("auth", "common auth migration cleanup incomplete backend=keychain source=stored_key err={s}", .{@errorName(err)}); - break :failed false; - }; + _ = publish_keychain_migration(alloc, &mutation.fx_dir.dir, document, keychain); break :migrated observation.take_document(); }, .use_legacy, .reject_current => unreachable, @@ -563,6 +588,26 @@ fn commit_and_verify_keychain( if (!document.eql(verified)) return error.AuthDocumentWriteMismatch; } +fn publish_keychain_migration( + alloc: Allocator, + fx_dir: *std.Io.Dir, + document: auth_store.Document, + keychain: KeychainBackend, +) bool { + commit_and_verify_keychain(alloc, document, keychain) catch |err| { + debug_trace.logf("auth", "common auth migration deferred backend=keychain step=publish err={s}", .{@errorName(err)}); + return false; + }; + delete_keychain_legacy_profile_files(fx_dir) catch |err| { + debug_trace.logf("auth", "common auth migration cleanup incomplete backend=keychain source=profile err={s}", .{@errorName(err)}); + }; + _ = keychain.delete_api_key(alloc) catch |err| failed: { + debug_trace.logf("auth", "common auth migration cleanup incomplete backend=keychain source=stored_key err={s}", .{@errorName(err)}); + break :failed false; + }; + return true; +} + fn observe_profile(alloc: Allocator, fx_dir: *std.Io.Dir) !ProfileObservation { const auth_bytes = try read_optional_private_file( alloc, @@ -905,6 +950,40 @@ test "source mutations share one document without losing unrelated credentials" )) == null); } +test "active entry mutation migrates an unexpired legacy subscription" { + const alloc = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const home = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "."); + defer alloc.free(home); + + var verified_home = io_mod.VerifiedDir{ + .dir = try tmp.dir.openDir(std.testing.io, ".", .{ .iterate = true }), + }; + defer verified_home.close(); + var fx_dir = try io_mod.openOrCreateVerifiedPrivateDir(&verified_home, profile_paths.root_dir_name); + defer fx_dir.close(); + const codex = + "{\"version\":1,\"access_token\":\"codex-access\",\"refresh_token\":\"codex-refresh\",\"expires_at_ms\":4102444800000,\"account_id\":\"codex-account\"}"; + try io_mod.durableReplaceVerified(alloc, &fx_dir, profile_paths.chatgpt_auth_file_name, codex); + + var mutation = try begin_profile_entry_mutation(home, .chatgpt_subscription); + defer mutation.deinit(); + const loaded = (try mutation.load(alloc)) orelse return error.TestExpectedLegacyCredential; + defer secret.zeroAndFree(alloc, loaded); + + try std.testing.expectEqualStrings(codex, loaded); + const persisted = try read_profile_file(alloc, home, profile_paths.auth_file_name); + defer secret.zeroAndFree(alloc, persisted); + var document = try auth_store.Document.parse(alloc, persisted); + defer document.deinit(alloc); + try std.testing.expectEqualStrings(codex, document.get(.chatgpt_subscription).?); + try std.testing.expectError( + error.FileNotFound, + fx_dir.dir.statFile(std.testing.io, profile_paths.chatgpt_auth_file_name, .{}), + ); +} + test "Keychain migration publishes every source before removing legacy credentials" { const alloc = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); @@ -968,11 +1047,15 @@ test "Keychain publication failure keeps legacy credentials authoritative" { var fake = FakeKeychain{ .alloc = alloc, .fail_store = true }; defer fake.deinit(); - var loaded = (try load_keychain_document(alloc, home, .active, fake.backend())) orelse + var mutation = try begin_profile_entry_mutation(home, .chatgpt_subscription); + defer mutation.deinit(); + mutation.backend = .macos_keychain; + mutation.keychain = fake.backend(); + const loaded = (try mutation.load(alloc)) orelse return error.TestExpectedLegacyDocument; - defer loaded.deinit(alloc); + defer secret.zeroAndFree(alloc, loaded); - try std.testing.expectEqualStrings(codex, loaded.get(.chatgpt_subscription).?); + try std.testing.expectEqualStrings(codex, loaded); try std.testing.expect(fake.document == null); _ = try fx_dir.dir.statFile(std.testing.io, profile_paths.chatgpt_auth_file_name, .{}); } diff --git a/tests/e2e/acp.test.ts b/tests/e2e/acp.test.ts index e104435d2..d50c285c4 100644 --- a/tests/e2e/acp.test.ts +++ b/tests/e2e/acp.test.ts @@ -8645,6 +8645,14 @@ describe.skipIf(!HAS_API_KEY)("acp: model-backed protocol", () => { const prompt = await runPrompt(client, "Answer directly.", TIMEOUT); expect(prompt.promptResult.result.stopReason).toBe("end_turn"); expect(JSON.stringify(prompt.messages)).toContain("ACP_CHATGPT_RESPONSE"); + const commonAuth = JSON.parse( + readFileSync(join(root.home, ".fx", "auth.json"), "utf8"), + ); + expect(commonAuth.version).toBe(2); + expect(commonAuth.credentials.chatgpt_subscription.session.account_id).toBe( + "acct_acp_e2e", + ); + expect(existsSync(join(root.home, ".fx", "chatgpt-auth.json"))).toBe(false); const secondPrompt = await runPrompt(client, "Answer again.", TIMEOUT); expect(secondPrompt.promptResult.result.stopReason).toBe("end_turn"); expect(codex.requests).toHaveLength(3); @@ -8701,6 +8709,14 @@ describe.skipIf(!HAS_API_KEY)("acp: model-backed protocol", () => { const prompt = await runPrompt(client, "Answer directly.", TIMEOUT); expect(prompt.promptResult.result.stopReason).toBe("end_turn"); expect(JSON.stringify(prompt.messages)).toContain("ACP_GROK_RESPONSE"); + const commonAuth = JSON.parse( + readFileSync(join(root.home, ".fx", "auth.json"), "utf8"), + ); + expect(commonAuth.version).toBe(2); + expect(commonAuth.credentials.grok_subscription.session.account_id).toBe( + "acct_grok_acp", + ); + expect(existsSync(join(root.home, ".fx", "grok-auth.json"))).toBe(false); const secondPrompt = await runPrompt(client, "Answer again.", TIMEOUT); expect(secondPrompt.promptResult.result.stopReason).toBe("end_turn"); From 545adebfd4b8208fc848d66f3ef9be60766fa5fd Mon Sep 17 00:00:00 2001 From: Pranit Date: Tue, 1 Sep 2026 01:49:52 -0400 Subject: [PATCH 04/12] Support host-managed provider authentication Allow embedding hosts to own provider credentials while fx omits authentication-owned headers. Keep existing local authentication unchanged unless FX_AUTH_MODE=host-managed. --- src/acp/prompt.zig | 7 +- src/acp/server.zig | 152 ++++++---- src/builtins/gateway.zig | 44 +-- src/builtins/gateway/permission_reviewer.zig | 27 +- src/core/agent/runtime/gateway_step.zig | 31 ++- src/core/agent/runtime/image_provider.zig | 13 +- src/core/agent/runtime/orchestrator.zig | 17 +- src/core/agent/runtime/tests/support.zig | 2 +- src/core/agent/stream_provider.zig | 18 +- src/core/app/app_auth_runtime.zig | 192 ++++++++----- src/core/app/app_bootstrap_runtime.zig | 4 + src/core/app/app_entry_runtime.zig | 19 +- src/core/app/app_lifecycle.zig | 139 ++++++++-- src/core/app/model_cache_runtime.zig | 4 +- src/core/auth/auth_runtime.zig | 86 +++++- src/core/auth/credential_authority.zig | 2 + src/core/auth/credentials.zig | 96 ++++++- src/core/cli/acp_runner.zig | 2 + src/core/cli/cli_ask.zig | 131 +++++---- src/core/cli/cli_surface.zig | 141 +++++++--- src/core/config/model_provider.zig | 1 + .../session/generation_usage_provider.zig | 23 +- src/core/session/session_codec.zig | 2 +- src/core/session/session_usage.zig | 100 +++++-- src/core/shared/types.zig | 15 + src/core/tooling/tool_runtime.zig | 13 +- src/gateway/client.zig | 88 ++++-- src/gateway/host_stream_provider.zig | 19 +- src/gateway/openai_codex.zig | 74 +++-- src/gateway/openai_codex_models.zig | 85 ++++-- src/gateway/responses_permission_reviewer.zig | 43 ++- src/gateway/xai_grok.zig | 91 ++++-- src/gateway/xai_grok_models.zig | 80 ++++-- src/main.zig | 37 ++- src/ui/footer/model_menu_presentation.zig | 1 + tests/e2e/acp.test.ts | 36 +++ tests/e2e/host-managed-auth.test.ts | 261 ++++++++++++++++++ 37 files changed, 1617 insertions(+), 479 deletions(-) create mode 100644 tests/e2e/host-managed-auth.test.ts diff --git a/src/acp/prompt.zig b/src/acp/prompt.zig index 5f8127569..de3827bc5 100644 --- a/src/acp/prompt.zig +++ b/src/acp/prompt.zig @@ -606,7 +606,12 @@ pub fn handlePrompt( ); if (comptime @import("builtin").os.tag != .wasi) { if (state.cfg.provider_set.select(session.provider).deferred_usage != null) { - if (session.credential_source) |source| { + if (session.credential_source == .host_managed) { + session.session_rt.usage.replaceHostManagedReconciliationAuthority( + alloc, + session.provider, + ); + } else if (session.credential_source) |source| { session.session_rt.usage.replaceProviderReconciliationCredential( alloc, session.provider, diff --git a/src/acp/server.zig b/src/acp/server.zig index 967ced77b..2cef1380e 100644 --- a/src/acp/server.zig +++ b/src/acp/server.zig @@ -335,6 +335,15 @@ pub fn selectCredentialForProvider( state: *ServerState, provider: model_provider.ProviderId, ) !bool { + if (state.cfg.auth_mode == .host_managed) { + state.credential_source = .host_managed; + if (state.active_session) |*active| { + active.credential_source = .host_managed; + active.api_key = &.{}; + active.account_id = null; + } + return true; + } if (state.active_session) |active| { if (credentialMatchesProvider(active.credential_source, provider)) return true; } @@ -1271,21 +1280,24 @@ fn parseInitializeRequest( fn loadConfiguredStartupState(state: *const ServerState, alloc: Allocator) !app_lifecycle.StartupState { if (state.cfg.home_override) |home_dir| { if (state.cfg.workspace_root_override) |workspace_root| { - return app_lifecycle.loadEmbeddedStartupState( + var startup = try app_lifecycle.loadEmbeddedStartupState( alloc, home_dir, workspace_root, state.cfg.default_model, state.cfg.default_agent_step_limit, ); + startup.auth_mode = state.cfg.auth_mode; + return startup; } } - return app_lifecycle.loadStartupState( + return app_lifecycle.loadStartupStateWithAuthMode( alloc, state.cfg.gateway_provider.oauth_transport, state.cfg.secret_store, state.cfg.default_model, state.cfg.default_agent_step_limit, + state.cfg.auth_mode, ); } @@ -1345,34 +1357,53 @@ fn handleInitialize(state: *ServerState, alloc: Allocator, msg: *jsonrpc.Message state.provider = startup.provider; state.configured_model = try alloc.dupe(u8, startup.configured_model); - var startup_credential = startup.takeCredential(); - defer if (startup_credential) |*credential| credential.deinit(alloc); - var routed_credential: ?credentials.Credential = null; - defer if (routed_credential) |*credential| credential.deinit(alloc); - const startup_matches_model = if (startup_credential) |credential| - credentialMatchesProvider(credential.source, state.provider) - else - false; - const credential: *credentials.Credential = if (state.provider == .gateway and state.cfg.credential_override != null) override: { - routed_credential = .{ - .token = try alloc.dupe(u8, state.cfg.credential_override.?), - .source = .ai_gateway_api_key, + if (state.cfg.auth_mode == .host_managed) { + state.api_key = &.{}; + state.credential_source = .host_managed; + state.account_id = null; + state.gateway_team = null; + } else { + var startup_credential = startup.takeCredential(); + defer if (startup_credential) |*credential| credential.deinit(alloc); + var routed_credential: ?credentials.Credential = null; + defer if (routed_credential) |*credential| credential.deinit(alloc); + const startup_matches_model = if (startup_credential) |credential| + credentialMatchesProvider(credential.source, state.provider) + else + false; + const credential: *credentials.Credential = if (state.provider == .gateway and state.cfg.credential_override != null) override: { + routed_credential = .{ + .token = try alloc.dupe(u8, state.cfg.credential_override.?), + .source = .ai_gateway_api_key, + }; + break :override &routed_credential.?; + } else if (startup_matches_model) + &startup_credential.? + else routed: { + const preferred = if (startup_credential) |value| value.source else null; + const resolution = try credentials.resolveForProvider( + alloc, + state.cfg.gateway_provider.oauth_transport, + state.cfg.secret_store, + .refresh_if_needed, + state.provider, + preferred, + ); + routed_credential = resolution.credential; + if (routed_credential == null) { + return state.writer.writeError(alloc, msg.id, .{ + .code = ErrorCode.invalid_request, + .message = if (state.provider == .codex) + credentials.missing_chatgpt_credential_message + else if (state.provider == .grok) + credentials.missing_grok_credential_message + else + credentials.missing_credential_message, + }); + } + break :routed &routed_credential.?; }; - break :override &routed_credential.?; - } else if (startup_matches_model) - &startup_credential.? - else routed: { - const preferred = if (startup_credential) |value| value.source else null; - const resolution = try credentials.resolveForProvider( - alloc, - state.cfg.gateway_provider.oauth_transport, - state.cfg.secret_store, - .refresh_if_needed, - state.provider, - preferred, - ); - routed_credential = resolution.credential; - if (routed_credential == null) { + if (credential.token.len == 0) { return state.writer.writeError(alloc, msg.id, .{ .code = ErrorCode.invalid_request, .message = if (state.provider == .codex) @@ -1383,20 +1414,8 @@ fn handleInitialize(state: *ServerState, alloc: Allocator, msg: *jsonrpc.Message credentials.missing_credential_message, }); } - break :routed &routed_credential.?; - }; - if (credential.token.len == 0) { - return state.writer.writeError(alloc, msg.id, .{ - .code = ErrorCode.invalid_request, - .message = if (state.provider == .codex) - credentials.missing_chatgpt_credential_message - else if (state.provider == .grok) - credentials.missing_grok_credential_message - else - credentials.missing_credential_message, - }); + adoptServerCredential(state, credential); } - adoptServerCredential(state, credential); state.permission_mode = startup.permission_mode; state.permission_rules = startup.takePermissionRules(); @@ -1426,12 +1445,15 @@ fn handleInitialize(state: *ServerState, alloc: Allocator, msg: *jsonrpc.Message state.alloc, startup_catalog, .{ - .access = credentials.catalogAccessForCredentialAndAccount( - state.credential_source, - state.api_key, - state.gateway_team, - state.account_id, - ), + .access = if (state.cfg.auth_mode == .host_managed) + .host_managed + else + credentials.catalogAccessForCredentialAndAccount( + state.credential_source, + state.api_key, + state.gateway_team, + state.account_id, + ), .endpoint = state.cfg.gateway_models_path, .cancel_flag = &catalog_cancel_flag, }, @@ -1638,7 +1660,9 @@ fn handleSetConfigOption(state: *ServerState, alloc: Allocator, msg: *jsonrpc.Me .message = "Subscription provider switching is unavailable in this WASM runtime", }); } - var staged_credential = if (target == .gateway and state.cfg.credential_override != null) + var staged_credential: ?credentials.Credential = if (state.cfg.auth_mode == .host_managed) + null + else if (target == .gateway and state.cfg.credential_override != null) credentials.Credential{ .token = try alloc.dupe(u8, state.cfg.credential_override.?), .source = .ai_gateway_api_key, @@ -1663,24 +1687,27 @@ fn handleSetConfigOption(state: *ServerState, alloc: Allocator, msg: *jsonrpc.Me credentials.missing_credential_message, }); }; - defer staged_credential.deinit(alloc); - if (!model_provider.authorizesCredential(target, staged_credential.source)) { + defer if (staged_credential) |*credential| credential.deinit(alloc); + if (staged_credential) |credential| if (!model_provider.authorizesCredential(target, credential.source)) { return state.writer.writeError(alloc, msg.id, .{ .code = ErrorCode.invalid_request, .message = "Credential cannot authorize the selected provider", }); - } + }; const catalog_provider = catalogProviderFor(state, target) orelse return state.writer.writeError(alloc, msg.id, .{ .code = ErrorCode.invalid_request, .message = "Selected provider is unavailable in this host", }); - const access = credentials.catalogAccessForCredentialAndAccount( - staged_credential.source, - staged_credential.token, - staged_credential.gatewayTeam(), - staged_credential.accountId(), - ); + const access: credentials.CatalogAccess = if (state.cfg.auth_mode == .host_managed) + .host_managed + else + credentials.catalogAccessForCredentialAndAccount( + staged_credential.?.source, + staged_credential.?.token, + staged_credential.?.gatewayTeam(), + staged_credential.?.accountId(), + ); const fetched = try catalog_provider.fetch(alloc, .{ .access = access, .endpoint = state.cfg.gateway_models_path, @@ -1732,7 +1759,14 @@ fn handleSetConfigOption(state: *ServerState, alloc: Allocator, msg: *jsonrpc.Me }); }; state.capability_resolver.adoptOwnedCatalog(alloc, &catalog); - adoptServerCredential(state, &staged_credential); + if (staged_credential) |*credential| { + adoptServerCredential(state, credential); + } else { + state.credential_source = .host_managed; + session.credential_source = .host_managed; + session.api_key = &.{}; + session.account_id = null; + } } } else if (std.mem.eql(u8, config_id, "mode")) { if (state.active_session) |*session| { diff --git a/src/builtins/gateway.zig b/src/builtins/gateway.zig index 813fff6a3..b1fd33f2d 100644 --- a/src/builtins/gateway.zig +++ b/src/builtins/gateway.zig @@ -517,7 +517,8 @@ fn streamAgentCompletion( alloc: Allocator, request: agent_stream_provider_contract.ModelRequest, ) anyerror!agent_stream_provider_contract.Result { - if (request.credential.source == .chatgpt_subscription or request.credential.source == .grok_subscription) { + const credential_source = request.credential.credentialSource(); + if (credential_source == .chatgpt_subscription or credential_source == .grok_subscription) { return agent_stream_provider_contract.failResult( error.SubscriptionCredentialCannotAuthorizeGateway, ); @@ -528,8 +529,8 @@ fn streamAgentCompletion( const result = gateway_client.streamGatewayCompletion( alloc, .{ - .api_key = request.credential.secret, - .team = request.credential.tenant, + .api_key = request.credential.secret(), + .team = request.credential.tenant(), .session_id = request.session_id, .model = request.model, .retry_count = request.retry_count, @@ -595,17 +596,17 @@ fn gatewayUsageReference( completion: shared_types.ModelCompletion, ) ?agent_stream_provider_contract.DeferredUsageReference { const generation_id = completion.generation_id orelse return null; - const source = request.credential.source orelse return null; + const source = request.credential.credentialSource(); return .{ .provider = .gateway, .generation_id = generation_id, .scope = gateway_client.generationBaseUrl(), - .tenant = request.credential.tenant, - .account_id = request.credential.account_id, + .tenant = request.credential.tenant(), + .account_id = request.credential.accountId(), .credential_source = source, .credential_identity = credential_authority.derive( source, - request.credential.account_id, + request.credential.accountId(), ), }; } @@ -885,7 +886,7 @@ fn executeWebSearchProvider( progress_ctx: ?*anyopaque, ) !Response { return executeGatewayWorker(alloc, .{ - .api_key = inputs.api_key, + .api_key = if (inputs.credential_source == .host_managed) null else inputs.api_key, .credential_source = inputs.credential_source, .team = inputs.gateway_team, .model = inputs.worker_model, @@ -963,7 +964,7 @@ pub const StreamFn = *const fn ( var default_stream_ctx: u8 = 0; pub const GatewayWorkerConfig = struct { - api_key: []const u8, + api_key: ?[]const u8, credential_source: ?shared_types.CredentialSource = null, team: ?[]const u8 = null, model: []const u8, @@ -991,7 +992,9 @@ pub fn executeGatewayWorker( on_progress: ?ProgressFn, progress_ctx: ?*anyopaque, ) !Response { - if (config.api_key.len == 0 or config.model.len == 0 or config.chat_url.len == 0) { + if ((config.api_key == null and config.credential_source != .host_managed) or + config.model.len == 0 or config.chat_url.len == 0) + { return error.MissingGatewaySearchConfiguration; } if (request.cancel_flag.load(.seq_cst)) return error.Cancelled; @@ -1021,7 +1024,7 @@ pub fn executeGatewayWorker( var stream = config.stream_fn( config.stream_ctx, alloc, - config.api_key, + config.api_key orelse "", config.team, config.model, @max(config.retry_count, 1), @@ -1051,11 +1054,18 @@ pub fn executeGatewayWorker( } if (!builtin.is_test and stream.status == .ok and std.meta.activeTag(usage_outcome) == .deferred) { if (config.usage) |ledger| { - ledger.startDeferredReconciliation( - config.usage_allocator, - usage_outcome.deferred, - config.api_key, - ); + if (config.api_key) |api_key| { + ledger.startDeferredReconciliation( + config.usage_allocator, + usage_outcome.deferred, + api_key, + ); + } else if (config.credential_source == .host_managed) { + ledger.startHostManagedDeferredReconciliation( + config.usage_allocator, + usage_outcome.deferred, + ); + } } } if (stream.status != .ok) return error.GatewayRequestFailed; @@ -1159,7 +1169,7 @@ fn streamGatewayWorker( return gateway_client.streamGatewayProviderToolCompletionBounded( alloc, .{ - .api_key = api_key, + .api_key = if (api_key.len > 0) api_key else null, .team = team, .model = model, .retry_count = request_retry_count, diff --git a/src/builtins/gateway/permission_reviewer.zig b/src/builtins/gateway/permission_reviewer.zig index cebf6f7a9..75052de1b 100644 --- a/src/builtins/gateway/permission_reviewer.zig +++ b/src/builtins/gateway/permission_reviewer.zig @@ -29,7 +29,7 @@ const StreamFn = *const fn ( var default_stream_ctx: u8 = 0; const GatewayConfig = struct { - api_key: []const u8, + api_key: ?[]const u8, credential_source: ?types.CredentialSource = null, team: ?[]const u8 = null, chat_url: []const u8, @@ -49,7 +49,7 @@ fn reviewGateway( request: permission_auto_classifier.ReviewRequest, ) anyerror!permission_auto_classifier.ParseOutcome { return reviewGatewayConfig(.{ - .api_key = input.credential, + .api_key = if (input.credential_source == .host_managed) null else input.credential, .credential_source = input.credential_source, .team = input.tenant, .chat_url = input.endpoint, @@ -123,7 +123,7 @@ fn sendGatewayReview( .{ model, single_transport_attempt }, ); if (cancel_flag.load(.seq_cst)) return .cancelled; - if (config.api_key.len == 0 or config.chat_url.len == 0) { + if ((config.api_key == null and config.credential_source != .host_managed) or config.chat_url.len == 0) { debug_trace.logf("permission", "event=auto_review_transport result=permanent_failure reason=missing_gateway_config", .{}); return .permanent_failure; } @@ -140,7 +140,7 @@ fn sendGatewayReview( var stream = config.stream_fn( config.stream_ctx, alloc, - config.api_key, + config.api_key orelse "", config.team, model, single_transport_attempt, @@ -182,11 +182,18 @@ fn sendGatewayReview( return .permanent_failure; }; if (stream.status == .ok and std.meta.activeTag(usage_outcome) == .deferred) if (config.usage) |ledger| { - ledger.startDeferredReconciliation( - config.usage_allocator, - usage_outcome.deferred, - config.api_key, - ); + if (config.api_key) |api_key| { + ledger.startDeferredReconciliation( + config.usage_allocator, + usage_outcome.deferred, + api_key, + ); + } else if (config.credential_source == .host_managed) { + ledger.startHostManagedDeferredReconciliation( + config.usage_allocator, + usage_outcome.deferred, + ); + } }; if (cancel_flag.load(.seq_cst)) { @@ -285,7 +292,7 @@ fn streamGatewayReviewer( return gateway_client.streamGatewayRequiredToolCompletionBounded( alloc, .{ - .api_key = api_key, + .api_key = if (api_key.len > 0) api_key else null, .team = team, .model = model, .retry_count = retry_count, diff --git a/src/core/agent/runtime/gateway_step.zig b/src/core/agent/runtime/gateway_step.zig index 6fd65e040..5eb4dc51e 100644 --- a/src/core/agent/runtime/gateway_step.zig +++ b/src/core/agent/runtime/gateway_step.zig @@ -85,11 +85,18 @@ pub fn streamModelCompletion( ); if (comptime @import("builtin").os.tag != .wasi) { if (std.meta.activeTag(completed.usage) == .deferred) if (usage) |ledger| { - ledger.startDeferredReconciliation( - usage_allocator, - completed.usage.deferred, - request.credential.secret, - ); + if (request.credential.secret()) |credential| { + ledger.startDeferredReconciliation( + usage_allocator, + completed.usage.deferred, + credential, + ); + } else if (request.credential.credentialSource() == .host_managed) { + ledger.startHostManagedDeferredReconciliation( + usage_allocator, + completed.usage.deferred, + ); + } }; } }, @@ -269,7 +276,7 @@ test "provider preflight failure does not reserve usage" { agent_stream_provider.unavailable_provider, alloc, .{ - .credential = .{ .secret = "test-key" }, + .credential = .{ .direct = .{ .secret_bytes = "test-key" } }, .model = "test/model", .retry_count = 1, .messages = &.{}, @@ -337,7 +344,7 @@ test "caller admission publishes before provider attempt is admitted" { .{ .context = &provider, .stream_fn = Provider.stream }, alloc, .{ - .credential = .{ .secret = "test-key" }, + .credential = .{ .direct = .{ .secret_bytes = "test-key" } }, .model = "test/model", .retry_count = 1, .messages = &.{}, @@ -405,7 +412,7 @@ test "caller admission failure settles usage and prevents request open" { .{ .context = &provider, .stream_fn = Provider.stream }, alloc, .{ - .credential = .{ .secret = "test-key" }, + .credential = .{ .direct = .{ .secret_bytes = "test-key" } }, .model = "test/model", .retry_count = 1, .messages = &.{}, @@ -462,7 +469,7 @@ test "possibly sent gateway failure marks billing incomplete" { .{ .stream_fn = Gateway.stream }, alloc, .{ - .credential = .{ .secret = "test-key" }, + .credential = .{ .direct = .{ .secret_bytes = "test-key" } }, .model = "test/model", .retry_count = 1, .messages = &.{}, @@ -542,11 +549,11 @@ test "provider-local exact usage reaches session accounting" { provider, alloc, .{ - .credential = .{ - .secret = "subscription-token", + .credential = .{ .direct = .{ + .secret_bytes = "subscription-token", .source = .chatgpt_subscription, .account_id = "acct_test", - }, + } }, .session_id = "session-test", .model = "gpt-test", .retry_count = 1, diff --git a/src/core/agent/runtime/image_provider.zig b/src/core/agent/runtime/image_provider.zig index a686721d8..43fcfda9e 100644 --- a/src/core/agent/runtime/image_provider.zig +++ b/src/core/agent/runtime/image_provider.zig @@ -59,11 +59,14 @@ pub fn inspect( request.stream_provider, alloc, .{ - .credential = .{ - .secret = request.api_key, - .source = request.credential_source, - .tenant = request.gateway_team, - }, + .credential = if (request.credential_source == .host_managed) + .host_managed + else + .{ .direct = .{ + .secret_bytes = request.api_key, + .source = request.credential_source orelse .ai_gateway_api_key, + .tenant_context = request.gateway_team, + } }, .session_id = request.session_id, .model = model, .retry_count = request.retry_count, diff --git a/src/core/agent/runtime/orchestrator.zig b/src/core/agent/runtime/orchestrator.zig index 3f781744f..061ab011a 100644 --- a/src/core/agent/runtime/orchestrator.zig +++ b/src/core/agent/runtime/orchestrator.zig @@ -3704,12 +3704,15 @@ fn processQueuedPromptLoop( within_turn_suffix.items, ); var model_request = agent_stream_provider.ModelRequest{ - .credential = .{ - .secret = active_api_key, - .source = job.credential_source, - .account_id = job.account_id, - .tenant = job.gateway_team, - }, + .credential = if (job.credential_source == .host_managed) + .host_managed + else + .{ .direct = .{ + .secret_bytes = active_api_key, + .source = job.credential_source orelse .ai_gateway_api_key, + .account_id = job.account_id, + .tenant_context = job.gateway_team, + } }, .session_id = lifecycle.scope.session_id, .model = gateway_model, .retry_count = config.gateway_retry_count, @@ -4101,7 +4104,7 @@ fn processQueuedPromptLoop( auth_retry_used = true; var replay_delivery = runtime_gateway_step.DeliveryCertainty.init(); var replay_evidence: runtime_gateway_step.AttemptEvidence = .{}; - model_request.credential.secret = active_api_key; + model_request.credential.direct.secret_bytes = active_api_key; model_request.delivery = &replay_delivery; model_request.attempt_evidence = &replay_evidence; stream_result = try runtime_gateway_step.streamModelCompletion( diff --git a/src/core/agent/runtime/tests/support.zig b/src/core/agent/runtime/tests/support.zig index 20ce770c5..c2103716f 100644 --- a/src/core/agent/runtime/tests/support.zig +++ b/src/core/agent/runtime/tests/support.zig @@ -268,7 +268,7 @@ pub const FakeGateway = struct { defer alloc.free(payload); try self.request_bodies.append(self.alloc, try self.alloc.dupe(u8, payload)); try self.request_models.append(self.alloc, try self.alloc.dupe(u8, request.model)); - try self.request_api_keys.append(self.alloc, try self.alloc.dupe(u8, request.credential.secret)); + try self.request_api_keys.append(self.alloc, try self.alloc.dupe(u8, request.credential.secret() orelse "")); const session_id = if (request.session_id) |id| try self.alloc.dupe(u8, id) else null; errdefer if (session_id) |id| self.alloc.free(id); try self.request_session_ids.append(self.alloc, session_id); diff --git a/src/core/agent/stream_provider.zig b/src/core/agent/stream_provider.zig index a97f70260..42f9a8249 100644 --- a/src/core/agent/stream_provider.zig +++ b/src/core/agent/stream_provider.zig @@ -7,6 +7,7 @@ const tool_dispatch = @import("../tooling/tool_dispatch.zig"); const model_tool_schema = @import("../tooling/model_tool_schema.zig"); const model_provider = @import("../config/model_provider.zig"); const credential_authority = @import("../auth/credential_authority.zig"); +const credentials = @import("../auth/credentials.zig"); const Allocator = std.mem.Allocator; @@ -148,12 +149,15 @@ pub const ToolSelection = struct { } }; -pub const CredentialLease = struct { - secret: []const u8, - source: ?types.CredentialSource = null, - account_id: ?[]const u8 = null, - tenant: ?[]const u8 = null, -}; +pub const CredentialLease = credentials.RequestAuth; + +test "host-managed credential lease exposes no secret or account metadata" { + const lease: CredentialLease = .host_managed; + try std.testing.expect(lease.secret() == null); + try std.testing.expect(lease.accountId() == null); + try std.testing.expect(lease.tenant() == null); + try std.testing.expectEqual(types.CredentialSource.host_managed, lease.credentialSource()); +} /// Pure provider input used by request serializers and permission reviewers. /// Every slice and JSON value is borrowed for the call. @@ -391,7 +395,7 @@ test "stream provider accepts one typed request and emits ordered neutral events .context = &fake, .stream_fn = Fake.stream, }).stream(std.testing.allocator, .{ - .credential = .{ .secret = "key" }, + .credential = .{ .direct = .{ .secret_bytes = "key" } }, .model = "model", .retry_count = 1, .messages = &.{}, diff --git a/src/core/app/app_auth_runtime.zig b/src/core/app/app_auth_runtime.zig index 3cfa808ff..e48a66e72 100644 --- a/src/core/app/app_auth_runtime.zig +++ b/src/core/app/app_auth_runtime.zig @@ -49,6 +49,27 @@ fn selectCatalogModel( return if (entries.len > 0) entries[0].id else null; } +fn optionalGatewayApiKey(credential: anytype) ?[]const u8 { + if (comptime @typeInfo(@TypeOf(credential.api_key)) == .optional) { + return credential.api_key; + } + return credential.api_key; +} + +fn gatewayCredentialSource(credential: anytype) ?credentials.Source { + if (comptime @hasField(@TypeOf(credential), "source")) { + return credential.source; + } + return null; +} + +fn hostManagesAuth(app: anytype) bool { + if (comptime @hasDecl(@TypeOf(app.auth), "isHostManaged")) { + return app.auth.isHostManaged(); + } + return false; +} + pub fn Runtime(comptime App: type) type { return struct { fn ensurePromptCredential(app: *App) !bool { @@ -100,6 +121,10 @@ pub fn Runtime(comptime App: type) type { } pub fn runLoginCommand(app: *App) !void { + if (hostManagesAuth(app)) { + try writeAuthNotice(app, .{ .topic = "auth", .tone = .neutral, .body = credentials.host_managed_auth_message }); + return; + } if (comptime !oauthAuthEnabled(App)) { try app.writeDomainNotice(.{ .topic = "auth", @@ -118,6 +143,10 @@ pub fn Runtime(comptime App: type) type { } pub fn runLogoutCommand(app: *App, target: []const u8) !void { + if (hostManagesAuth(app)) { + try writeAuthNotice(app, .{ .topic = "auth", .tone = .neutral, .body = credentials.host_managed_auth_message }); + return; + } if (comptime !oauthAuthEnabled(App)) { try app.writeDomainNotice(.{ .topic = "auth", @@ -215,6 +244,10 @@ pub fn Runtime(comptime App: type) type { } pub fn openSetupHub(app: *App) !void { + if (hostManagesAuth(app)) { + try writeAuthNotice(app, .{ .topic = "auth", .tone = .neutral, .body = credentials.host_managed_auth_message }); + return; + } if (comptime !runtime_profile.allows(App, .native_auth)) { try app.writeDomainNotice(.{ .topic = "auth", @@ -771,69 +804,75 @@ pub fn Runtime(comptime App: type) type { } try app.flushBeforeBlockingExternalWork(); - const resolution = credentials.resolveForProvider( - app.alloc, - app.auth.oauthTransport(), - app.auth.secretStore(), - .refresh_if_needed, - target, - null, - ) catch |err| { - debug_trace.logf("provider", "credential preparation failed provider={t} err={s}", .{ target, @errorName(err) }); - try app.writeDomainNotice(.{ - .topic = "provider", - .tone = .@"error", - .body = providerFailureMessage( - intent, - "Could not prepare the target provider credential. The current provider is unchanged.", - "Subscription sign-in completed, but its credential could not be prepared. The current provider is unchanged.", - ), - }, true); - return; - }; - var credential = resolution.credential orelse { - if (target == .codex and allow_login) { - try beginCodexSignInForProviderSwitch(app); + var credential: ?credentials.Credential = null; + defer if (credential) |*value| value.deinit(app.alloc); + if (!hostManagesAuth(app)) { + const resolution = credentials.resolveForProvider( + app.alloc, + app.auth.oauthTransport(), + app.auth.secretStore(), + .refresh_if_needed, + target, + null, + ) catch |err| { + debug_trace.logf("provider", "credential preparation failed provider={t} err={s}", .{ target, @errorName(err) }); + try app.writeDomainNotice(.{ + .topic = "provider", + .tone = .@"error", + .body = providerFailureMessage( + intent, + "Could not prepare the target provider credential. The current provider is unchanged.", + "Subscription sign-in completed, but its credential could not be prepared. The current provider is unchanged.", + ), + }, true); return; - } - if (target == .grok and allow_login) { - try beginGrokSignInForProviderSwitch(app); + }; + credential = resolution.credential orelse { + if (target == .codex and allow_login) { + try beginCodexSignInForProviderSwitch(app); + return; + } + if (target == .grok and allow_login) { + try beginGrokSignInForProviderSwitch(app); + return; + } + try app.writeDomainNotice(.{ + .topic = "provider", + .tone = .warning, + .body = if (intent == .post_oauth) + "Subscription sign-in completed, but its saved credential is unavailable. The current provider is unchanged." + else if (target == .codex) + "Run fx login codex, then try switching again." + else if (target == .grok) + "Run fx login grok, then try switching again." + else + credentials.missing_interactive_credential_message, + }, true); + return; + }; + if (!model_provider.authorizesCredential(target, credential.?.source)) { + try app.writeDomainNotice(.{ + .topic = "provider", + .tone = .@"error", + .body = providerFailureMessage( + intent, + "The target credential cannot authorize that provider. The current provider is unchanged.", + "Subscription sign-in completed, but its credential cannot authorize the provider. The current provider is unchanged.", + ), + }, true); return; } - try app.writeDomainNotice(.{ - .topic = "provider", - .tone = .warning, - .body = if (intent == .post_oauth) - "Subscription sign-in completed, but its saved credential is unavailable. The current provider is unchanged." - else if (target == .codex) - "Run fx login codex, then try switching again." - else if (target == .grok) - "Run fx login grok, then try switching again." - else - credentials.missing_interactive_credential_message, - }, true); - return; - }; - defer credential.deinit(app.alloc); - if (!model_provider.authorizesCredential(target, credential.source)) { - try app.writeDomainNotice(.{ - .topic = "provider", - .tone = .@"error", - .body = providerFailureMessage( - intent, - "The target credential cannot authorize that provider. The current provider is unchanged.", - "Subscription sign-in completed, but its credential cannot authorize the provider. The current provider is unchanged.", - ), - }, true); - return; } - const access = credentials.catalogAccessForCredentialAndAccount( - credential.source, - credential.token, - credential.gatewayTeam(), - credential.accountId(), - ); + const access: credentials.CatalogAccess = if (hostManagesAuth(app)) + .host_managed + else + credentials.catalogAccessForCredentialAndAccount( + credential.?.source, + credential.?.token, + credential.?.gatewayTeam(), + credential.?.accountId(), + ); const fetched = app.fetchProviderCatalog(target, access) catch |err| { debug_trace.logf("provider", "catalog preparation failed provider={t} err={s}", .{ target, @errorName(err) }); try app.writeDomainNotice(.{ @@ -919,7 +958,7 @@ pub fn Runtime(comptime App: type) type { app.model_cache.adoptOwnedCatalog(access, &catalog); app.provider_selection.adoptOwned(target, &owned_model); - _ = app.auth.adoptCredential(app.alloc, &credential); + if (credential) |*value| _ = app.auth.adoptCredential(app.alloc, value); reconcileGatewayCredential(app); const body = try std.fmt.allocPrint( @@ -1162,6 +1201,15 @@ pub fn Runtime(comptime App: type) type { @hasField(@TypeOf(app.session), "usage")) { if (app.auth.gatewayCredential()) |credential| { + if (gatewayCredentialSource(credential) == .host_managed) { + if (comptime @hasDecl(@TypeOf(app.session.usage), "replaceHostManagedReconciliationAuthority")) { + app.session.usage.replaceHostManagedReconciliationAuthority( + app.alloc, + provider_runtime.provider(app), + ); + } + return; + } const subscription = if (comptime @hasField(@TypeOf(credential), "source")) credential.source == .chatgpt_subscription or credential.source == .grok_subscription else @@ -1173,18 +1221,22 @@ pub fn Runtime(comptime App: type) type { @TypeOf(app.session.usage), "replaceProviderReconciliationCredential", )) { - app.session.usage.replaceProviderReconciliationCredential( - app.alloc, - .gateway, - credential.source, - null, - credential.api_key, - ); + if (optionalGatewayApiKey(credential)) |api_key| { + app.session.usage.replaceProviderReconciliationCredential( + app.alloc, + .gateway, + credential.source, + null, + api_key, + ); + } } else { - app.session.usage.replaceReconciliationCredential( - app.alloc, - credential.api_key, - ); + if (optionalGatewayApiKey(credential)) |api_key| { + app.session.usage.replaceReconciliationCredential( + app.alloc, + api_key, + ); + } } } } else { diff --git a/src/core/app/app_bootstrap_runtime.zig b/src/core/app/app_bootstrap_runtime.zig index f1c15ef33..696cba1d0 100644 --- a/src/core/app/app_bootstrap_runtime.zig +++ b/src/core/app/app_bootstrap_runtime.zig @@ -198,6 +198,10 @@ pub fn Runtime(comptime App: type) type { app.secretStore() else host.unavailable_secret_store, + .auth_mode = if (comptime @hasDecl(@TypeOf(app.auth), "authMode")) + app.auth.authMode() + else + .local, .resize_handler = resize_handler, .fx_version = App.app_version, }); diff --git a/src/core/app/app_entry_runtime.zig b/src/core/app/app_entry_runtime.zig index e81696686..07b75e099 100644 --- a/src/core/app/app_entry_runtime.zig +++ b/src/core/app/app_entry_runtime.zig @@ -5,6 +5,7 @@ const app_session_runtime = @import("app_session_runtime.zig"); const auto_upgrade = @import("../upgrade/auto_upgrade.zig"); const acp_runner = @import("../cli/acp_runner.zig"); const cli_surface = @import("../cli/cli_surface.zig"); +const credentials = @import("../auth/credentials.zig"); const background_process_provider = @import( "../execution/background_process_provider.zig", ); @@ -39,6 +40,7 @@ pub const Config = struct { version: []const u8 = "", revision: []const u8 = "", build_channel: update_target.Channel = .stable, + auth_mode: credentials.AuthMode = .local, command_catalog: command_specs.TopLevelRegistry, default_model: []const u8, default_agent_step_limit: usize, @@ -134,7 +136,7 @@ fn runWithDeps(comptime App: type, alloc: Allocator, args: []const [:0]const u8, .exit => |code| return .{ .exit = code }, } - return runInteractiveWithDeps(App, false, alloc, &launch, deps); + return runInteractiveWithDeps(App, false, alloc, &launch, cfg.auth_mode, deps); } pub fn runBeforeInteractive(alloc: Allocator, args: []const [:0]const u8, cfg: Config) !BeforeInteractiveResult { @@ -193,23 +195,23 @@ fn benchEnabled() bool { return io_mod.getenv("FX_BENCH") != null; } -pub fn runInteractive(comptime App: type, alloc: Allocator, launch: *cli_surface.InteractiveLaunch) !RunOutcome { - return runInteractiveWithDeps(App, false, alloc, launch, .{}); +pub fn runInteractive(comptime App: type, alloc: Allocator, launch: *cli_surface.InteractiveLaunch, auth_mode: credentials.AuthMode) !RunOutcome { + return runInteractiveWithDeps(App, false, alloc, launch, auth_mode, .{}); } /// Runs the interactive product without native CLI dispatch, process replacement, /// or a worker thread. Single-threaded hosts must arrange cooperative prompt work. -pub fn runInteractiveCooperative(comptime App: type, alloc: Allocator, launch: *cli_surface.InteractiveLaunch) !RunOutcome { - return runInteractiveWithDeps(App, true, alloc, launch, .{}); +pub fn runInteractiveCooperative(comptime App: type, alloc: Allocator, launch: *cli_surface.InteractiveLaunch, auth_mode: credentials.AuthMode) !RunOutcome { + return runInteractiveWithDeps(App, true, alloc, launch, auth_mode, .{}); } fn unavailableCliDispatch(_: ?*anyopaque, _: Allocator, _: []const [:0]const u8, _: cli_surface.Config) anyerror!cli_surface.RunResult { return error.UnknownCliCommand; } -fn runInteractiveWithDeps(comptime App: type, comptime cooperative: bool, alloc: Allocator, launch: *cli_surface.InteractiveLaunch, deps: RunDeps) !RunOutcome { +fn runInteractiveWithDeps(comptime App: type, comptime cooperative: bool, alloc: Allocator, launch: *cli_surface.InteractiveLaunch, auth_mode: credentials.AuthMode, deps: RunDeps) !RunOutcome { const resume_requested = launch.requested_resume != null; - var app = App.init(alloc, launch) catch |err| { + var app = App.init(alloc, launch, auth_mode) catch |err| { switch (err) { error.NotATerminal => { writeStderr(deps, "fx requires an interactive terminal (TTY).\n"); @@ -379,6 +381,7 @@ fn cliSurfaceConfig(cfg: Config) cli_surface.Config { .version = cfg.version, .revision = cfg.revision, .build_channel = cfg.build_channel, + .auth_mode = cfg.auth_mode, .command_catalog = cfg.command_catalog, .default_model = cfg.default_model, .default_agent_step_limit = cfg.default_agent_step_limit, @@ -695,7 +698,7 @@ const TestApp = struct { requested_resume: ?cli_surface.ResumeTarget = null, terminal_released: bool = false, - fn init(_: Allocator, launch: *cli_surface.InteractiveLaunch) !TestApp { + fn init(_: Allocator, launch: *cli_surface.InteractiveLaunch, _: credentials.AuthMode) !TestApp { appendInitEvent(launch); if (active_capture.?.init_error) |err| return err; diff --git a/src/core/app/app_lifecycle.zig b/src/core/app/app_lifecycle.zig index 81fd92bc2..e882a7886 100644 --- a/src/core/app/app_lifecycle.zig +++ b/src/core/app/app_lifecycle.zig @@ -117,6 +117,7 @@ pub const StartupState = struct { workspace_root: []u8 = &.{}, workspace_access: workspace_access.WorkspaceAccess = .{}, credential: ?credentials.Credential = null, + auth_mode: credentials.AuthMode = .local, credential_onboarding_skipped: bool = false, stored_key_status: credentials.StoredKeyReadStatus = .not_attempted, provider: model_provider.ProviderId = .gateway, @@ -183,6 +184,7 @@ pub const StartupState = struct { } pub fn modelCatalogAccess(self: *const StartupState) credentials.CatalogAccess { + if (self.auth_mode == .host_managed) return .host_managed; return credentials.catalogAccessAt(self.credential, io_mod.milliTimestamp()); } @@ -250,6 +252,7 @@ pub const BootstrapConfig = struct { default_model: []const u8, default_agent_step_limit: usize, secret_store: host.SecretStore, + auth_mode: credentials.AuthMode = .local, resize_handler: ResizeHandler, fx_version: []const u8 = "", }; @@ -260,14 +263,32 @@ pub fn loadStartupState( secret_store: host.SecretStore, default_model: []const u8, default_agent_step_limit: usize, +) !StartupState { + return loadStartupStateWithAuthMode( + alloc, + transport, + secret_store, + default_model, + default_agent_step_limit, + .local, + ); +} + +pub fn loadStartupStateWithAuthMode( + alloc: Allocator, + transport: oauth_transport.Provider, + secret_store: host.SecretStore, + default_model: []const u8, + default_agent_step_limit: usize, + auth_mode: credentials.AuthMode, ) !StartupState { const workspace_root = try io_mod.realpathAlloc(alloc, "."); - return loadStartupStateFromOwnedWorkspace(alloc, transport, secret_store, workspace_root, default_model, default_agent_step_limit, null, .refresh_if_needed); + return loadStartupStateFromOwnedWorkspace(alloc, transport, secret_store, workspace_root, default_model, default_agent_step_limit, auth_mode, null, .refresh_if_needed); } pub fn loadStartupStateWithoutCredentials(alloc: Allocator, default_model: []const u8, default_agent_step_limit: usize) !StartupState { const workspace_root = try io_mod.realpathAlloc(alloc, "."); - return loadStartupStateFromOwnedWorkspace(alloc, oauth_transport.unavailable_provider, host.unavailable_secret_store, workspace_root, default_model, default_agent_step_limit, null, null); + return loadStartupStateFromOwnedWorkspace(alloc, oauth_transport.unavailable_provider, host.unavailable_secret_store, workspace_root, default_model, default_agent_step_limit, .local, null, null); } pub fn loadEmbeddedStartupState( @@ -285,6 +306,7 @@ pub fn loadEmbeddedStartupState( owned_workspace_root, default_model, default_agent_step_limit, + .local, home_dir, null, ); @@ -295,9 +317,25 @@ pub fn loadCatalogStartupState( secret_store: host.SecretStore, default_model: []const u8, default_agent_step_limit: usize, +) !StartupState { + return loadCatalogStartupStateWithAuthMode( + alloc, + secret_store, + default_model, + default_agent_step_limit, + .local, + ); +} + +pub fn loadCatalogStartupStateWithAuthMode( + alloc: Allocator, + secret_store: host.SecretStore, + default_model: []const u8, + default_agent_step_limit: usize, + auth_mode: credentials.AuthMode, ) !StartupState { const workspace_root = try io_mod.realpathAlloc(alloc, "."); - return loadStartupStateFromOwnedWorkspace(alloc, oauth_transport.unavailable_provider, secret_store, workspace_root, default_model, default_agent_step_limit, null, .stored); + return loadStartupStateFromOwnedWorkspace(alloc, oauth_transport.unavailable_provider, secret_store, workspace_root, default_model, default_agent_step_limit, auth_mode, null, .stored); } pub fn loadStartupStatus( @@ -305,6 +343,22 @@ pub fn loadStartupStatus( secret_store: host.SecretStore, default_model: []const u8, default_agent_step_limit: usize, +) !StartupStatus { + return loadStartupStatusWithAuthMode( + alloc, + secret_store, + default_model, + default_agent_step_limit, + .local, + ); +} + +pub fn loadStartupStatusWithAuthMode( + alloc: Allocator, + secret_store: host.SecretStore, + default_model: []const u8, + default_agent_step_limit: usize, + auth_mode: credentials.AuthMode, ) !StartupStatus { const workspace_root = try io_mod.realpathAlloc(alloc, "."); errdefer alloc.free(workspace_root); @@ -318,12 +372,20 @@ pub fn loadStartupStatus( const selected_model = try loadStartupStatusModel(alloc, configured_selection.model, null); errdefer if (selected_model.owned) |model| alloc.free(model); - var auth_status = try auth_runtime.loadStatusSnapshotForProvider( - alloc, - secret_store, - configured_selection.provider, - settings.credential_source, - ); + var auth_status = if (auth_mode == .host_managed) + auth_runtime.StatusSnapshot{ + .active_source = .host_managed, + .gateway_connected = true, + .chatgpt_connected = true, + .grok_connected = true, + } + else + try auth_runtime.loadStatusSnapshotForProvider( + alloc, + secret_store, + configured_selection.provider, + settings.credential_source, + ); errdefer auth_status.deinit(alloc); const result = StartupStatus{ @@ -358,7 +420,7 @@ pub fn applyWorkspaceLaunch( fn loadStartupStateForWorkspace(alloc: Allocator, workspace_root: []const u8, default_model: []const u8, default_agent_step_limit: usize) !StartupState { const owned_workspace_root = try alloc.dupe(u8, workspace_root); - return loadStartupStateFromOwnedWorkspace(alloc, oauth_transport.unavailable_provider, host.unavailable_secret_store, owned_workspace_root, default_model, default_agent_step_limit, null, null); + return loadStartupStateFromOwnedWorkspace(alloc, oauth_transport.unavailable_provider, host.unavailable_secret_store, owned_workspace_root, default_model, default_agent_step_limit, .local, null, null); } const CredentialLoadMode = credentials.LoadMode; @@ -370,10 +432,14 @@ fn loadStartupStateFromOwnedWorkspace( owned_workspace_root: []u8, default_model: []const u8, default_agent_step_limit: usize, + auth_mode: credentials.AuthMode, profile_home: ?[]const u8, credential_mode: ?CredentialLoadMode, ) !StartupState { - var state = StartupState{ .agent_step_limit = default_agent_step_limit }; + var state = StartupState{ + .agent_step_limit = default_agent_step_limit, + .auth_mode = auth_mode, + }; errdefer state.deinit(alloc); state.workspace_root = owned_workspace_root; @@ -403,17 +469,19 @@ fn loadStartupStateFromOwnedWorkspace( detailed.diagnostics = &.{}; state.prompt_history_enabled = settings.prompt_history_enabled orelse true; state.prompt_history_store_allowed = detailed.prompt_history_store_allowed; - if (credential_mode) |mode| { - const resolution = try credentials.resolveForProvider( - alloc, - transport, - secret_store, - mode, - state.provider, - settings.credential_source, - ); - state.credential = resolution.credential; - state.stored_key_status = resolution.stored_key_status; + if (auth_mode == .local) { + if (credential_mode) |mode| { + const resolution = try credentials.resolveForProvider( + alloc, + transport, + secret_store, + mode, + state.provider, + settings.credential_source, + ); + state.credential = resolution.credential; + state.stored_key_status = resolution.stored_key_status; + } } state.permission_mode = loadPermissionMode(settings.permission_mode); state.yolo_acknowledged = settings.yolo_acknowledged orelse false; @@ -454,15 +522,16 @@ pub fn bootstrapInteractiveApp(cfg: BootstrapConfig) !StartupState { cfg.shell.layout = minimalLayout(); try cfg.shell.initBacking(cfg.alloc); - var state = try loadCatalogStartupState( + var state = try loadCatalogStartupStateWithAuthMode( cfg.alloc, cfg.secret_store, cfg.default_model, cfg.default_agent_step_limit, + cfg.auth_mode, ); errdefer state.deinit(cfg.alloc); - state.credential_onboarding_skipped = credentialOnboardingDisabled(); + state.credential_onboarding_skipped = cfg.auth_mode == .host_managed or credentialOnboardingDisabled(); errdefer shutdownInteractiveShell( cfg.terminal, @@ -2013,6 +2082,28 @@ test "loadStartupState applies core env overrides" { try std.testing.expectEqual(@as(usize, 37), state.agent_step_limit); } +test "host-managed startup skips every local credential source" { + var env = try TestEnv.install(std.testing.allocator, &.{ + .{ .key = "AI_GATEWAY_API_KEY", .value = "must-not-load" }, + }); + defer env.deinit(); + + var state = try loadStartupStateWithAuthMode( + std.testing.allocator, + oauth_transport.unavailable_provider, + host.unavailable_secret_store, + "default-model", + 12, + .host_managed, + ); + defer state.deinit(std.testing.allocator); + + try std.testing.expectEqual(credentials.AuthMode.host_managed, state.auth_mode); + try std.testing.expect(state.credential == null); + try std.testing.expect(state.apiKey() == null); + try std.testing.expectEqual(credentials.CatalogAccess.host_managed, state.modelCatalogAccess()); +} + test "loadStartupState defaults fast mode on only for the compiled Gateway default and preserves explicit preferences" { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); diff --git a/src/core/app/model_cache_runtime.zig b/src/core/app/model_cache_runtime.zig index 6bdc33bf7..fe745e815 100644 --- a/src/core/app/model_cache_runtime.zig +++ b/src/core/app/model_cache_runtime.zig @@ -39,7 +39,7 @@ const OwnedCatalogAccess = struct { fn init(alloc: Allocator, access: credentials.CatalogAccess) !OwnedCatalogAccess { return switch (access) { - .public_only => .{ .access = access }, + .public_only, .host_managed => .{ .access = access }, .authenticated => |authenticated| blk: { const credential = try alloc.dupe(u8, authenticated.credential); errdefer secret.zeroAndFree(alloc, credential); @@ -72,7 +72,7 @@ const OwnedCatalogAccess = struct { fn deinit(self: *OwnedCatalogAccess, alloc: Allocator) void { switch (self.access) { - .public_only => {}, + .public_only, .host_managed => {}, .authenticated => |access| { secret.zeroAndFree(alloc, @constCast(access.credential)); if (access.team_context) |team| alloc.free(@constCast(team)); diff --git a/src/core/auth/auth_runtime.zig b/src/core/auth/auth_runtime.zig index d96b188b8..7cc19d95f 100644 --- a/src/core/auth/auth_runtime.zig +++ b/src/core/auth/auth_runtime.zig @@ -758,7 +758,7 @@ pub const View = struct { }; pub const GatewayCredential = struct { - api_key: []const u8, + api_key: ?[]const u8, gateway_team: ?[]const u8, source: credentials.Source, }; @@ -769,6 +769,7 @@ pub const Runtime = struct { api_key_validator: api_key_validator.Provider = api_key_validator.unavailable_provider, oauth_transport: oauth_transport.Provider = oauth_transport.unavailable_provider, secret_store: host.SecretStore = host.unavailable_secret_store, + auth_mode: credentials.AuthMode = .local, selected_credential: ?credentials.Credential = null, credential_refresh_failure_source: ?credentials.Source = null, source_inventory: SourceSet = .empty, @@ -795,11 +796,21 @@ pub const Runtime = struct { validator: api_key_validator.Provider, transport: oauth_transport.Provider, secret_store: host.SecretStore, + ) Self { + return initWithMode(validator, transport, secret_store, .local); + } + + pub fn initWithMode( + validator: api_key_validator.Provider, + transport: oauth_transport.Provider, + secret_store: host.SecretStore, + auth_mode: credentials.AuthMode, ) Self { return .{ .api_key_validator = validator, .oauth_transport = transport, .secret_store = secret_store, + .auth_mode = auth_mode, }; } @@ -810,9 +821,19 @@ pub const Runtime = struct { validator: api_key_validator.Provider, transport: oauth_transport.Provider, secret_store: host.SecretStore, + ) void { + initIntoWithMode(storage, validator, transport, secret_store, .local); + } + + pub fn initIntoWithMode( + storage: *Self, + validator: api_key_validator.Provider, + transport: oauth_transport.Provider, + secret_store: host.SecretStore, + auth_mode: credentials.AuthMode, ) void { comptime { - if (std.meta.fields(Self).len != 24) { + if (std.meta.fields(Self).len != 25) { @compileError("update Runtime.initInto for the changed field set"); } } @@ -820,6 +841,7 @@ pub const Runtime = struct { storage.api_key_validator = validator; storage.oauth_transport = transport; storage.secret_store = secret_store; + storage.auth_mode = auth_mode; storage.selected_credential = null; storage.credential_refresh_failure_source = null; storage.source_inventory = .empty; @@ -860,6 +882,11 @@ pub const Runtime = struct { } fn gatewayCredentialAt(self: *const Self, now_ms: i64) ?GatewayCredential { + if (self.auth_mode == .host_managed) return .{ + .api_key = null, + .gateway_team = null, + .source = .host_managed, + }; const credential = self.selected_credential orelse return null; if (credential.needsRefreshAt(now_ms)) return null; return .{ @@ -874,6 +901,14 @@ pub const Runtime = struct { return credential.api_key; } + pub fn isHostManaged(self: *const Self) bool { + return self.auth_mode == .host_managed; + } + + pub fn authMode(self: *const Self) credentials.AuthMode { + return self.auth_mode; + } + pub fn oauthTransport(self: *const Self) oauth_transport.Provider { return self.oauth_transport; } @@ -883,6 +918,7 @@ pub const Runtime = struct { } pub fn modelCatalogAccess(self: *const Self) credentials.CatalogAccess { + if (self.auth_mode == .host_managed) return .host_managed; if (self.credential_refresh_failure_source) |source| { return credentials.catalogAccessAfterRefreshFailure(source); } @@ -895,11 +931,13 @@ pub const Runtime = struct { } pub fn credentialSource(self: *const Self) ?credentials.Source { + if (self.auth_mode == .host_managed) return .host_managed; const credential = self.selected_credential orelse return null; return credential.source; } pub fn accountId(self: *const Self) ?[]const u8 { + if (self.auth_mode == .host_managed) return null; const credential = self.selected_credential orelse return null; return credential.accountId(); } @@ -923,6 +961,12 @@ pub const Runtime = struct { } fn statusSnapshotAt(self: *const Self, now_ms: i64) StatusSnapshot { + if (self.auth_mode == .host_managed) return .{ + .active_source = .host_managed, + .gateway_connected = true, + .chatgpt_connected = true, + .grok_connected = true, + }; const gateway_connected = self.source_inventory.contains(.vercel_oidc_token) or self.source_inventory.contains(.ai_gateway_api_key) or self.source_inventory.contains(.fx_login) or @@ -973,10 +1017,15 @@ pub const Runtime = struct { } pub fn refreshSourceInventory(self: *Self, alloc: Allocator) !void { + if (self.auth_mode == .host_managed) { + self.source_inventory = .empty; + return; + } try self.refreshSourceInventoryWithProbe(alloc, self, probeCredentialSource); } pub fn refreshChatGptSourceInventory(self: *Self, alloc: Allocator) !void { + if (self.auth_mode == .host_managed) return; if (try credentials.sourceExists(alloc, self.secret_store, .chatgpt_subscription)) { self.source_inventory.insert(.chatgpt_subscription); } else if (self.credentialSource() != .chatgpt_subscription) { @@ -985,6 +1034,7 @@ pub const Runtime = struct { } pub fn refreshGrokSourceInventory(self: *Self, alloc: Allocator) !void { + if (self.auth_mode == .host_managed) return; if (try credentials.sourceExists(alloc, self.secret_store, .grok_subscription)) { self.source_inventory.insert(.grok_subscription); } else if (self.credentialSource() != .grok_subscription) { @@ -1617,6 +1667,7 @@ pub const Runtime = struct { alloc: Allocator, provider: model_provider.ProviderId, ) !?bool { + if (self.auth_mode == .host_managed) return false; return switch (provider) { .codex => if (self.credentialSource() == .chatgpt_subscription) false @@ -1816,6 +1867,33 @@ test "auth in-place initialization preserves empty runtime state" { try std.testing.expect(runtime.api_key_input.items.len == 0); } +test "host-managed runtime exposes authority without local credential state" { + var runtime = Runtime.initWithMode( + api_key_validator.unavailable_provider, + oauth_transport.unavailable_provider, + host.unavailable_secret_store, + .host_managed, + ); + defer runtime.deinit(std.testing.allocator); + + try std.testing.expect(runtime.isHostManaged()); + try std.testing.expect(runtime.apiKey() == null); + try std.testing.expect(runtime.accountId() == null); + try std.testing.expect(runtime.gatewayTeam() == null); + try std.testing.expectEqual(credentials.Source.host_managed, runtime.credentialSource().?); + try std.testing.expectEqual(credentials.Source.host_managed, runtime.gatewayCredential().?.source); + try std.testing.expect(runtime.gatewayCredential().?.api_key == null); + try std.testing.expectEqual(credentials.CatalogAccess.host_managed, runtime.modelCatalogAccess()); + const status = runtime.statusSnapshot(); + try std.testing.expectEqual(credentials.Source.host_managed, status.active_source.?); + try std.testing.expect(status.gateway_connected); + try std.testing.expect(status.chatgpt_connected); + try std.testing.expect(status.grok_connected); + try std.testing.expect(!status.refreshable()); + try runtime.refreshSourceInventory(std.testing.allocator); + try std.testing.expectEqual(@as(usize, 0), runtime.source_inventory.count()); +} + fn probeCredentialSource(raw_context: ?*anyopaque, alloc: Allocator, source: credentials.Source) !bool { const self: *Runtime = @ptrCast(@alignCast(raw_context.?)); return credentials.sourceExists(alloc, self.secret_store, source); @@ -2144,7 +2222,7 @@ test "auth runtime exposes one current Gateway credential for prompt admission" _ = runtime.adoptCredential(alloc, &credential); const gateway_credential = runtime.gatewayCredential().?; - try std.testing.expectEqualStrings("token-a", gateway_credential.api_key); + try std.testing.expectEqualStrings("token-a", gateway_credential.api_key.?); try std.testing.expectEqualStrings("team_123", gateway_credential.gateway_team.?); try std.testing.expectEqual(credentials.Source.fx_login, gateway_credential.source); } @@ -2177,7 +2255,7 @@ test "auth runtime withholds an fx credential across its expiry boundary" { refreshed.refresh_after_ms = 140_000; try std.testing.expect(runtime.adoptCredential(alloc, &refreshed)); try std.testing.expect(!runtime.credentialNeedsRefreshAt(40_000)); - try std.testing.expectEqualStrings("stale-token", runtime.gatewayCredentialAt(40_000).?.api_key); + try std.testing.expectEqualStrings("stale-token", runtime.gatewayCredentialAt(40_000).?.api_key.?); } test "auth runtime view preserves missing and loaded states" { diff --git a/src/core/auth/credential_authority.zig b/src/core/auth/credential_authority.zig index a031d3c42..0b536d9f6 100644 --- a/src/core/auth/credential_authority.zig +++ b/src/core/auth/credential_authority.zig @@ -25,6 +25,7 @@ pub fn derive( .ai_gateway_api_key, .fx_login, .stored_key, + .host_managed, => hash.update("\x00slot\x00"), .chatgpt_subscription, .grok_subscription, @@ -60,4 +61,5 @@ test "credential authority uses non-secret Gateway credential slots" { try std.testing.expect(!api_key.eql(stored_key)); try std.testing.expect(derive(.vercel_oidc_token, null) != null); try std.testing.expect(derive(.fx_login, null) != null); + try std.testing.expect(derive(.host_managed, null) != null); } diff --git a/src/core/auth/credentials.zig b/src/core/auth/credentials.zig index 33ee26442..1145664d7 100644 --- a/src/core/auth/credentials.zig +++ b/src/core/auth/credentials.zig @@ -14,6 +14,62 @@ const types = @import("../shared/types.zig"); pub const Source = types.CredentialSource; +pub const AuthMode = enum { + local, + host_managed, +}; + +pub const AuthModeError = error{InvalidAuthMode}; + +pub fn parseAuthMode(value: ?[]const u8) AuthModeError!AuthMode { + const raw = value orelse return .local; + if (std.mem.eql(u8, raw, "local")) return .local; + if (std.mem.eql(u8, raw, "host-managed")) return .host_managed; + return error.InvalidAuthMode; +} + +pub const DirectRequestAuth = struct { + secret_bytes: []const u8, + source: Source = .ai_gateway_api_key, + account_id: ?[]const u8 = null, + tenant_context: ?[]const u8 = null, +}; + +/// Borrowed authorization decision for one provider request. Host-managed +/// requests deliberately carry no local credential or account metadata. +pub const RequestAuth = union(enum) { + direct: DirectRequestAuth, + host_managed, + + pub fn secret(self: RequestAuth) ?[]const u8 { + return switch (self) { + .direct => |direct| direct.secret_bytes, + .host_managed => null, + }; + } + + pub fn credentialSource(self: RequestAuth) Source { + return switch (self) { + .direct => |direct| direct.source, + .host_managed => .host_managed, + }; + } + + pub fn accountId(self: RequestAuth) ?[]const u8 { + return switch (self) { + .direct => |direct| direct.account_id, + .host_managed => null, + }; + } + + pub fn tenant(self: RequestAuth) ?[]const u8 { + return switch (self) { + .direct => |direct| direct.tenant_context, + .host_managed => null, + }; + } +}; + pub const CatalogPublicOnly = union(enum) { no_credential, fx_login_team_required, @@ -68,11 +124,13 @@ pub const CatalogAccess = union(enum) { team_context: ?[]const u8, account_id: ?[]const u8 = null, }, + host_managed, pub fn credentialSource(self: CatalogAccess) ?Source { return switch (self) { .public_only => |access| access.credentialSource(), .authenticated => |access| access.source.credentialSource(), + .host_managed => .host_managed, }; } @@ -84,7 +142,7 @@ pub const CatalogAccess = union(enum) { pub fn publicOnly(self: CatalogAccess) ?CatalogPublicOnly { return switch (self) { .public_only => |access| access, - .authenticated => null, + .authenticated, .host_managed => null, }; } @@ -99,6 +157,7 @@ pub const CatalogAccess = union(enum) { .authenticated_credential_rejected = access.source.credentialSource(), }, }, + .host_managed => null, }; } @@ -106,6 +165,7 @@ pub const CatalogAccess = union(enum) { return switch (self) { .public_only => null, .authenticated => |access| access.credential, + .host_managed => null, }; } @@ -113,6 +173,7 @@ pub const CatalogAccess = union(enum) { const team = switch (self) { .public_only => return null, .authenticated => |access| access.team_context orelse return null, + .host_managed => return null, }; return if (team.len > 0) team else null; } @@ -121,6 +182,7 @@ pub const CatalogAccess = union(enum) { const account_id = switch (self) { .public_only => return null, .authenticated => |access| access.account_id orelse return null, + .host_managed => return null, }; return if (account_id.len > 0) account_id else null; } @@ -162,12 +224,14 @@ pub fn catalogAccessForCredentialAndAccount( account_id: ?[]const u8, ) CatalogAccess { const selected_source = source orelse return .{ .public_only = .no_credential }; + if (selected_source == .host_managed) return .host_managed; const authenticated_source: CatalogAuthenticatedSource = switch (selected_source) { .vercel_oidc_token => .vercel_oidc_token, .ai_gateway_api_key => .ai_gateway_api_key, .stored_key => .stored_key, .chatgpt_subscription => .chatgpt_subscription, .grok_subscription => .grok_subscription, + .host_managed => unreachable, .fx_login => blk: { const team = team_context orelse return .{ .public_only = .fx_login_team_required }; @@ -203,6 +267,7 @@ pub const missing_chatgpt_interactive_credential_message = "Codex needs a subscr pub const missing_grok_credential_message = "fx needs a Grok subscription login for this model. Run fx login grok."; pub const missing_grok_interactive_credential_message = "Grok needs a subscription login. Run /login, open Connections, then choose Grok subscription."; pub const unreadable_store_message = "fx could not read the stored API key from " ++ stored_key_backend_label ++ ". A key may be saved but unreadable. Set FX_TRACE_LOG for the failing step, or set AI_GATEWAY_API_KEY."; +pub const host_managed_auth_message = "Authentication is managed by the host."; test "public credential guidance spells fx lowercase" { try std.testing.expect(std.mem.startsWith(u8, missing_credential_message, "fx needs")); @@ -210,6 +275,32 @@ test "public credential guidance spells fx lowercase" { try std.testing.expect(std.mem.startsWith(u8, unreadable_store_message, "fx could")); } +test "auth mode accepts only local and host-managed process values" { + try std.testing.expectEqual(AuthMode.local, try parseAuthMode(null)); + try std.testing.expectEqual(AuthMode.local, try parseAuthMode("local")); + try std.testing.expectEqual(AuthMode.host_managed, try parseAuthMode("host-managed")); + try std.testing.expectError(error.InvalidAuthMode, parseAuthMode("host_managed")); + try std.testing.expectError(error.InvalidAuthMode, parseAuthMode("")); +} + +test "host-managed authorization carries no credential bytes" { + const access: RequestAuth = .host_managed; + try std.testing.expect(access.secret() == null); + try std.testing.expect(access.accountId() == null); + try std.testing.expect(access.tenant() == null); + try std.testing.expectEqual(Source.host_managed, access.credentialSource()); +} + +test "host-managed catalog access is authenticated without local headers" { + const access: CatalogAccess = .host_managed; + try std.testing.expect(access.authorizationCredential() == null); + try std.testing.expect(access.accountId() == null); + try std.testing.expect(access.teamContext() == null); + try std.testing.expectEqual(Source.host_managed, access.credentialSource().?); + try std.testing.expect(access.publicOnlyReason() == null); + try std.testing.expect(access.publicFallbackAfterRejection() == null); +} + pub const Credential = struct { token: []u8, source: Source, @@ -412,6 +503,7 @@ pub fn loadSource( .stored_key => loadStoredKeyCredential(alloc, secret_store, .refresh_if_needed), .chatgpt_subscription => loadChatGptCredential(alloc, transport, .if_needed), .grok_subscription => loadGrokCredential(alloc, transport, .if_needed), + .host_managed => null, }; } @@ -450,6 +542,7 @@ pub fn sourceExists( secret.zeroAndFree(alloc, value); break :blk true; }, + .host_managed => false, }; } @@ -667,6 +760,7 @@ pub fn sourceLabel(source: Source) []const u8 { .stored_key => "stored API key (" ++ stored_key_backend_label ++ ")", .chatgpt_subscription => "Codex subscription", .grok_subscription => "Grok subscription", + .host_managed => "host managed", }; } diff --git a/src/core/cli/acp_runner.zig b/src/core/cli/acp_runner.zig index 448d31cb5..a08f3ca5e 100644 --- a/src/core/cli/acp_runner.zig +++ b/src/core/cli/acp_runner.zig @@ -6,6 +6,7 @@ const background_process_provider = @import( const gateway_provider = @import("../gateway/gateway_provider.zig"); const provider_set = @import("../gateway/provider_set.zig"); const host = @import("../hosts/host.zig"); +const credentials = @import("../auth/credentials.zig"); const mode_registry = @import("../modes/mode_registry.zig"); const prompt_policy = @import("../config/prompt_policy.zig"); const context_contract = @import("../workspace/context_contract.zig"); @@ -13,6 +14,7 @@ const context_contract = @import("../workspace/context_contract.zig"); const Allocator = std.mem.Allocator; pub const Config = struct { + auth_mode: credentials.AuthMode = .local, default_model: []const u8, default_agent_step_limit: usize, gateway_retry_count: usize, diff --git a/src/core/cli/cli_ask.zig b/src/core/cli/cli_ask.zig index 2ee7dc03e..77b5136b9 100644 --- a/src/core/cli/cli_ask.zig +++ b/src/core/cli/cli_ask.zig @@ -219,6 +219,7 @@ const headless_interrupt = if (supports_headless_interrupt) struct { }; pub const Config = struct { + auth_mode: credentials.AuthMode = .local, command_usage: []const u8, default_model: []const u8, default_agent_step_limit: usize, @@ -395,6 +396,7 @@ const NotifyAttentionFn = *const fn (?*anyopaque) void; const PermissionApprovalPromptFn = *const fn (?*anyopaque, ?*anyopaque, WriteFn, []const u8, ?*anyopaque, NotifyAttentionFn) anyerror!PermissionApprovalPromptResult; const IsTtyFn = *const fn (?*anyopaque) bool; const LoadStartupStateFn = *const fn (Allocator, oauth_transport.Provider, host.SecretStore, []const u8, usize) anyerror!app_lifecycle.StartupState; +const LoadStartupStateWithAuthModeFn = *const fn (Allocator, oauth_transport.Provider, host.SecretStore, []const u8, usize, credentials.AuthMode) anyerror!app_lifecycle.StartupState; const InitializeSessionStoresFn = *const fn (*AskContext) anyerror!void; const LoadSkillsFn = *const fn ( Allocator, @@ -417,6 +419,7 @@ const RunDeps = struct { stdout_is_tty: IsTtyFn = realStdoutIsTty, stderr_is_tty: IsTtyFn = realStderrIsTty, load_startup_state: LoadStartupStateFn = loadStartupStateDefault, + load_startup_state_with_auth_mode: LoadStartupStateWithAuthModeFn = app_lifecycle.loadStartupStateWithAuthMode, initialize_session_stores: InitializeSessionStoresFn = initializeSessionStoresDefault, load_skills: LoadSkillsFn = app_runtime_setup.loadSkills, context_registry: context_contract.Registry, @@ -1074,6 +1077,7 @@ const AskContext = struct { return permission_auto_classifier.Classifier.disabled(); return permission_auto_classifier.Classifier.withProvider(provider, .{ .credential = self.api_key, + .credential_source = self.credential_source, .account_id = self.account_id, .tenant = self.gateway_team, .endpoint = self.cfg.gateway_chat_url, @@ -1425,13 +1429,23 @@ fn runPromptInternal(alloc: Allocator, prompt: []const u8, permission_override: defer alloc.free(owned_prompt); try checkHeadlessCancellation(options.deps); - var startup = try options.deps.load_startup_state( - alloc, - cfg.gateway_provider.oauth_transport, - cfg.secret_store, - cfg.default_model, - cfg.default_agent_step_limit, - ); + var startup = if (cfg.auth_mode == .host_managed) + try options.deps.load_startup_state_with_auth_mode( + alloc, + cfg.gateway_provider.oauth_transport, + cfg.secret_store, + cfg.default_model, + cfg.default_agent_step_limit, + cfg.auth_mode, + ) + else + try options.deps.load_startup_state( + alloc, + cfg.gateway_provider.oauth_transport, + cfg.secret_store, + cfg.default_model, + cfg.default_agent_step_limit, + ); defer startup.deinit(alloc); try checkHeadlessCancellation(options.deps); @@ -1465,7 +1479,9 @@ fn runPromptInternal(alloc: Allocator, prompt: []const u8, permission_override: ); try checkHeadlessCancellation(options.deps); - if (!options.continue_recovery and options.resume_target == null and startup.credential == null) { + if (cfg.auth_mode == .local and + !options.continue_recovery and options.resume_target == null and startup.credential == null) + { return missingCredentialResult(alloc, options, startup.provider); } @@ -1545,48 +1561,63 @@ fn runPromptInternal(alloc: Allocator, prompt: []const u8, permission_override: var routed_credential: ?credentials.Credential = null; defer if (routed_credential) |*credential| credential.deinit(alloc); - const startup_matches_final_model = if (startup.credential) |credential| - model_provider.authorizesCredential(ctx.provider, credential.source) - else - false; - const credential: *const credentials.Credential = if (startup_matches_final_model) - &startup.credential.? - else routed: { - const preferred = if (startup.credential) |value| value.source else null; - const resolution = try credentials.resolveForProvider( - alloc, - cfg.gateway_provider.oauth_transport, - cfg.secret_store, - .refresh_if_needed, - ctx.provider, - preferred, - ); - routed_credential = resolution.credential; - if (routed_credential == null) { - return missingCredentialResult(alloc, options, ctx.provider); + if (cfg.auth_mode == .host_managed) { + ctx.api_key = ""; + ctx.gateway_team = null; + ctx.credential_source = .host_managed; + ctx.account_id = null; + ctx.model_catalog_access = .host_managed; + if (comptime @import("builtin").os.tag != .wasi) { + if (ctx.cfg.provider_set.select(ctx.provider).deferred_usage != null) { + ctx.session.usage.replaceHostManagedReconciliationAuthority( + ctx.alloc, + ctx.provider, + ); + } } - break :routed &routed_credential.?; - }; - const api_key = credential.token; - ctx.api_key = api_key; - ctx.gateway_team = credential.gatewayTeam(); - ctx.credential_source = credential.source; - ctx.account_id = credential.accountId(); - ctx.model_catalog_access = credentials.catalogAccessForCredentialAndAccount( - credential.source, - api_key, - credential.gatewayTeam(), - credential.accountId(), - ); - if (comptime @import("builtin").os.tag != .wasi) { - if (ctx.cfg.provider_set.select(ctx.provider).deferred_usage != null) { - ctx.session.usage.replaceProviderReconciliationCredential( + } else { + const startup_matches_final_model = if (startup.credential) |credential| + model_provider.authorizesCredential(ctx.provider, credential.source) + else + false; + const credential: *const credentials.Credential = if (startup_matches_final_model) + &startup.credential.? + else routed: { + const preferred = if (startup.credential) |value| value.source else null; + const resolution = try credentials.resolveForProvider( alloc, + cfg.gateway_provider.oauth_transport, + cfg.secret_store, + .refresh_if_needed, ctx.provider, - credential.source, - credential.accountId(), - credential.token, + preferred, ); + routed_credential = resolution.credential; + if (routed_credential == null) { + return missingCredentialResult(alloc, options, ctx.provider); + } + break :routed &routed_credential.?; + }; + ctx.api_key = credential.token; + ctx.gateway_team = credential.gatewayTeam(); + ctx.credential_source = credential.source; + ctx.account_id = credential.accountId(); + ctx.model_catalog_access = credentials.catalogAccessForCredentialAndAccount( + credential.source, + credential.token, + credential.gatewayTeam(), + credential.accountId(), + ); + if (comptime @import("builtin").os.tag != .wasi) { + if (ctx.cfg.provider_set.select(ctx.provider).deferred_usage != null) { + ctx.session.usage.replaceProviderReconciliationCredential( + alloc, + ctx.provider, + credential.source, + credential.accountId(), + credential.token, + ); + } } } @@ -1739,10 +1770,10 @@ fn runPromptInternal(alloc: Allocator, prompt: []const u8, permission_override: .images = current_images, .authorized_image_catalog = authorized_image_catalog, .model = @constCast(ctx.model), - .api_key = api_key, - .gateway_team = if (credential.gatewayTeam()) |team| @constCast(team) else null, - .credential_source = credential.source, - .account_id = if (credential.accountId()) |account_id| @constCast(account_id) else null, + .api_key = @constCast(ctx.api_key), + .gateway_team = if (ctx.gateway_team) |team| @constCast(team) else null, + .credential_source = ctx.credential_source, + .account_id = if (ctx.account_id) |account_id| @constCast(account_id) else null, .provider = ctx.provider, .permission_mode = ctx.permission_mode, .history = context_history, diff --git a/src/core/cli/cli_surface.zig b/src/core/cli/cli_surface.zig index de99b8da0..dfc538344 100644 --- a/src/core/cli/cli_surface.zig +++ b/src/core/cli/cli_surface.zig @@ -155,6 +155,7 @@ pub const Config = struct { version: []const u8 = "", revision: []const u8 = "", build_channel: update_target.Channel = .stable, + auth_mode: credentials.AuthMode = .local, command_catalog: CommandCatalog, default_model: []const u8, default_agent_step_limit: usize, @@ -311,6 +312,9 @@ const LoadStartupStateFn = *const fn (Allocator, oauth_transport.Provider, host. const LoadCatalogStartupStateFn = *const fn (Allocator, host.SecretStore, []const u8, usize) anyerror!app_lifecycle.StartupState; const LoadStartupStateWithoutCredentialsFn = *const fn (Allocator, []const u8, usize) anyerror!app_lifecycle.StartupState; const LoadStartupStatusFn = *const fn (Allocator, host.SecretStore, []const u8, usize) anyerror!app_lifecycle.StartupStatus; +const LoadStartupStateWithAuthModeFn = *const fn (Allocator, oauth_transport.Provider, host.SecretStore, []const u8, usize, credentials.AuthMode) anyerror!app_lifecycle.StartupState; +const LoadCatalogStartupStateWithAuthModeFn = *const fn (Allocator, host.SecretStore, []const u8, usize, credentials.AuthMode) anyerror!app_lifecycle.StartupState; +const LoadStartupStatusWithAuthModeFn = *const fn (Allocator, host.SecretStore, []const u8, usize, credentials.AuthMode) anyerror!app_lifecycle.StartupStatus; const GetenvFn = *const fn (?*anyopaque, []const u8) ?[]const u8; const EnvironMapFn = *const fn (?*anyopaque) ?*const std.process.Environ.Map; const SelfExePathFn = *const fn (?*anyopaque, Allocator) anyerror![]u8; @@ -328,6 +332,9 @@ const RunDeps = struct { load_catalog_startup_state: LoadCatalogStartupStateFn = app_lifecycle.loadCatalogStartupState, load_startup_state_without_credentials: LoadStartupStateWithoutCredentialsFn = app_lifecycle.loadStartupStateWithoutCredentials, load_startup_status: LoadStartupStatusFn = app_lifecycle.loadStartupStatus, + load_startup_state_with_auth_mode: LoadStartupStateWithAuthModeFn = app_lifecycle.loadStartupStateWithAuthMode, + load_catalog_startup_state_with_auth_mode: LoadCatalogStartupStateWithAuthModeFn = app_lifecycle.loadCatalogStartupStateWithAuthMode, + load_startup_status_with_auth_mode: LoadStartupStatusWithAuthModeFn = app_lifecycle.loadStartupStatusWithAuthMode, getenv: GetenvFn = getenvDefault, environ_map: EnvironMapFn = environMapDefault, self_exe_path: SelfExePathFn = selfExePathDefault, @@ -634,6 +641,11 @@ fn writeProviderActivationError( try writeStderr(deps, message); } +fn writeHostManagedAuthResult(deps: RunDeps) !void { + try writeStdout(deps, credentials.host_managed_auth_message); + try writeStdout(deps, "\n"); +} + fn activateProviderSelection( alloc: Allocator, cfg: Config, @@ -650,18 +662,23 @@ fn activateProviderSelection( }; defer settings.deinit(alloc); - var resolution = try credentials.resolveForProvider( - alloc, - cfg.gateway_provider.oauth_transport, - cfg.secret_store, - .refresh_if_needed, - target, - settings.credential_source, - ); + var resolution = if (cfg.auth_mode == .host_managed) + credentials.Resolution{} + else + try credentials.resolveForProvider( + alloc, + cfg.gateway_provider.oauth_transport, + cfg.secret_store, + .refresh_if_needed, + target, + settings.credential_source, + ); defer if (resolution.credential) |*credential| credential.deinit(alloc); const already_selected = (settings.provider orelse .gateway) == target; - if (caller == .provider_command and already_selected and resolution.credential != null) { + if (caller == .provider_command and already_selected and + (cfg.auth_mode == .host_managed or resolution.credential != null)) + { try writeStdout(deps, switch (target) { .gateway => "Gateway is already selected.\n", .codex => "Codex is already selected.\n", @@ -671,7 +688,7 @@ fn activateProviderSelection( } var performed_login: ?model_provider.ProviderId = null; - if (resolution.credential == null and target == .codex and caller == .provider_command) { + if (cfg.auth_mode == .local and resolution.credential == null and target == .codex and caller == .provider_command) { chatgpt_oauth.runLogin(alloc, cfg.gateway_provider.oauth_transport, cfg.url_opener) catch |err| { debug_trace.logf("auth", "provider selection Codex login failed err={s}", .{@errorName(err)}); try writeProviderActivationError(alloc, deps, caller, "Codex login failed"); @@ -687,7 +704,7 @@ fn activateProviderSelection( settings.credential_source, ); } - if (resolution.credential == null and target == .grok and caller == .provider_command) { + if (cfg.auth_mode == .local and resolution.credential == null and target == .grok and caller == .provider_command) { grok_oauth.runLogin(alloc, cfg.gateway_provider.oauth_transport, cfg.url_opener) catch |err| { debug_trace.logf("auth", "provider selection Grok login failed err={s}", .{@errorName(err)}); try writeProviderActivationError(alloc, deps, caller, "Grok login failed"); @@ -704,7 +721,11 @@ fn activateProviderSelection( ); } - const credential = if (resolution.credential) |*value| value else { + const credential = if (cfg.auth_mode == .host_managed) + null + else if (resolution.credential) |*value| + value + else { try writeProviderActivationError( alloc, deps, @@ -726,7 +747,10 @@ fn activateProviderSelection( return false; }; const fetch_result = model_catalog.fetchWithPublicFallback(catalog_provider, alloc, .{ - .access = credentials.catalogAccessAt(credential.*, io_mod.milliTimestamp()), + .access = if (cfg.auth_mode == .host_managed) + .host_managed + else + credentials.catalogAccessAt(credential.?.*, io_mod.milliTimestamp()), .endpoint = cfg.models_path, .view = .picker, }); @@ -859,6 +883,7 @@ fn runNonInteractiveWithDeps( return .handled_failure; }; try cfg.acp_runner.run(alloc, .{ + .auth_mode = cfg.auth_mode, .default_model = cfg.default_model, .default_agent_step_limit = cfg.default_agent_step_limit, .gateway_retry_count = cfg.gateway_retry_count, @@ -894,6 +919,10 @@ fn runNonInteractiveWithDeps( try writeStderr(deps, "usage: fx login [vercel|codex|grok]\n"); return .handled_failure; }; + if (cfg.auth_mode == .host_managed) { + try writeHostManagedAuthResult(deps); + return .handled_success; + } // Preserve the original `fx login` behavior for scripts and users. const login_provider = maybe_login_provider orelse .gateway; switch (login_provider) { @@ -953,6 +982,10 @@ fn runNonInteractiveWithDeps( try writeStderr(deps, "usage: fx logout [vercel|codex|grok]\n"); return .handled_failure; }; + if (cfg.auth_mode == .host_managed) { + try writeHostManagedAuthResult(deps); + return .handled_success; + } // Preserve the original `fx logout` behavior for scripts and users. const login_provider = maybe_login_provider orelse .gateway; if (login_provider == .codex) { @@ -1023,6 +1056,10 @@ fn runNonInteractiveWithDeps( try writeStderr(deps, "usage: fx teams\n"); return .handled_failure; } + if (cfg.auth_mode == .host_managed) { + try writeHostManagedAuthResult(deps); + return .handled_success; + } login_flow.runTeams(alloc, cfg.gateway_provider.oauth_transport) catch |err| { const message = switch (err) { error.NoSession => "fx teams: run fx login first\n", @@ -1056,6 +1093,10 @@ fn runNonInteractiveWithDeps( try writeTopLevelUsage(cfg.command_catalog, deps, .setup); return .handled_failure; } + if (cfg.auth_mode == .host_managed) { + try writeHostManagedAuthResult(deps); + return .handled_success; + } return if (try runPasteSetup(alloc, cfg.secret_store, deps)) .handled_success else .handled_failure; }, .status => |rest| { @@ -1063,12 +1104,21 @@ fn runNonInteractiveWithDeps( try writeUsageOrJsonError(alloc, cfg.command_catalog, deps, .status, "status", err, rest); return .handled_failure; }; - var startup = try deps.load_startup_status( - alloc, - cfg.secret_store, - cfg.default_model, - cfg.default_agent_step_limit, - ); + var startup = if (cfg.auth_mode == .host_managed) + try deps.load_startup_status_with_auth_mode( + alloc, + cfg.secret_store, + cfg.default_model, + cfg.default_agent_step_limit, + cfg.auth_mode, + ) + else + try deps.load_startup_status( + alloc, + cfg.secret_store, + cfg.default_model, + cfg.default_agent_step_limit, + ); defer startup.deinit(alloc); try writeConfigDiagnostics(alloc, deps, startup.config_diagnostics); var mcp_inspection = try cfg.inspect_mcp_local_config( @@ -1124,12 +1174,21 @@ fn runNonInteractiveWithDeps( return .handled_failure; }; - var startup = try deps.load_catalog_startup_state( - alloc, - cfg.secret_store, - cfg.default_model, - cfg.default_agent_step_limit, - ); + var startup = if (cfg.auth_mode == .host_managed) + try deps.load_catalog_startup_state_with_auth_mode( + alloc, + cfg.secret_store, + cfg.default_model, + cfg.default_agent_step_limit, + cfg.auth_mode, + ) + else + try deps.load_catalog_startup_state( + alloc, + cfg.secret_store, + cfg.default_model, + cfg.default_agent_step_limit, + ); defer startup.deinit(alloc); try writeConfigDiagnostics(alloc, deps, startup.config_diagnostics); @@ -1542,13 +1601,23 @@ fn runNonInteractiveWithDeps( try writeUsageOrJsonError(alloc, cfg.command_catalog, deps, .credits, "credits", err, rest); return .handled_failure; }; - var startup = try deps.load_startup_state( - alloc, - cfg.gateway_provider.oauth_transport, - cfg.secret_store, - cfg.default_model, - cfg.default_agent_step_limit, - ); + var startup = if (cfg.auth_mode == .host_managed) + try deps.load_startup_state_with_auth_mode( + alloc, + cfg.gateway_provider.oauth_transport, + cfg.secret_store, + cfg.default_model, + cfg.default_agent_step_limit, + cfg.auth_mode, + ) + else + try deps.load_startup_state( + alloc, + cfg.gateway_provider.oauth_transport, + cfg.secret_store, + cfg.default_model, + cfg.default_agent_step_limit, + ); defer startup.deinit(alloc); try writeConfigDiagnostics(alloc, deps, startup.config_diagnostics); @@ -1556,7 +1625,12 @@ fn runNonInteractiveWithDeps( gateway_provider.unavailable_credits_provider; var snapshot = credits.fetch(alloc, .{ .credential = startup.apiKey(), - .credential_source = if (startup.credential) |credential| credential.source else null, + .credential_source = if (startup.auth_mode == .host_managed) + .host_managed + else if (startup.credential) |credential| + credential.source + else + null, .tenant = startup.gatewayTeam(), }); defer snapshot.deinit(alloc); @@ -3391,6 +3465,7 @@ test "session recovery boundary failures keep stable text and json guidance" { fn workflowConfig(cfg: Config) @import("cli_ask.zig").Config { return .{ + .auth_mode = cfg.auth_mode, .command_usage = command_specs.topLevelUsage(cfg.command_catalog, .ask), .default_model = cfg.default_model, .default_agent_step_limit = cfg.default_agent_step_limit, diff --git a/src/core/config/model_provider.zig b/src/core/config/model_provider.zig index 93b0168a7..219438bbb 100644 --- a/src/core/config/model_provider.zig +++ b/src/core/config/model_provider.zig @@ -21,6 +21,7 @@ pub fn parse(value: []const u8) ?ProviderId { pub fn authorizesCredential(provider: ProviderId, source: ?types.CredentialSource) bool { const selected = source orelse return false; + if (selected == .host_managed) return true; return switch (provider) { .gateway => selected != .chatgpt_subscription and selected != .grok_subscription, .codex => selected == .chatgpt_subscription, diff --git a/src/core/session/generation_usage_provider.zig b/src/core/session/generation_usage_provider.zig index 19ad021b1..216231968 100644 --- a/src/core/session/generation_usage_provider.zig +++ b/src/core/session/generation_usage_provider.zig @@ -4,7 +4,7 @@ const model_provider = @import("../config/model_provider.zig"); const Allocator = std.mem.Allocator; pub const LookupInput = struct { - credential: []const u8, + credential: ?[]const u8, tenant: ?[]const u8, origin: []const u8, generation_id: []const u8, @@ -112,7 +112,8 @@ test "generation usage lookup dispatches through the injected provider" { const self: *@This() = @ptrCast(@alignCast(raw_context.?)); self.calls += 1; self.saw_expected_input = - std.mem.eql(u8, "credential", input.credential) and + input.credential != null and + std.mem.eql(u8, "credential", input.credential.?) and std.mem.eql(u8, "generation", input.generation_id); const id = try alloc.dupe(u8, input.generation_id); errdefer alloc.free(id); @@ -150,6 +151,24 @@ test "generation usage lookup dispatches through the injected provider" { try std.testing.expectEqualStrings("provider/model", outcome.found.model); } +test "generation usage lookup can defer authentication to the host" { + const Fake = struct { + fn lookup(_: ?*anyopaque, _: Allocator, input: LookupInput) LookupError!LookupOutcome { + if (input.credential != null) return error.Unavailable; + return .preserve_pending; + } + }; + var cancel = std.atomic.Value(bool).init(false); + const outcome = try (Provider{ .lookup_fn = Fake.lookup }).lookup(std.testing.allocator, .{ + .credential = null, + .tenant = null, + .origin = "https://ai-gateway.vercel.sh", + .generation_id = "gen_01ARZ3NDEKTSV4RRFFQ69G5FAV", + .cancel_flag = &cancel, + }); + try std.testing.expectEqual(LookupOutcome.preserve_pending, outcome); +} + test "generation usage providers are selected by provider identity" { const routes = Set.gatewayOnly(unavailable_provider); try std.testing.expect(routes.select(.gateway) != null); diff --git a/src/core/session/session_codec.zig b/src/core/session/session_codec.zig index c152e76cd..dc0850093 100644 --- a/src/core/session/session_codec.zig +++ b/src/core/session/session_codec.zig @@ -1043,7 +1043,7 @@ fn parseTurnAuthority(alloc: Allocator, value: std.json.Value) !TurnAuthority { errdefer alloc.free(model); const credential_source = if (object.get("credential_source")) |source| switch (source) { .null => null, - .string => |text| types.parseCredentialSource(text) orelse return error.InvalidDurableField, + .string => |text| types.parseRuntimeCredentialSource(text) orelse return error.InvalidDurableField, else => return error.InvalidDurableField, } else return error.InvalidSessionFormat; const credential_identity = if (object.get("credential_identity")) |identity| switch (identity) { diff --git a/src/core/session/session_usage.zig b/src/core/session/session_usage.zig index 1d354e746..bf2d40c9b 100644 --- a/src/core/session/session_usage.zig +++ b/src/core/session/session_usage.zig @@ -1726,6 +1726,23 @@ pub const Usage = struct { ); } + pub fn startHostManagedDeferredReconciliation( + self: *Usage, + alloc: Allocator, + reference: stream_provider.DeferredUsageReference, + ) void { + self.startReconciliationWithCredential( + alloc, + null, + .{ + .provider = reference.provider, + .credential_identity = reference.credential_identity, + }, + false, + null, + ); + } + /// Installs the host's authoritative credential regardless of the prior key. pub fn replaceReconciliationCredential( self: *Usage, @@ -1783,6 +1800,23 @@ pub const Usage = struct { ); } + pub fn replaceHostManagedReconciliationAuthority( + self: *Usage, + alloc: Allocator, + provider: model_provider.ProviderId, + ) void { + self.startReconciliationWithCredential( + alloc, + null, + .{ + .provider = provider, + .credential_identity = credential_authority.derive(.host_managed, null), + }, + true, + null, + ); + } + /// Replaces a producer's key only while that key is still authoritative. pub fn refreshReconciliationCredential( self: *Usage, @@ -1819,13 +1853,16 @@ pub const Usage = struct { fn startReconciliationWithCredential( self: *Usage, alloc: Allocator, - api_key: []const u8, + credential: ?[]const u8, authority: ReconciliationAuthority, replace_existing: bool, expected_api_key: ?[]const u8, ) void { - if (api_key.len == 0) return; - const key_digest = reconciliationKeyDigest(api_key); + if (credential) |api_key| if (api_key.len == 0) return; + const key_digest = if (credential) |api_key| + reconciliationKeyDigest(api_key) + else + hostManagedReconciliationDigest(); const expected_digest = if (expected_api_key) |expected| reconciliationKeyDigest(expected) else @@ -1876,21 +1913,24 @@ pub const Usage = struct { self.mutex.unlock(io_mod.getIo()); if (!still_has_pending) return; - const api_key_copy = alloc.dupe(u8, api_key) catch |err| { - debug_trace.logf( - "session", - "usage reconciliation start failed reason={s}", - .{@errorName(err)}, - ); - return; - }; + const credential_copy = if (credential) |api_key| + alloc.dupe(u8, api_key) catch |err| { + debug_trace.logf( + "session", + "usage reconciliation start failed reason={s}", + .{@errorName(err)}, + ); + return; + } + else + null; self.reconciliation_cancel.store(false, .seq_cst); self.reconciliation_done.store(false, .seq_cst); self.reconciliation_key_digest = key_digest; self.reconciliation_thread = std.Thread.spawn( .{}, reconciliationThreadMain, - .{ self, alloc, api_key_copy, authority, self.generation_usage_providers }, + .{ self, alloc, credential_copy, authority, self.generation_usage_providers }, ) catch |err| { self.reconciliation_done.store(true, .seq_cst); debug_trace.logf( @@ -1898,7 +1938,7 @@ pub const Usage = struct { "usage reconciliation start failed reason={s}", .{@errorName(err)}, ); - secret.zeroAndFree(alloc, api_key_copy); + if (credential_copy) |api_key| secret.zeroAndFree(alloc, api_key); return; }; } @@ -2949,18 +2989,18 @@ fn writeOptionalU64(writer: *std.Io.Writer, value: ?u64) !void { fn reconciliationThreadMain( usage: *Usage, alloc: Allocator, - api_key: []u8, + credential: ?[]u8, authority: ReconciliationAuthority, providers: generation_usage.Set, ) void { - defer secret.zeroAndFree(alloc, api_key); + defer if (credential) |api_key| secret.zeroAndFree(alloc, api_key); defer usage.reconciliation_done.store(true, .seq_cst); var observed_epoch = usage.reconciliation_work_epoch.load(.seq_cst); while (!usage.reconciliation_cancel.load(.seq_cst)) { reconcilePendingBlocking( usage, alloc, - api_key, + credential, &usage.reconciliation_cancel, authority, providers, @@ -2987,16 +3027,21 @@ fn reconciliationKeyDigest(api_key: []const u8) [Sha256.digest_length]u8 { return digest; } +fn hostManagedReconciliationDigest() [Sha256.digest_length]u8 { + return reconciliationKeyDigest("fx-host-managed-auth-v1"); +} + fn reconcilePendingBlocking( usage: *Usage, alloc: Allocator, - api_key: []const u8, + credential: ?[]const u8, cancel_flag: *std.atomic.Value(bool), authority: ReconciliationAuthority, providers: generation_usage.Set, max_attempts: usize, ) void { - if (api_key.len == 0 or cancel_flag.load(.seq_cst)) return; + if (credential) |api_key| if (api_key.len == 0) return; + if (cancel_flag.load(.seq_cst)) return; var attempt: usize = 0; while (attempt < max_attempts and !cancel_flag.load(.seq_cst)) : (attempt += 1) { var current = usage.snapshot(alloc) catch |err| { @@ -3026,7 +3071,7 @@ fn reconcilePendingBlocking( continue; }; var outcome = provider.lookup(alloc, .{ - .credential = api_key, + .credential = credential, .tenant = pending.team, .origin = pending.origin, .generation_id = pending.id, @@ -4570,7 +4615,8 @@ const TestGenerationUsageProvider = struct { const self: *@This() = @ptrCast(@alignCast(raw_context.?)); self.calls += 1; self.saw_expected_input = - std.mem.eql(u8, input.credential, "credential") and + input.credential != null and + std.mem.eql(u8, input.credential.?, "credential") and std.mem.eql( u8, input.generation_id, @@ -5729,3 +5775,17 @@ test "resumed provider reconciliation uses Gateway credential slot identity" { try std.testing.expect(usage.reconciliation_authority == null); try std.testing.expect(usage.reconciliation_credential_blocked); } + +test "host-managed reconciliation records authority without credential bytes" { + var usage = Usage.initFresh(); + defer usage.deinit(std.testing.allocator); + + usage.replaceHostManagedReconciliationAuthority(std.testing.allocator, .gateway); + + try std.testing.expect(usage.reconciliation_key_digest != null); + try std.testing.expectEqual(model_provider.ProviderId.gateway, usage.reconciliation_authority.?.provider); + try std.testing.expect(usage.reconciliation_authority.?.credential_identity.?.eql( + credential_authority.derive(.host_managed, null).?, + )); + try std.testing.expect(!usage.reconciliation_credential_blocked); +} diff --git a/src/core/shared/types.zig b/src/core/shared/types.zig index 3901bc51d..86908bff5 100644 --- a/src/core/shared/types.zig +++ b/src/core/shared/types.zig @@ -94,19 +94,34 @@ pub const CredentialSource = enum { stored_key, chatgpt_subscription, grok_subscription, + host_managed, }; pub fn parseCredentialSource(text: []const u8) ?CredentialSource { + const source = parseRuntimeCredentialSource(text) orelse return null; + return if (source == .host_managed) null else source; +} + +pub fn parseRuntimeCredentialSource(text: []const u8) ?CredentialSource { return std.meta.stringToEnum(CredentialSource, text); } test "credential source round trips through its persisted name" { for (std.meta.tags(CredentialSource)) |source| { + if (source == .host_managed) continue; try std.testing.expectEqual(source, parseCredentialSource(@tagName(source)).?); } try std.testing.expect(parseCredentialSource("keychain") == null); } +test "host-managed authority is runtime-only and cannot be persisted" { + try std.testing.expect(parseCredentialSource("host_managed") == null); + try std.testing.expectEqual( + CredentialSource.host_managed, + parseRuntimeCredentialSource("host_managed").?, + ); +} + pub const TurnPresentationOutcome = enum { completed, interrupted, diff --git a/src/core/tooling/tool_runtime.zig b/src/core/tooling/tool_runtime.zig index 3bf04dc0a..b98d8b390 100644 --- a/src/core/tooling/tool_runtime.zig +++ b/src/core/tooling/tool_runtime.zig @@ -286,6 +286,7 @@ pub const Context = struct { return permission_auto_classifier.Classifier.disabled(); return permission_auto_classifier.Classifier.withProvider(provider, .{ .credential = self.api_key, + .credential_source = self.credential_source, .account_id = self.account_id, .tenant = self.gateway_team, .endpoint = self.gateway_chat_url, @@ -7909,8 +7910,8 @@ const VisionGatewayFixture = struct { const payload = try test_builtin_gateway.buildAgentRequest(self.alloc, request.data()); defer self.alloc.free(payload); try self.payloads.append(self.alloc, try self.alloc.dupe(u8, payload)); - self.last_api_key = request.credential.secret; - self.last_team = request.credential.tenant; + self.last_api_key = request.credential.secret() orelse ""; + self.last_team = request.credential.tenant(); self.last_model = request.model; self.last_retry_count = request.retry_count; if (self.cancel_after_call == self.call_count) request.cancel_flag.store(true, .seq_cst); @@ -7930,11 +7931,11 @@ const VisionGatewayFixture = struct { .provider = .gateway, .generation_id = response.generation_id orelse "gen_test", .scope = "https://ai-gateway.vercel.sh", - .tenant = request.credential.tenant, - .credential_source = request.credential.source orelse .ai_gateway_api_key, + .tenant = request.credential.tenant(), + .credential_source = request.credential.credentialSource(), .credential_identity = credential_authority.derive( - request.credential.source orelse .ai_gateway_api_key, - request.credential.account_id, + request.credential.credentialSource(), + request.credential.accountId(), ), } }, } }; diff --git a/src/gateway/client.zig b/src/gateway/client.zig index c9bb298aa..149325463 100644 --- a/src/gateway/client.zig +++ b/src/gateway/client.zig @@ -262,7 +262,7 @@ pub fn fetchGatewayGetResult(alloc: std.mem.Allocator, api_key: ?[]const u8, pat pub fn fetchGatewayGenerationResult( alloc: std.mem.Allocator, - api_key: []const u8, + api_key: ?[]const u8, gateway_team: ?[]const u8, gateway_origin: []const u8, generation_id: []const u8, @@ -290,7 +290,7 @@ pub fn fetchGatewayGenerationResult( const GenerationLookupOperation = struct { alloc: std.mem.Allocator, - api_key: []const u8, + api_key: ?[]const u8, gateway_team: ?[]const u8, gateway_origin: []const u8, generation_id: []const u8, @@ -315,23 +315,23 @@ const GenerationLookupOperation = struct { .io = io_mod.getIo(), }; defer client.deinit(); - const auth_header = try std.fmt.allocPrint( - self.alloc, - "Bearer {s}", - .{self.api_key}, - ); - defer secret.zeroAndFree(self.alloc, auth_header); + var auth_header: ?[]u8 = null; + defer if (auth_header) |value| secret.zeroAndFree(self.alloc, value); + var headers: std.http.Client.Request.Headers = .{ + .accept_encoding = .omit, + .user_agent = .{ .override = user_agent }, + }; + if (self.api_key) |api_key| { + auth_header = try std.fmt.allocPrint(self.alloc, "Bearer {s}", .{api_key}); + headers.authorization = .{ .override = auth_header.? }; + } var extra_headers_buf: [1]std.http.Header = undefined; const extra_headers = gatewayModelCatalogExtraHeaders( &extra_headers_buf, self.gateway_team, ); var req = try client.request(.GET, uri, .{ - .headers = .{ - .authorization = .{ .override = auth_header }, - .accept_encoding = .omit, - .user_agent = .{ .override = user_agent }, - }, + .headers = headers, .extra_headers = extra_headers, .redirect_behavior = .unhandled, }); @@ -1137,7 +1137,7 @@ test "connection setup policy bounds retry by deadline attempts and delivery" { } pub const StreamRequest = struct { - api_key: []const u8, + api_key: ?[]const u8, model: []const u8, retry_count: usize, chat_url: []const u8, @@ -1345,8 +1345,17 @@ fn streamGatewayCompletionCoreWithOptions( const request_url = try resolveE2eGatewayUrl(e2e_gateway_chat_url_env, request.chat_url); const uri = try std.Uri.parse(request_url); - const auth_header = try std.fmt.allocPrint(alloc, "Bearer {s}", .{request.api_key}); - defer alloc.free(auth_header); + var auth_header: ?[]u8 = null; + defer if (auth_header) |value| secret.zeroAndFree(alloc, value); + var request_headers: std.http.Client.Request.Headers = .{ + .content_type = .{ .override = "application/json" }, + .accept_encoding = .omit, + .user_agent = .{ .override = user_agent }, + }; + if (request.api_key) |api_key| { + auth_header = try std.fmt.allocPrint(alloc, "Bearer {s}", .{api_key}); + request_headers.authorization = .{ .override = auth_header.? }; + } var extra_headers_buf: [9]std.http.Header = undefined; const extra_headers = gatewayExtraHeaders( @@ -1380,12 +1389,7 @@ fn streamGatewayCompletionCoreWithOptions( debug_trace.eventf("gateway", "before_http_open_connect", trace_ctx, "attempt={d} attempt_limit={d} retries_used={d}", .{ attempt + 1, retry_count, attempt }); debug_trace.eventf("gateway", "before_request_open", trace_ctx, "attempt={d} attempt_limit={d} retries_used={d} payload_bytes={d}", .{ attempt + 1, retry_count, attempt, payload.len }); var req = openGatewayRequestBounded(&client, uri, .{ - .headers = .{ - .content_type = .{ .override = "application/json" }, - .authorization = .{ .override = auth_header }, - .accept_encoding = .omit, - .user_agent = .{ .override = user_agent }, - }, + .headers = request_headers, .extra_headers = extra_headers, .keep_alive = false, .redirect_behavior = .unhandled, @@ -7729,3 +7733,43 @@ test "gateway chat request sends fx user agent and attribution headers" { try std.testing.expectEqualStrings("session_wire_123", fixture.capturedHeaderValue("x-session-affinity").?); try std.testing.expect(std.mem.find(u8, fixture.capturedHeaderValue("user-agent").?, "zig") == null); } + +test "host-managed Gateway chat omits authentication-owned headers" { + var fixture = try LoopbackGatewayFixture.init(.success_capture, 0); + defer fixture.deinit(); + try fixture.start(); + try std.testing.expect(fixture.waitForAcceptStart(5000)); + + const url = try std.fmt.allocPrint(std.testing.allocator, "http://127.0.0.1:{d}/chat", .{fixture.port()}); + defer std.testing.allocator.free(url); + + const Noop = struct { + fn onChunk(_: *anyopaque, _: []const u8) void {} + }; + var callback_ctx: u8 = 0; + var cancel_flag = std.atomic.Value(bool).init(false); + var result = try streamGatewayCompletionCore( + std.testing.allocator, + .{ + .api_key = null, + .model = "test/model", + .retry_count = 1, + .chat_url = url, + .payload = "{}", + .team = null, + }, + @ptrCast(&callback_ctx), + Noop.onChunk, + null, + &cancel_flag, + null, + false, + ); + defer result.deinit(std.testing.allocator); + fixture.deinit(); + + if (fixture.failure) |err| return err; + try std.testing.expect(fixture.capturedHeaderValue("authorization") == null); + try std.testing.expect(fixture.capturedHeaderValue(vercel_ai_gateway_team_header) == null); + try std.testing.expectEqualStrings(user_agent, fixture.capturedHeaderValue("user-agent").?); +} diff --git a/src/gateway/host_stream_provider.zig b/src/gateway/host_stream_provider.zig index 9803d11d4..41d35b36a 100644 --- a/src/gateway/host_stream_provider.zig +++ b/src/gateway/host_stream_provider.zig @@ -70,15 +70,17 @@ fn stream(raw: ?*anyopaque, alloc: Allocator, request: stream_provider.ModelRequ const transport = context.transport; const payload = try context.build_fn(alloc, request.data()); defer alloc.free(payload); - const auth = try std.fmt.allocPrint(alloc, "Bearer {s}", .{request.credential.secret}); - defer alloc.free(auth); + const auth = if (request.credential.secret()) |credential| + try std.fmt.allocPrint(alloc, "Bearer {s}", .{credential}) + else + null; + defer if (auth) |value| alloc.free(value); const Header = struct { name: []const u8, value: []const u8 }; var headers: std.ArrayList(Header) = .empty; defer headers.deinit(alloc); try headers.appendSlice(alloc, &.{ .{ .name = "content-type", .value = "application/json" }, - .{ .name = "authorization", .value = auth }, .{ .name = "HTTP-Referer", .value = "https://github.com/vercel-labs/fx" }, .{ .name = "X-Title", .value = "fx" }, .{ .name = "ai-gateway-protocol-version", .value = "0.0.1" }, @@ -86,7 +88,8 @@ fn stream(raw: ?*anyopaque, alloc: Allocator, request: stream_provider.ModelRequ .{ .name = "ai-language-model-id", .value = request.model }, .{ .name = "ai-language-model-streaming", .value = "true" }, }); - if (request.credential.tenant) |team| if (team.len > 0) try headers.append(alloc, .{ .name = "x-vercel-ai-gateway-team", .value = team }); + if (auth) |value| try headers.append(alloc, .{ .name = "authorization", .value = value }); + if (request.credential.tenant()) |team| if (team.len > 0) try headers.append(alloc, .{ .name = "x-vercel-ai-gateway-team", .value = team }); if (request.session_id) |session_id| if (session_id.len > 0) try headers.appendSlice(alloc, &.{ .{ .name = "x-session-id", .value = session_id }, .{ .name = "x-session-affinity", .value = session_id }, @@ -160,17 +163,17 @@ fn gatewayUsageReference( completion: @import("../core/shared/types.zig").ModelCompletion, ) ?stream_provider.DeferredUsageReference { const generation_id = completion.generation_id orelse return null; - const source = request.credential.source orelse return null; + const source = request.credential.credentialSource(); return .{ .provider = .gateway, .generation_id = generation_id, .scope = gateway_client.generationBaseUrl(), - .tenant = request.credential.tenant, - .account_id = request.credential.account_id, + .tenant = request.credential.tenant(), + .account_id = request.credential.accountId(), .credential_source = source, .credential_identity = credential_authority.derive( source, - request.credential.account_id, + request.credential.accountId(), ), }; } diff --git a/src/gateway/openai_codex.zig b/src/gateway/openai_codex.zig index 95e3356ed..4df4a6339 100644 --- a/src/gateway/openai_codex.zig +++ b/src/gateway/openai_codex.zig @@ -134,7 +134,9 @@ fn streamCompletion( request: stream_provider.ModelRequest, ) !stream_provider.Result { if (request.cancel_flag.load(.seq_cst)) return stream_provider.failResult(error.Cancelled); - if (request.credential.source != .chatgpt_subscription) { + if (request.credential.credentialSource() != .chatgpt_subscription and + request.credential.credentialSource() != .host_managed) + { return stream_provider.failResult(error.CodexSubscriptionCredentialRequired); } try validateModel(request.model); @@ -165,17 +167,20 @@ const OpenedRequest = struct { const OpenRequestOperation = struct { client: *std.http.Client, uri: std.Uri, - auth_header: []const u8, + auth_header: ?[]const u8, extra_headers: []const std.http.Header, pub fn run(self: *@This()) !OpenedRequest { + var headers: std.http.Client.Request.Headers = .{ + .content_type = .{ .override = "application/json" }, + .accept_encoding = .omit, + .user_agent = .{ .override = gateway_client.user_agent }, + }; + if (self.auth_header) |authorization| { + headers.authorization = .{ .override = authorization }; + } return .{ .request = try self.client.request(.POST, self.uri, .{ - .headers = .{ - .content_type = .{ .override = "application/json" }, - .authorization = .{ .override = self.auth_header }, - .accept_encoding = .omit, - .user_agent = .{ .override = gateway_client.user_agent }, - }, + .headers = headers, .extra_headers = self.extra_headers, .keep_alive = false, .redirect_behavior = .unhandled, @@ -183,16 +188,43 @@ const OpenRequestOperation = struct { } }; +const RequestAuthHeaders = struct { + authorization: ?[]u8 = null, + account_id: ?[]u8 = null, + + fn deinit(self: *RequestAuthHeaders, alloc: Allocator) void { + if (self.authorization) |value| secret.zeroAndFree(alloc, value); + if (self.account_id) |value| alloc.free(value); + self.* = .{}; + } +}; + +fn requestAuthHeaders(alloc: Allocator, auth: stream_provider.CredentialLease) !RequestAuthHeaders { + return switch (auth) { + .host_managed => .{}, + .direct => |direct| blk: { + const authorization = try std.fmt.allocPrint(alloc, "Bearer {s}", .{direct.secret_bytes}); + errdefer secret.zeroAndFree(alloc, authorization); + const account_id = if (direct.account_id) |account| + try alloc.dupe(u8, account) + else + try chatgpt_oauth.extractAccountId(alloc, direct.secret_bytes); + break :blk .{ + .authorization = authorization, + .account_id = account_id, + }; + }, + }; +} + pub fn streamPrepared( alloc: Allocator, request: stream_provider.ModelRequest, payload: []const u8, ) !stream_provider.Result { if (request.cancel_flag.load(.seq_cst)) return stream_provider.failResult(error.Cancelled); - const account_id = try chatgpt_oauth.extractAccountId(alloc, request.credential.secret); - defer alloc.free(account_id); - const auth_header = try std.fmt.allocPrint(alloc, "Bearer {s}", .{request.credential.secret}); - defer secret.zeroAndFree(alloc, auth_header); + var auth_headers = try requestAuthHeaders(alloc, request.credential); + defer auth_headers.deinit(alloc); const request_endpoint = if (io_mod.getenv(e2e_endpoint_env)) |override| endpoint: { if (!gateway_client.isLoopbackHttpUrl(override)) { return stream_provider.failResult(error.InvalidE2EOpenAICodexEndpoint); @@ -203,8 +235,10 @@ pub fn streamPrepared( var extra_headers_buf: [7]std.http.Header = undefined; var extra_count: usize = 0; - extra_headers_buf[extra_count] = .{ .name = "chatgpt-account-id", .value = account_id }; - extra_count += 1; + if (auth_headers.account_id) |account_id| { + extra_headers_buf[extra_count] = .{ .name = "chatgpt-account-id", .value = account_id }; + extra_count += 1; + } extra_headers_buf[extra_count] = .{ .name = "originator", .value = "fx" }; extra_count += 1; extra_headers_buf[extra_count] = .{ .name = "OpenAI-Beta", .value = "responses=experimental" }; @@ -223,7 +257,7 @@ pub fn streamPrepared( var open_operation = OpenRequestOperation{ .client = &client, .uri = uri, - .auth_header = auth_header, + .auth_header = auth_headers.authorization, .extra_headers = extra_headers_buf[0..extra_count], }; const connect_deadline = std.Io.Clock.Timestamp.fromNow(io_mod.getIo(), .{ @@ -662,7 +696,7 @@ test "OpenAI Codex rejects a wrong-origin credential before network I/O" { try std.testing.expectError( error.CodexSubscriptionCredentialRequired, agent_stream_provider.stream(std.testing.allocator, .{ - .credential = .{ .secret = "gateway-key", .source = .ai_gateway_api_key }, + .credential = .{ .direct = .{ .secret_bytes = "gateway-key", .source = .ai_gateway_api_key } }, .model = "gpt-5.6-sol", .retry_count = 1, .messages = &.{}, @@ -681,6 +715,14 @@ test "OpenAI Codex rejects a wrong-origin credential before network I/O" { try std.testing.expectEqual(stream_provider.DeliveryCertainty.State.definitely_unsent, delivery.load()); } +test "host-managed Codex request auth omits bearer and account headers" { + var headers = try requestAuthHeaders(std.testing.allocator, .host_managed); + defer headers.deinit(std.testing.allocator); + + try std.testing.expect(headers.authorization == null); + try std.testing.expect(headers.account_id == null); +} + test "OpenAI Codex SSE maps text reasoning tools and usage" { const sse_text = "data: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"type\":\"reasoning\"}}\n\n" ++ diff --git a/src/gateway/openai_codex_models.zig b/src/gateway/openai_codex_models.zig index b6992e190..832f06a4e 100644 --- a/src/gateway/openai_codex_models.zig +++ b/src/gateway/openai_codex_models.zig @@ -1,5 +1,6 @@ const std = @import("std"); const chatgpt_oauth = @import("../core/auth/chatgpt_oauth.zig"); +const credentials = @import("../core/auth/credentials.zig"); const model_catalog = @import("../core/gateway/model_catalog.zig"); const gateway_provider = @import("../core/gateway/gateway_provider.zig"); const io_mod = @import("../core/shared/io.zig"); @@ -58,16 +59,17 @@ fn fetchCatalogForProvider( alloc: std.mem.Allocator, input: model_catalog.FetchInput, ) std.mem.Allocator.Error!model_catalog.ProviderResult { - if (input.access.credentialSource() != .chatgpt_subscription) { + const request_auth = catalogRequestAuth(input.access) orelse return .{ .failure = .{ .category = .authentication, .http_status = .unauthorized } }; - } - const credential = input.access.authorizationCredential() orelse - return .{ .failure = .{ .category = .authentication, .http_status = .unauthorized } }; - const account_id = chatgpt_oauth.extractAccountId(alloc, credential) catch |err| { - if (err == error.OutOfMemory) return error.OutOfMemory; - return .{ .failure = .{ .category = .authentication, .http_status = .unauthorized } }; - }; - defer alloc.free(account_id); + var owned_account_id: ?[]u8 = null; + defer if (owned_account_id) |account| alloc.free(account); + const account_id = request_auth.account_id orelse if (request_auth.credential) |credential| account: { + owned_account_id = chatgpt_oauth.extractAccountId(alloc, credential) catch |err| { + if (err == error.OutOfMemory) return error.OutOfMemory; + return .{ .failure = .{ .category = .authentication, .http_status = .unauthorized } }; + }; + break :account owned_account_id.?; + } else null; const request_url = modelsUrl(alloc) catch |err| { if (err == error.OutOfMemory) return error.OutOfMemory; return .{ .failure = .{ .category = .runtime } }; @@ -79,7 +81,7 @@ fn fetchCatalogForProvider( var operation = FetchOperation{ .alloc = alloc, .url = request_url, - .credential = credential, + .credential = request_auth.credential, .account_id = account_id, }; var response = gateway_client.runBoundedHttpOperation( @@ -120,6 +122,25 @@ fn fetchCatalogForProvider( return .{ .catalog = catalog }; } +const CatalogRequestAuth = struct { + credential: ?[]const u8 = null, + account_id: ?[]const u8 = null, +}; + +fn catalogRequestAuth(access: credentials.CatalogAccess) ?CatalogRequestAuth { + return switch (access) { + .host_managed => .{}, + .public_only => null, + .authenticated => |authenticated| if (authenticated.source == .chatgpt_subscription) + .{ + .credential = authenticated.credential, + .account_id = authenticated.account_id, + } + else + null, + }; +} + const FetchResponse = struct { status: std.http.Status, body: []u8, @@ -133,30 +154,40 @@ const FetchResponse = struct { const FetchOperation = struct { alloc: std.mem.Allocator, url: []const u8, - credential: []const u8, - account_id: []const u8, + credential: ?[]const u8, + account_id: ?[]const u8, pub fn run(self: *@This()) !FetchResponse { var client: std.http.Client = .{ .allocator = self.alloc, .io = io_mod.getIo() }; defer client.deinit(); - const auth_header = try std.fmt.allocPrint(self.alloc, "Bearer {s}", .{self.credential}); - defer secret.zeroAndFree(self.alloc, auth_header); + var auth_header: ?[]u8 = null; + defer if (auth_header) |value| secret.zeroAndFree(self.alloc, value); + var headers: std.http.Client.Request.Headers = .{ + .user_agent = .{ .override = gateway_client.user_agent }, + .accept_encoding = .omit, + }; + if (self.credential) |credential| { + auth_header = try std.fmt.allocPrint(self.alloc, "Bearer {s}", .{credential}); + headers.authorization = .{ .override = auth_header.? }; + } const body_buffer = try self.alloc.alloc(u8, max_catalog_bytes + 1); defer secret.zeroAndFree(self.alloc, body_buffer); var response_writer = std.Io.Writer.fixed(body_buffer); + var extra_headers: [3]std.http.Header = undefined; + var extra_len: usize = 0; + if (self.account_id) |account_id| { + extra_headers[extra_len] = .{ .name = "chatgpt-account-id", .value = account_id }; + extra_len += 1; + } + extra_headers[extra_len] = .{ .name = "originator", .value = "fx" }; + extra_len += 1; + extra_headers[extra_len] = .{ .name = "accept", .value = "application/json" }; + extra_len += 1; const result = client.fetch(.{ .location = .{ .url = self.url }, .method = .GET, - .headers = .{ - .authorization = .{ .override = auth_header }, - .user_agent = .{ .override = gateway_client.user_agent }, - .accept_encoding = .omit, - }, - .extra_headers = &.{ - .{ .name = "chatgpt-account-id", .value = self.account_id }, - .{ .name = "originator", .value = "fx" }, - .{ .name = "accept", .value = "application/json" }, - }, + .headers = headers, + .extra_headers = extra_headers[0..extra_len], .response_writer = &response_writer, .redirect_behavior = .unhandled, }) catch |err| switch (err) { @@ -324,3 +355,9 @@ test "Codex catalog URL uses the live-validated protocol compatibility version" try std.testing.expect(std.mem.find(u8, url, "client_version=0.148.0") != null); try std.testing.expect(std.mem.find(u8, url, "client_version=0.0.4") == null); } + +test "host-managed Codex catalog auth carries no local headers" { + const auth = catalogRequestAuth(.host_managed) orelse return error.TestExpectedHostManagedCatalogAuth; + try std.testing.expect(auth.credential == null); + try std.testing.expect(auth.account_id == null); +} diff --git a/src/gateway/responses_permission_reviewer.zig b/src/gateway/responses_permission_reviewer.zig index 5f85f6da0..30038d0f5 100644 --- a/src/gateway/responses_permission_reviewer.zig +++ b/src/gateway/responses_permission_reviewer.zig @@ -40,12 +40,13 @@ pub fn review( request: permission_auto_classifier.ReviewRequest, adapter: Adapter, ) !permission_auto_classifier.ParseOutcome { - if (input.credential.len == 0) return .invalid; - if (adapter.require_account and input.account_id == null) return .invalid; - adapter.validate_fn(alloc, input) catch |err| { - if (err == error.OutOfMemory) return error.OutOfMemory; - return .invalid; - }; + if (!reviewInputAuthorized(input, adapter.require_account)) return .invalid; + if (input.credential_source != .host_managed) { + adapter.validate_fn(alloc, input) catch |err| { + if (err == error.OutOfMemory) return error.OutOfMemory; + return .invalid; + }; + } var runtime = Runtime{ .input = input, .adapter = adapter }; return permission_auto_classifier.Reviewer.withTransportModel( .{ @@ -59,6 +60,15 @@ pub fn review( ).review(alloc, request); } +fn reviewInputAuthorized( + input: permission_auto_classifier.ProviderInput, + require_account: bool, +) bool { + if (input.credential_source == .host_managed) return true; + if (input.credential.len == 0) return false; + return !require_account or input.account_id != null; +} + fn buildReviewPayload( raw: *anyopaque, alloc: Allocator, @@ -122,6 +132,12 @@ pub fn buildPayloadForTest( fn validateUnavailable(_: Allocator, _: permission_auto_classifier.ProviderInput) !void {} +test "host-managed permission review accepts absent local credential metadata" { + try std.testing.expect(reviewInputAuthorized(.{ + .credential_source = .host_managed, + }, true)); +} + const OwnedResult = struct { result: stream_provider.Result, }; @@ -171,12 +187,15 @@ fn sendReview( }; var callback_context: u8 = 0; var result = runtime.adapter.send_fn(alloc, .{ - .credential = .{ - .secret = runtime.input.credential, - .source = runtime.adapter.source, - .account_id = runtime.input.account_id, - .tenant = runtime.input.tenant, - }, + .credential = if (runtime.input.credential_source == .host_managed) + .host_managed + else + .{ .direct = .{ + .secret_bytes = runtime.input.credential, + .source = runtime.adapter.source, + .account_id = runtime.input.account_id, + .tenant_context = runtime.input.tenant, + } }, .model = model, .retry_count = 1, .messages = &.{}, diff --git a/src/gateway/xai_grok.zig b/src/gateway/xai_grok.zig index 31597fbf3..a58bd557d 100644 --- a/src/gateway/xai_grok.zig +++ b/src/gateway/xai_grok.zig @@ -121,13 +121,17 @@ fn streamCompletion( request: stream_provider.ModelRequest, ) !stream_provider.Result { if (request.cancel_flag.load(.seq_cst)) return stream_provider.failResult(error.Cancelled); - if (request.credential.source != .grok_subscription) { + if (request.credential.credentialSource() != .grok_subscription and + request.credential.credentialSource() != .host_managed) + { return stream_provider.failResult(error.GrokSubscriptionCredentialRequired); } - const account_id = request.credential.account_id orelse - return stream_provider.failResult(error.GrokSubscriptionAccountRequired); - if (!grok_session.validAccountId(account_id)) { - return stream_provider.failResult(error.InvalidGrokSubscriptionAccount); + if (request.credential.credentialSource() != .host_managed) { + const account_id = request.credential.accountId() orelse + return stream_provider.failResult(error.GrokSubscriptionAccountRequired); + if (!grok_session.validAccountId(account_id)) { + return stream_provider.failResult(error.InvalidGrokSubscriptionAccount); + } } try validateModel(request.model); const payload = try buildRequest(alloc, request.data()); @@ -169,17 +173,20 @@ const OpenedRequest = struct { const OpenRequestOperation = struct { client: *std.http.Client, uri: std.Uri, - auth_header: []const u8, + auth_header: ?[]const u8, extra_headers: []const std.http.Header, pub fn run(self: *@This()) !OpenedRequest { + var headers: std.http.Client.Request.Headers = .{ + .content_type = .{ .override = "application/json" }, + .accept_encoding = .omit, + .user_agent = .{ .override = gateway_client.user_agent }, + }; + if (self.auth_header) |authorization| { + headers.authorization = .{ .override = authorization }; + } return .{ .request = try self.client.request(.POST, self.uri, .{ - .headers = .{ - .content_type = .{ .override = "application/json" }, - .authorization = .{ .override = self.auth_header }, - .accept_encoding = .omit, - .user_agent = .{ .override = gateway_client.user_agent }, - }, + .headers = headers, .extra_headers = self.extra_headers, .keep_alive = false, .redirect_behavior = .unhandled, @@ -187,15 +194,36 @@ const OpenRequestOperation = struct { } }; +const RequestAuthHeaders = struct { + authorization: ?[]u8 = null, + account_id: ?[]const u8 = null, + include_subscription_headers: bool = false, + + fn deinit(self: *RequestAuthHeaders, alloc: Allocator) void { + if (self.authorization) |value| secret.zeroAndFree(alloc, value); + self.* = .{}; + } +}; + +fn requestAuthHeaders(alloc: Allocator, auth: stream_provider.CredentialLease) !RequestAuthHeaders { + return switch (auth) { + .host_managed => .{}, + .direct => |direct| .{ + .authorization = try std.fmt.allocPrint(alloc, "Bearer {s}", .{direct.secret_bytes}), + .account_id = direct.account_id, + .include_subscription_headers = true, + }, + }; +} + pub fn streamPrepared( alloc: Allocator, request: stream_provider.ModelRequest, payload: []const u8, ) !stream_provider.Result { if (request.cancel_flag.load(.seq_cst)) return stream_provider.failResult(error.Cancelled); - const account_id = request.credential.account_id.?; - const auth_header = try std.fmt.allocPrint(alloc, "Bearer {s}", .{request.credential.secret}); - defer secret.zeroAndFree(alloc, auth_header); + var auth_headers = try requestAuthHeaders(alloc, request.credential); + defer auth_headers.deinit(alloc); const request_endpoint = if (io_mod.getenv(e2e_endpoint_env)) |override| endpoint: { if (!gateway_client.isLoopbackHttpUrl(override)) { return stream_provider.failResult(error.InvalidE2EXaiGrokEndpoint); @@ -208,18 +236,22 @@ pub fn streamPrepared( var extra_count: usize = 0; extra_headers_buf[extra_count] = .{ .name = "accept", .value = "text/event-stream" }; extra_count += 1; - extra_headers_buf[extra_count] = .{ .name = "X-XAI-Token-Auth", .value = "xai-grok-cli" }; - extra_count += 1; - extra_headers_buf[extra_count] = .{ .name = "x-authenticateresponse", .value = "authenticate-response" }; - extra_count += 1; + if (auth_headers.include_subscription_headers) { + extra_headers_buf[extra_count] = .{ .name = "X-XAI-Token-Auth", .value = "xai-grok-cli" }; + extra_count += 1; + extra_headers_buf[extra_count] = .{ .name = "x-authenticateresponse", .value = "authenticate-response" }; + extra_count += 1; + } extra_headers_buf[extra_count] = .{ .name = "x-grok-client-version", .value = proxy_compatibility_version }; extra_count += 1; extra_headers_buf[extra_count] = .{ .name = "x-grok-client-identifier", .value = "fx" }; extra_count += 1; extra_headers_buf[extra_count] = .{ .name = "x-grok-model-override", .value = request.model }; extra_count += 1; - extra_headers_buf[extra_count] = .{ .name = "x-grok-user-id", .value = account_id }; - extra_count += 1; + if (auth_headers.account_id) |account_id| { + extra_headers_buf[extra_count] = .{ .name = "x-grok-user-id", .value = account_id }; + extra_count += 1; + } if (request.session_id) |session_id| if (session_id.len > 0) { extra_headers_buf[extra_count] = .{ .name = "x-grok-conv-id", .value = session_id }; extra_count += 1; @@ -230,7 +262,7 @@ pub fn streamPrepared( var open_operation = OpenRequestOperation{ .client = &client, .uri = uri, - .auth_header = auth_header, + .auth_header = auth_headers.authorization, .extra_headers = extra_headers_buf[0..extra_count], }; var connect_deadline = std.Io.Clock.Timestamp.fromNow(io_mod.getIo(), .{ @@ -645,11 +677,11 @@ fn testModelRequest( callback_context: *u8, ) stream_provider.ModelRequest { return .{ - .credential = .{ - .secret = secret_value, + .credential = .{ .direct = .{ + .secret_bytes = secret_value, .source = source, .account_id = account_id, - }, + } }, .model = "grok-4.20", .retry_count = 1, .messages = &.{}, @@ -956,6 +988,15 @@ fn ignoreTestChunk(_: *anyopaque, _: []const u8) void {} fn ignoreTestEvent(_: *anyopaque, _: stream_provider.Event) void {} fn admitTestRequest(_: *anyopaque) !void {} +test "host-managed Grok request auth omits bearer and subscription headers" { + var headers = try requestAuthHeaders(std.testing.allocator, .host_managed); + defer headers.deinit(std.testing.allocator); + + try std.testing.expect(headers.authorization == null); + try std.testing.expect(headers.account_id == null); + try std.testing.expect(!headers.include_subscription_headers); +} + test "xAI Grok error-body reader accepts the exact bound and replaces one beyond" { inline for (.{ TestResponseMode.error_body_exact, TestResponseMode.error_body_excess }) |mode| { var fixture = try TestResponseFixture.init(mode); diff --git a/src/gateway/xai_grok_models.zig b/src/gateway/xai_grok_models.zig index 3394a6f70..0ec969c97 100644 --- a/src/gateway/xai_grok_models.zig +++ b/src/gateway/xai_grok_models.zig @@ -58,15 +58,12 @@ fn fetchCatalogForProvider( alloc: std.mem.Allocator, input: model_catalog.FetchInput, ) std.mem.Allocator.Error!model_catalog.ProviderResult { - if (input.access.credentialSource() != .grok_subscription) { - return .{ .failure = .{ .category = .authentication, .http_status = .unauthorized } }; - } - const credential = input.access.authorizationCredential() orelse - return .{ .failure = .{ .category = .authentication, .http_status = .unauthorized } }; - const account_id = input.access.accountId() orelse - return .{ .failure = .{ .category = .authentication, .http_status = .unauthorized } }; - if (!grok_session.validAccountId(account_id)) { + const request_auth = catalogRequestAuth(input.access) orelse return .{ .failure = .{ .category = .authentication, .http_status = .unauthorized } }; + if (request_auth.account_id) |account_id| { + if (!grok_session.validAccountId(account_id)) { + return .{ .failure = .{ .category = .authentication, .http_status = .unauthorized } }; + } } const request_url = modelsUrl(alloc) catch |err| { if (err == error.OutOfMemory) return error.OutOfMemory; @@ -88,8 +85,9 @@ fn fetchCatalogForProvider( var response = fetchCatalogResponse( alloc, request_url, - credential, - account_id, + request_auth.credential, + request_auth.account_id, + request_auth.include_subscription_headers, cancel_flag, deadline, ) catch |err| { @@ -103,8 +101,9 @@ fn fetchCatalogForProvider( var modalities_response = fetchCatalogResponse( alloc, modalities_url, - credential, + request_auth.credential, null, + false, cancel_flag, deadline, ) catch |err| { @@ -122,6 +121,28 @@ fn fetchCatalogForProvider( return .{ .catalog = catalog }; } +const CatalogRequestAuth = struct { + credential: ?[]const u8 = null, + account_id: ?[]const u8 = null, + include_subscription_headers: bool = false, +}; + +fn catalogRequestAuth(access: credentials.CatalogAccess) ?CatalogRequestAuth { + return switch (access) { + .host_managed => .{}, + .public_only => null, + .authenticated => |authenticated| if (authenticated.source == .grok_subscription and + authenticated.account_id != null) + .{ + .credential = authenticated.credential, + .account_id = authenticated.account_id, + .include_subscription_headers = true, + } + else + null, + }; +} + fn catalogFetchFailure(err: anyerror) model_catalog.Failure { if (err == error.Cancelled) return .{ .category = .cancellation }; if (err == error.GrokModelCatalogTooLarge) return .{ .category = .malformed_response }; @@ -141,14 +162,23 @@ const FetchResponse = struct { const FetchOperation = struct { alloc: std.mem.Allocator, url: []const u8, - credential: []const u8, + credential: ?[]const u8, account_id: ?[]const u8, + include_subscription_headers: bool, pub fn run(self: *@This()) !FetchResponse { var client: std.http.Client = .{ .allocator = self.alloc, .io = io_mod.getIo() }; defer client.deinit(); - const auth_header = try std.fmt.allocPrint(self.alloc, "Bearer {s}", .{self.credential}); - defer secret.zeroAndFree(self.alloc, auth_header); + var auth_header: ?[]u8 = null; + defer if (auth_header) |value| secret.zeroAndFree(self.alloc, value); + var headers: std.http.Client.Request.Headers = .{ + .user_agent = .{ .override = gateway_client.user_agent }, + .accept_encoding = .omit, + }; + if (self.credential) |credential| { + auth_header = try std.fmt.allocPrint(self.alloc, "Bearer {s}", .{credential}); + headers.authorization = .{ .override = auth_header.? }; + } const body_buffer = try self.alloc.alloc(u8, max_catalog_bytes + 1); defer secret.zeroAndFree(self.alloc, body_buffer); var response_writer = std.Io.Writer.fixed(body_buffer); @@ -156,20 +186,18 @@ const FetchOperation = struct { var extra_headers_len: usize = 0; extra_headers_buffer[extra_headers_len] = .{ .name = "accept", .value = "application/json" }; extra_headers_len += 1; - if (self.account_id) |account_id| { + if (self.include_subscription_headers) { extra_headers_buffer[extra_headers_len] = .{ .name = "X-XAI-Token-Auth", .value = "xai-grok-cli" }; extra_headers_len += 1; + } + if (self.account_id) |account_id| { extra_headers_buffer[extra_headers_len] = .{ .name = "x-userid", .value = account_id }; extra_headers_len += 1; } const result = client.fetch(.{ .location = .{ .url = self.url }, .method = .GET, - .headers = .{ - .authorization = .{ .override = auth_header }, - .user_agent = .{ .override = gateway_client.user_agent }, - .accept_encoding = .omit, - }, + .headers = headers, .extra_headers = extra_headers_buffer[0..extra_headers_len], .response_writer = &response_writer, .redirect_behavior = .unhandled, @@ -189,8 +217,9 @@ const FetchOperation = struct { fn fetchCatalogResponse( alloc: std.mem.Allocator, url: []const u8, - credential: []const u8, + credential: ?[]const u8, account_id: ?[]const u8, + include_subscription_headers: bool, cancel_flag: *std.atomic.Value(bool), deadline: std.Io.Clock.Timestamp, ) !FetchResponse { @@ -199,6 +228,7 @@ fn fetchCatalogResponse( .url = url, .credential = credential, .account_id = account_id, + .include_subscription_headers = include_subscription_headers, }; return gateway_client.runBoundedHttpOperation( FetchResponse, @@ -645,6 +675,13 @@ test "Grok catalog fixture cleanup joins without a client" { try std.testing.expect(fixture.failure == null); } +test "host-managed Grok catalog auth carries no local headers" { + const auth = catalogRequestAuth(.host_managed) orelse return error.TestExpectedHostManagedCatalogAuth; + try std.testing.expect(auth.credential == null); + try std.testing.expect(auth.account_id == null); + try std.testing.expect(!auth.include_subscription_headers); +} + var stable_catalog_test_environ: ?*std.process.Environ.Map = null; fn stableCatalogTestEnviron() !*const std.process.Environ.Map { @@ -723,6 +760,7 @@ fn fetchCatalogFixture(body: []const u8) !FetchResponse { .url = url, .credential = "grok-test-token", .account_id = "acct_test", + .include_subscription_headers = true, }; const result = operation.run(); fixture.deinit(); diff --git a/src/main.zig b/src/main.zig index b5314ce1d..e4309320e 100644 --- a/src/main.zig +++ b/src/main.zig @@ -595,7 +595,11 @@ const App = struct { return null; } - pub fn init(alloc: Allocator, launch: *cli_surface.InteractiveLaunch) !Self { + pub fn init( + alloc: Allocator, + launch: *cli_surface.InteractiveLaunch, + auth_mode: credentials.AuthMode, + ) !Self { var app = Self{ .alloc = alloc, .auth = undefined, @@ -613,11 +617,12 @@ const App = struct { else background_process.provider), }; - auth_runtime.Runtime.initInto( + auth_runtime.Runtime.initIntoWithMode( &app.auth, app_api_key_validator, app_oauth_transport, app_secret_store, + auth_mode, ); usage_dashboard_runtime.Runtime.initInto(&app.usage_dashboard, std.heap.c_allocator); app_session_runtime.Persistence.initInto(&app.session_persistence); @@ -1395,7 +1400,10 @@ const App = struct { errdefer std.heap.c_allocator.free(model_copy); const gateway_credential = self.auth.gatewayCredential() orelse return error.MissingApiKey; - const api_key_copy = try std.heap.c_allocator.dupe(u8, gateway_credential.api_key); + const api_key_copy = if (gateway_credential.api_key) |api_key| + try std.heap.c_allocator.dupe(u8, api_key) + else + @constCast(&[_]u8{}); errdefer secret.zeroAndFree(std.heap.c_allocator, api_key_copy); const gateway_team_copy = if (gateway_credential.gateway_team) |team| @@ -3129,7 +3137,7 @@ pub fn runWasmTerminal(init: std.process.Init) !void { }, }; defer launch.deinit(alloc); - const outcome = try app_entry_runtime.runInteractiveCooperative(App, alloc, &launch); + const outcome = try app_entry_runtime.runInteractiveCooperative(App, alloc, &launch, .local); switch (outcome) { .returned => {}, .exit => |code| if (code != 0) return error.WasmTerminalExited, @@ -3266,12 +3274,16 @@ fn runNonBenchmark(raw_args: []const [*:0]const u8, raw_env: RawEnviron, cli_arg io_mod.setRawEnviron(raw_env); const alloc = processAllocator(); + const auth_mode = credentials.parseAuthMode(rawEnvValue(raw_env, "FX_AUTH_MODE")) catch { + try writeStderrFast("fx: FX_AUTH_MODE must be local or host-managed\n"); + exitFast(1); + }; const cfg = if (cli_args.len == 0) - emptyEntryConfig() + emptyEntryConfig(auth_mode) else if (needsFullEntryConfig(cli_args)) - fullEntryConfig() + fullEntryConfig(auth_mode) else - localEntryConfig(); + localEntryConfig(auth_mode); var early_threaded: ?std.Io.Threaded = null; defer if (early_threaded) |*threaded| threaded.deinit(); @@ -3301,7 +3313,7 @@ fn runNonBenchmark(raw_args: []const [*:0]const u8, raw_env: RawEnviron, cli_arg defer owned_launch.deinit(alloc); defer debug_trace.shutdown(); - const outcome = try app_entry_runtime.runInteractive(App, alloc, &owned_launch); + const outcome = try app_entry_runtime.runInteractive(App, alloc, &owned_launch, auth_mode); switch (outcome) { .returned => return, .exit => |code| std.process.exit(code), @@ -3607,11 +3619,12 @@ test "native app preserves the built-in tool set without workspace metadata" { try std.testing.expectEqual(builtin_tools.advertisement_set.order.len, advertised.order.len); } -fn fullEntryConfig() app_entry_runtime.Config { +fn fullEntryConfig(auth_mode: credentials.AuthMode) app_entry_runtime.Config { return .{ .version = version, .revision = build_options.git_commit, .build_channel = compiled_update_channel, + .auth_mode = auth_mode, .command_catalog = builtin_commands.top_level_registry, .default_model = builtin_gateway.default_model, .default_agent_step_limit = default_max_agent_steps, @@ -3645,11 +3658,12 @@ fn fullEntryConfig() app_entry_runtime.Config { }; } -fn localEntryConfig() app_entry_runtime.Config { +fn localEntryConfig(auth_mode: credentials.AuthMode) app_entry_runtime.Config { return .{ .version = version, .revision = build_options.git_commit, .build_channel = compiled_update_channel, + .auth_mode = auth_mode, .command_catalog = builtin_commands.top_level_registry, .default_model = builtin_gateway.default_model, .default_agent_step_limit = default_max_agent_steps, @@ -3683,11 +3697,12 @@ fn localEntryConfig() app_entry_runtime.Config { }; } -fn emptyEntryConfig() app_entry_runtime.Config { +fn emptyEntryConfig(auth_mode: credentials.AuthMode) app_entry_runtime.Config { return .{ .version = version, .revision = build_options.git_commit, .build_channel = compiled_update_channel, + .auth_mode = auth_mode, .command_catalog = builtin_commands.top_level_registry, .default_model = "", .default_agent_step_limit = 0, diff --git a/src/ui/footer/model_menu_presentation.zig b/src/ui/footer/model_menu_presentation.zig index f566b2f7c..31c74505f 100644 --- a/src/ui/footer/model_menu_presentation.zig +++ b/src/ui/footer/model_menu_presentation.zig @@ -418,6 +418,7 @@ fn loadedCatalogStatusText(state: model_cache_runtime.ModelMenuCatalogState) ?[] .stored_key => "Gateway catalog: authenticated with the stored API key.", .chatgpt_subscription => "Codex catalog: authenticated with a subscription.", .grok_subscription => "Grok catalog: authenticated with a subscription.", + .host_managed => "Provider catalog: authentication is managed by the host.", }; } return null; diff --git a/tests/e2e/acp.test.ts b/tests/e2e/acp.test.ts index d50c285c4..448413643 100644 --- a/tests/e2e/acp.test.ts +++ b/tests/e2e/acp.test.ts @@ -1233,6 +1233,42 @@ describe("acp: model-independent", () => { if (client) await client.close(); }); + test( + "host-managed ACP sessions stream without local credentials", + async () => { + const root = createIsolatedRoot("fx-acp-host-managed-"); + const gateway = startFakeGateway([finalText("ACP_HOST_MANAGED_OK")]); + try { + client = await AcpClient.create({ + cwd: root.workspace, + env: { + ...fakeGatewayEnv(root, gateway), + AI_GATEWAY_API_KEY: undefined, + VERCEL_OIDC_TOKEN: undefined, + FX_AUTH_MODE: "host-managed", + }, + }); + await client.request("initialize", { protocolVersion: 1 }, 1); + await client.request("session/new", { mcpServers: [] }, 2); + await client.readLine(); + const result = await runPrompt(client, "Reply once.", TIMEOUT); + + expect(result.promptResult.result.stopReason).toBe("end_turn"); + expect(JSON.stringify(result.messages)).toContain("ACP_HOST_MANAGED_OK"); + expect(gateway.requests.length).toBe(1); + expect(gateway.requests[0]!.headers.get("authorization")).toBeNull(); + expect(gateway.requests[0]!.headers.get("x-vercel-ai-gateway-team")).toBeNull(); + expect(existsSync(join(root.home, ".fx", "auth.json"))).toBe(false); + expect(client.stderr).toBe(""); + } finally { + await client?.close(); + gateway.stop(); + rmSync(root.root, { recursive: true, force: true }); + } + }, + TIMEOUT, + ); + test( "active ACP session uses typed MCP Resources Prompts and Completion state", async () => { diff --git a/tests/e2e/host-managed-auth.test.ts b/tests/e2e/host-managed-auth.test.ts new file mode 100644 index 000000000..6cd9bf131 --- /dev/null +++ b/tests/e2e/host-managed-auth.test.ts @@ -0,0 +1,261 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runFx } from "../evals/eval-helpers"; +import { fakeGatewayFinalText, TmuxSession } from "./tmux-helpers"; + +const TIMEOUT = 30_000; + +type CapturedRequest = { + path: string; + method: string; + headers: Headers; +}; + +describe("host-managed authentication", () => { + let root = ""; + let home = ""; + let workspace = ""; + let requests: CapturedRequest[] = []; + let server: ReturnType; + let baseUrl = ""; + let codexUnauthorizedResponses = 0; + + beforeAll(() => { + root = mkdtempSync(join(tmpdir(), "fx-host-managed-auth-")); + home = join(root, "home"); + workspace = join(root, "workspace"); + mkdirSync(home, { recursive: true }); + mkdirSync(workspace, { recursive: true }); + server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request) { + const path = new URL(request.url).pathname; + requests.push({ + path, + method: request.method, + headers: new Headers(request.headers), + }); + if (path === "/gateway/models") { + return Response.json({ + data: [{ id: "test/gateway-model", type: "language", tags: ["tool-use"] }], + }); + } + if (path === "/gateway/responses") { + return fakeGatewayFinalText("GATEWAY_HOST_MANAGED_OK"); + } + if (path === "/codex/models") { + return Response.json({ models: [{ + slug: "gpt-5.4-mini", + visibility: "list", + supported_in_api: true, + priority: 1, + supported_reasoning_levels: [{ effort: "low" }], + additional_speed_tiers: [], + input_modalities: ["text"], + context_window: 272000, + }] }); + } + if (path === "/codex/responses") { + if (codexUnauthorizedResponses > 0) { + codexUnauthorizedResponses -= 1; + return Response.json({ error: { message: "host rejected request" } }, { status: 401 }); + } + return new Response( + 'data: {"type":"response.output_text.delta","delta":"CODEX_HOST_MANAGED_OK"}\n\n' + + 'data: {"type":"response.completed","response":{"id":"resp_codex_host","status":"completed","usage":{"input_tokens":4,"output_tokens":2}}}\n\n', + { headers: { "content-type": "text/event-stream" } }, + ); + } + if (path === "/grok/models") { + return Response.json({ data: [{ + id: "grok-4.20", + model: "grok-4.20", + api_backend: "responses", + context_window: 1000000, + supports_reasoning_effort: false, + reasoning_efforts: [], + }] }); + } + if (path === "/grok/modalities") { + return Response.json({ models: [{ + id: "grok-4.20", + input_modalities: ["text"], + output_modalities: ["text"], + }] }); + } + if (path === "/grok/responses") { + return new Response( + 'data: {"type":"response.output_text.delta","delta":"GROK_HOST_MANAGED_OK"}\n\n' + + 'data: {"type":"response.completed","response":{"id":"resp_grok_host","status":"completed","usage":{"input_tokens":4,"output_tokens":2}}}\n\n', + { headers: { "content-type": "text/event-stream" } }, + ); + } + return new Response("not found", { status: 404 }); + }, + }); + baseUrl = `http://127.0.0.1:${server.port}`; + }); + + afterAll(() => { + server.stop(true); + rmSync(root, { recursive: true, force: true }); + }); + + function env(): Record { + return { + HOME: home, + AI_GATEWAY_API_KEY: undefined, + VERCEL_OIDC_TOKEN: undefined, + FX_AUTH_MODE: "host-managed", + FX_AUTO_UPGRADE: "0", + FX_DISABLE_KEYCHAIN: "1", + FX_SKIP_ONBOARDING: "1", + FX_SOUND: "0", + FX_E2E_GATEWAY_MODELS_URL: `${baseUrl}/gateway/models`, + FX_E2E_GATEWAY_CHAT_URL: `${baseUrl}/gateway/responses`, + FX_E2E_OPENAI_CODEX_MODELS_URL: `${baseUrl}/codex/models`, + FX_E2E_OPENAI_CODEX_RESPONSES_URL: `${baseUrl}/codex/responses`, + FX_E2E_XAI_GROK_MODELS_URL: `${baseUrl}/grok/models`, + FX_E2E_XAI_GROK_MODALITIES_URL: `${baseUrl}/grok/modalities`, + FX_E2E_XAI_GROK_RESPONSES_URL: `${baseUrl}/grok/responses`, + }; + } + + test("runs Gateway Codex and Grok without local authentication headers", async () => { + const childEnv = env(); + const status = await runFx(["status", "--json"], { cwd: workspace, env: childEnv }); + expect(status.code).toBe(0); + expect(status.stderr).toBe(""); + expect(JSON.parse(status.stdout).auth).toBe("host managed"); + + for (const command of [["login"], ["logout"], ["setup"], ["teams"]]) { + const result = await runFx(command, { cwd: workspace, env: childEnv }); + expect(result.code).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toBe("Authentication is managed by the host.\n"); + } + expect(existsSync(join(home, ".fx", "auth.json"))).toBe(false); + + for (const [provider, marker] of [ + ["gateway", "GATEWAY_HOST_MANAGED_OK"], + ["codex", "CODEX_HOST_MANAGED_OK"], + ["grok", "GROK_HOST_MANAGED_OK"], + ] as const) { + const selected = await runFx(["provider", provider], { + cwd: workspace, + env: childEnv, + timeoutMs: TIMEOUT, + }); + expect(selected.code).toBe(0); + expect(selected.stderr).toBe(""); + + const models = await runFx(["models", "--json"], { + cwd: workspace, + env: childEnv, + timeoutMs: TIMEOUT, + }); + expect(models.code).toBe(0); + expect(models.stderr).toBe(""); + + const asked = await runFx(["ask", "--json", "--no-save", "Reply once."], { + cwd: workspace, + env: childEnv, + timeoutMs: TIMEOUT, + }); + expect(asked.code).toBe(0); + expect(asked.stderr).toBe(""); + expect(asked.stdout).toContain(marker); + } + + expect(requests.length).toBeGreaterThan(0); + for (const request of requests) { + expect(request.headers.get("authorization"), request.path).toBeNull(); + expect(request.headers.get("x-vercel-ai-gateway-team"), request.path).toBeNull(); + expect(request.headers.get("chatgpt-account-id"), request.path).toBeNull(); + expect(request.headers.get("x-xai-token-auth"), request.path).toBeNull(); + expect(request.headers.get("x-authenticateresponse"), request.path).toBeNull(); + expect(request.headers.get("x-grok-user-id"), request.path).toBeNull(); + expect(request.headers.get("x-userid"), request.path).toBeNull(); + } + expect(existsSync(join(home, ".fx", "auth.json"))).toBe(false); + }, TIMEOUT); + + test("rejects malformed auth mode before provider I/O", async () => { + const before = requests.length; + const result = await runFx(["ask", "--json", "--no-save", "Do nothing."], { + cwd: workspace, + env: { ...env(), FX_AUTH_MODE: "host_managed" }, + timeoutMs: TIMEOUT, + }); + expect(result.code).toBe(1); + expect(result.stderr).toContain("FX_AUTH_MODE must be local or host-managed"); + expect(requests.length).toBe(before); + }, TIMEOUT); + + test("final provider 401 does not enter local refresh or replay", async () => { + const childEnv = env(); + const selected = await runFx(["provider", "codex"], { + cwd: workspace, + env: childEnv, + timeoutMs: TIMEOUT, + }); + expect(selected.code).toBe(0); + + const before = requests.filter((request) => request.path === "/codex/responses").length; + codexUnauthorizedResponses = 1; + const asked = await runFx(["ask", "--json", "--no-save", "Reply once."], { + cwd: workspace, + env: childEnv, + timeoutMs: TIMEOUT, + }); + expect(asked.code).toBe(1); + const after = requests.filter((request) => request.path === "/codex/responses").length; + expect(after - before).toBe(1); + expect(existsSync(join(home, ".fx", "auth.json"))).toBe(false); + }, TIMEOUT); + + test("interactive host-managed session streams through the same authority", async () => { + const childEnv = env(); + const selected = await runFx(["provider", "gateway"], { + cwd: workspace, + env: childEnv, + timeoutMs: TIMEOUT, + }); + expect(selected.code).toBe(0); + + const stderrPath = join(root, "tui.stderr"); + const tracePath = join(root, "tui.trace"); + const before = requests.length; + const session = await TmuxSession.create({ + cwd: workspace, + env: { + ...childEnv, + FX_TRACE_LOG: tracePath, + FX_TRACE_SCOPES: "auth,session,worker,gateway", + }, + stderrPath, + isolated: true, + }); + try { + await session.waitForComposer(TIMEOUT); + await session.sendText("Reply once."); + const pane = await session.waitForText("GATEWAY_HOST_MANAGED_OK", TIMEOUT); + expect(pane).toContain("GATEWAY_HOST_MANAGED_OK"); + } catch (error) { + const trace = existsSync(tracePath) ? readFileSync(tracePath, "utf8") : ""; + throw new Error(`${String(error)}\ntrace:\n${trace}`); + } finally { + await session.kill(); + } + + expect(readFileSync(stderrPath, "utf8")).toBe(""); + expect(requests.length).toBeGreaterThan(before); + for (const request of requests.slice(before)) { + expect(request.headers.get("authorization"), request.path).toBeNull(); + expect(request.headers.get("x-vercel-ai-gateway-team"), request.path).toBeNull(); + } + }, TIMEOUT * 2); +}); From 4df34c31cbe0c38dc3015a674751a2dbbb36b01d Mon Sep 17 00:00:00 2001 From: Pranit Date: Tue, 1 Sep 2026 01:54:07 -0400 Subject: [PATCH 05/12] Classify host-managed auth E2E corpus --- scripts/pgso/corpus.json | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/pgso/corpus.json b/scripts/pgso/corpus.json index cc37f1c68..43cddf3d8 100644 --- a/scripts/pgso/corpus.json +++ b/scripts/pgso/corpus.json @@ -85,6 +85,7 @@ {"name": "e2e-config-persistence", "argv": ["bun", "test", "--max-concurrency", "1", "./config-persistence.test.ts"], "test_file": "config-persistence.test.ts"}, {"name": "e2e-prompt-history", "argv": ["bun", "test", "--max-concurrency", "1", "./prompt-history.test.ts"], "test_file": "prompt-history.test.ts"}, {"name": "e2e-auth-refresh", "argv": ["bun", "test", "--max-concurrency", "1", "./auth-refresh.test.ts"], "test_file": "auth-refresh.test.ts"}, + {"name": "e2e-host-managed-auth", "argv": ["bun", "test", "--max-concurrency", "1", "./host-managed-auth.test.ts"], "test_file": "host-managed-auth.test.ts"}, {"name": "e2e-file-tool-paths", "argv": ["bun", "test", "--max-concurrency", "1", "./file-tool-paths.test.ts"], "test_file": "file-tool-paths.test.ts"}, {"name": "e2e-file-tool-permissions", "argv": ["bun", "test", "--max-concurrency", "1", "./file-tool-permissions.test.ts"], "test_file": "file-tool-permissions.test.ts"}, {"name": "e2e-gateway-stream-lifecycle", "argv": ["bun", "test", "--max-concurrency", "1", "./gateway-stream-lifecycle.test.ts"], "test_file": "gateway-stream-lifecycle.test.ts"}, From daca1dd82fcf1bcb092703663fec2cf1b39f4ded Mon Sep 17 00:00:00 2001 From: Pranit Date: Tue, 1 Sep 2026 01:59:59 -0400 Subject: [PATCH 06/12] Balance host-managed auth E2E shard --- tests/e2e/ci-shard-weights.json | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/e2e/ci-shard-weights.json b/tests/e2e/ci-shard-weights.json index 157ac392c..8be99f49e 100644 --- a/tests/e2e/ci-shard-weights.json +++ b/tests/e2e/ci-shard-weights.json @@ -10,6 +10,7 @@ { "file": "file-tool-paths.test.ts", "weight": 6 }, { "file": "file-tool-permissions.test.ts", "weight": 2 }, { "file": "gateway-stream-lifecycle.test.ts", "weight": 98 }, + { "file": "host-managed-auth.test.ts", "weight": 2 }, { "file": "mcp-auth.test.ts", "weight": 105 }, { "file": "mcp-http.test.ts", "weight": 10 }, { "file": "mcp-legacy-remote.test.ts", "weight": 19 }, From 76f53cc438761d8d01a2e6b482c6920ec6f00bc6 Mon Sep 17 00:00:00 2001 From: Pranit Date: Tue, 1 Sep 2026 02:07:14 -0400 Subject: [PATCH 07/12] Update PGSO corpus expectations --- scripts/pgso/tests/test_corpus.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/pgso/tests/test_corpus.py b/scripts/pgso/tests/test_corpus.py index bfefdf432..330fe51f2 100644 --- a/scripts/pgso/tests/test_corpus.py +++ b/scripts/pgso/tests/test_corpus.py @@ -25,6 +25,7 @@ "config-persistence.test.ts", "prompt-history.test.ts", "auth-refresh.test.ts", + "host-managed-auth.test.ts", "file-tool-paths.test.ts", "file-tool-permissions.test.ts", "gateway-stream-lifecycle.test.ts", @@ -364,8 +365,8 @@ def test_production_manifest_classifies_every_e2e_file(self) -> None: EXCLUDED_E2E_TESTS, tuple(test_file for test_file, _ in corpus.intentional_exclusions), ) - self.assertEqual(36, len(corpus.scenarios)) - self.assertEqual(53, len(corpus.candidate_scenarios)) + self.assertEqual(37, len(corpus.scenarios)) + self.assertEqual(54, len(corpus.candidate_scenarios)) self.assertEqual( { "direct-help": 100, From fc9d28803e07ea6f493de5fc5549caa1561a455d Mon Sep 17 00:00:00 2001 From: Pranit Date: Tue, 1 Sep 2026 03:00:58 -0400 Subject: [PATCH 08/12] Preserve host auth in usage snapshots --- src/core/session/session_usage.zig | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/src/core/session/session_usage.zig b/src/core/session/session_usage.zig index bf2d40c9b..699d16f4b 100644 --- a/src/core/session/session_usage.zig +++ b/src/core/session/session_usage.zig @@ -3453,7 +3453,7 @@ fn parseCredentialSourceOptional(value: ?std.json.Value) !?types.CredentialSourc const actual = value orelse return error.InvalidUsageSnapshot; return switch (actual) { .null => null, - .string => |text| types.parseCredentialSource(text) orelse return error.InvalidUsageSnapshot, + .string => |text| types.parseRuntimeCredentialSource(text) orelse return error.InvalidUsageSnapshot, else => error.InvalidUsageSnapshot, }; } @@ -3542,6 +3542,34 @@ test "usage snapshot JSON round trips" { try std.testing.expectEqual(snapshot.wall_duration_ms, decoded.wall_duration_ms); } +test "host-managed deferred usage authority round trips" { + const alloc = std.testing.allocator; + var usage = Usage.initFresh(); + defer usage.deinit(alloc); + const identity = credential_authority.derive(.host_managed, null).?; + const observation = try InvocationObservation.begin(&usage); + try observation.complete(alloc, .{}, .{ .deferred = .{ + .provider = .gateway, + .generation_id = "gen_01ARZ3NDEKTSV4RRFFQ69G5FAV", + .scope = "https://ai-gateway.vercel.sh", + .credential_source = .host_managed, + .credential_identity = identity, + } }); + + var snapshot = try usage.snapshot(alloc); + defer snapshot.deinit(alloc); + var encoded: std.Io.Writer.Allocating = .init(alloc); + defer encoded.deinit(); + try writeSnapshot(&encoded.writer, snapshot); + var parsed = try std.json.parseFromSlice(std.json.Value, alloc, encoded.written(), .{}); + defer parsed.deinit(); + var decoded = try parseSnapshotValue(alloc, parsed.value); + defer decoded.deinit(alloc); + + try std.testing.expectEqual(types.CredentialSource.host_managed, decoded.pending[0].credential_source.?); + try std.testing.expect(decoded.pending[0].credential_identity.?.eql(identity)); +} + test "profile recovery hint follows unresolved durable usage" { const alloc = std.testing.allocator; var usage = Usage.initFresh(); From dce2bb46eab77174b0fa5b6979318e3ad976faec Mon Sep 17 00:00:00 2001 From: Pranit Date: Wed, 2 Sep 2026 04:07:55 -0400 Subject: [PATCH 09/12] Preserve absent credential authority Keep empty review and tool leases source-free while host-managed requests retain their explicit authority. --- src/builtins/gateway.zig | 2 +- src/core/agent/runtime/orchestrator.zig | 2 +- src/core/agent/stream_provider.zig | 2 +- src/core/shared/types.zig | 12 +++++++++--- src/core/tooling/tool_runtime.zig | 6 ++++-- src/gateway/host_stream_provider.zig | 2 +- 6 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/builtins/gateway.zig b/src/builtins/gateway.zig index a6c0d65ef..931c1c6d1 100644 --- a/src/builtins/gateway.zig +++ b/src/builtins/gateway.zig @@ -596,7 +596,7 @@ fn gatewayUsageReference( completion: shared_types.ModelCompletion, ) ?agent_stream_provider_contract.DeferredUsageReference { const generation_id = completion.generation_id orelse return null; - const source = request.credential.credentialSource(); + const source = request.credential.credentialSource() orelse return null; return .{ .provider = .gateway, .generation_id = generation_id, diff --git a/src/core/agent/runtime/orchestrator.zig b/src/core/agent/runtime/orchestrator.zig index bb72d0b04..955809f0a 100644 --- a/src/core/agent/runtime/orchestrator.zig +++ b/src/core/agent/runtime/orchestrator.zig @@ -4015,7 +4015,7 @@ fn activeCredentialLease( if (job.credential_source == .host_managed) return .host_managed; return .{ .direct = .{ .secret_bytes = secret_value, - .source = job.credential_source orelse .ai_gateway_api_key, + .source = job.credential_source, .account_id = job.account_id, .tenant_context = job.gateway_team, } }; diff --git a/src/core/agent/stream_provider.zig b/src/core/agent/stream_provider.zig index 067c37869..51bc7ed61 100644 --- a/src/core/agent/stream_provider.zig +++ b/src/core/agent/stream_provider.zig @@ -155,7 +155,7 @@ test "host-managed credential lease exposes no secret or account metadata" { try std.testing.expect(lease.secret() == null); try std.testing.expect(lease.accountId() == null); try std.testing.expect(lease.tenant() == null); - try std.testing.expectEqual(types.CredentialSource.host_managed, lease.credentialSource()); + try std.testing.expectEqual(types.CredentialSource.host_managed, lease.credentialSource().?); } /// Pure provider input used by request serializers and permission reviewers. diff --git a/src/core/shared/types.zig b/src/core/shared/types.zig index bd8d1d27a..cd9181789 100644 --- a/src/core/shared/types.zig +++ b/src/core/shared/types.zig @@ -99,7 +99,7 @@ pub const CredentialSource = enum { pub const DirectCredentialLease = struct { secret_bytes: []const u8 = "", - source: CredentialSource = .ai_gateway_api_key, + source: ?CredentialSource = null, account_id: ?[]const u8 = null, tenant_context: ?[]const u8 = null, }; @@ -117,7 +117,7 @@ pub const CredentialLease = union(enum) { }; } - pub fn credentialSource(self: CredentialLease) CredentialSource { + pub fn credentialSource(self: CredentialLease) ?CredentialSource { return switch (self) { .direct => |direct| direct.source, .host_managed => .host_managed, @@ -144,7 +144,13 @@ test "host-managed credential lease carries no local authority bytes" { try std.testing.expect(lease.secret() == null); try std.testing.expect(lease.accountId() == null); try std.testing.expect(lease.tenant() == null); - try std.testing.expectEqual(CredentialSource.host_managed, lease.credentialSource()); + try std.testing.expectEqual(CredentialSource.host_managed, lease.credentialSource().?); +} + +test "empty direct credential lease preserves absent authority" { + const lease = CredentialLease{ .direct = .{} }; + try std.testing.expect(lease.secret() == null); + try std.testing.expect(lease.credentialSource() == null); } pub fn parseCredentialSource(text: []const u8) ?CredentialSource { diff --git a/src/core/tooling/tool_runtime.zig b/src/core/tooling/tool_runtime.zig index c07c1bc11..feac0a96c 100644 --- a/src/core/tooling/tool_runtime.zig +++ b/src/core/tooling/tool_runtime.zig @@ -6657,6 +6657,8 @@ const VisionGatewayFixture = struct { try request.admission.admit(); request.delivery.markPossiblySent(); if (response.status != .ok) return .{ .failed = .{ .kind = .provider_error } }; + const credential_source = request.credential.credentialSource() orelse + return error.MissingCredentialSource; return .{ .completed = .{ .completion = .{ .content = response.content, @@ -6669,9 +6671,9 @@ const VisionGatewayFixture = struct { .generation_id = response.generation_id orelse "gen_test", .scope = "https://ai-gateway.vercel.sh", .tenant = request.credential.tenant(), - .credential_source = request.credential.credentialSource(), + .credential_source = credential_source, .credential_identity = credential_authority.derive( - request.credential.credentialSource(), + credential_source, request.credential.accountId(), ), } }, diff --git a/src/gateway/host_stream_provider.zig b/src/gateway/host_stream_provider.zig index 41d35b36a..b755f5220 100644 --- a/src/gateway/host_stream_provider.zig +++ b/src/gateway/host_stream_provider.zig @@ -163,7 +163,7 @@ fn gatewayUsageReference( completion: @import("../core/shared/types.zig").ModelCompletion, ) ?stream_provider.DeferredUsageReference { const generation_id = completion.generation_id orelse return null; - const source = request.credential.credentialSource(); + const source = request.credential.credentialSource() orelse return null; return .{ .provider = .gateway, .generation_id = generation_id, From 8864b630c49078ce0d03b414a994bfb17fabfe5c Mon Sep 17 00:00:00 2001 From: Pranit Date: Wed, 2 Sep 2026 10:03:58 -0400 Subject: [PATCH 10/12] Keep native credentials in their existing stores Remove the versioned aggregate and its migration while retaining the unified runtime auth contract and host-managed mode. --- README.md | 4 +- src/core/auth/auth_store.zig | 352 ------ src/core/auth/chatgpt_oauth.zig | 12 +- src/core/auth/chatgpt_session.zig | 167 ++- src/core/auth/credentials.zig | 89 +- src/core/auth/grok_oauth.zig | 12 +- src/core/auth/grok_session.zig | 169 ++- src/core/auth/oauth_session.zig | 1010 ++++++++++++++++- src/core/hosts/host.zig | 14 - src/core/hosts/native_auth_store.zig | 1132 ------------------- src/core/hosts/native_keychain.zig | 6 +- src/core/hosts/native_secret_store.zig | 236 +++- tests/e2e/acp.test.ts | 17 - tests/e2e/auth-refresh.test.ts | 11 +- tests/e2e/cli.test.ts | 89 +- tests/e2e/oauth-keychain-migration.test.ts | 34 +- tests/e2e/tui-auth-source-selection.test.ts | 114 +- 17 files changed, 1505 insertions(+), 1963 deletions(-) delete mode 100644 src/core/auth/auth_store.zig delete mode 100644 src/core/hosts/native_auth_store.zig diff --git a/README.md b/README.md index b6a2a3000..66d443194 100644 --- a/README.md +++ b/README.md @@ -49,9 +49,9 @@ fx `fx login codex` and `fx login grok` select that provider and a model from its authenticated catalog. Inside fx, open `/setup` and choose **Model provider** to move between Gateway, Codex, and Grok. `/model` lists the active provider's fetched models. Subscription model IDs are the raw IDs returned by each authenticated catalog. Use `/logout codex` or `/logout grok` to remove that subscription session without affecting other providers; choosing it again from **Model provider** starts sign-in. -The OpenAI Codex route uses ChatGPT subscription access directly and never sends its OAuth token to Vercel AI Gateway. The session is stored with the other model-provider credentials in fx's private auth store and refreshed when needed. On supported Codex models, `/fast` requests OpenAI's priority service tier and consumes ChatGPT credits at the higher Fast mode rate. +The OpenAI Codex route uses ChatGPT subscription access directly and never sends its OAuth token to Vercel AI Gateway. The session is stored privately at `~/.fx/chatgpt-auth.json` and refreshed when needed. On supported Codex models, `/fast` requests OpenAI's priority service tier and consumes ChatGPT credits at the higher Fast mode rate. -The Grok route uses subscription access directly at xAI and never sends its OAuth token to Vercel AI Gateway or OpenAI. Its session is stored in the same private auth store, refreshed when needed, and used only with the authenticated xAI catalog and Responses API. The portable backend keeps that store at `~/.fx/auth.json`. +The Grok route uses subscription access directly at xAI and never sends its OAuth token to Vercel AI Gateway or OpenAI. Its session is stored privately at `~/.fx/grok-auth.json`, refreshed when needed, and used only with the authenticated xAI catalog and Responses API. To use an AI Gateway API key instead: diff --git a/src/core/auth/auth_store.zig b/src/core/auth/auth_store.zig deleted file mode 100644 index 3c0a222dd..000000000 --- a/src/core/auth/auth_store.zig +++ /dev/null @@ -1,352 +0,0 @@ -const std = @import("std"); -const secret = @import("secret.zig"); - -const Allocator = std.mem.Allocator; - -pub const StoredSource = enum { - stored_key, - fx_login, - chatgpt_subscription, - grok_subscription, -}; - -pub const StoreState = enum { - empty, - legacy, - current, - malformed_current, -}; - -pub const LoadIntent = enum { - inspect, - active, -}; - -pub const LoadDecision = enum { - missing, - use_legacy, - migrate_legacy, - use_current, - reject_current, -}; - -pub fn decide_load(state: StoreState, intent: LoadIntent) LoadDecision { - return switch (state) { - .empty => .missing, - .legacy => if (intent == .inspect) .use_legacy else .migrate_legacy, - .current => .use_current, - .malformed_current => .reject_current, - }; -} - -const slot_count = std.meta.fields(StoredSource).len; - -pub const Document = struct { - slots: [slot_count]?[]u8 = [_]?[]u8{null} ** slot_count, - - pub fn deinit(self: *Document, alloc: Allocator) void { - for (&self.slots) |*slot| { - if (slot.*) |value| secret.zeroAndFree(alloc, value); - slot.* = null; - } - self.* = .{}; - } - - pub fn get(self: *const Document, source: StoredSource) ?[]const u8 { - return self.slots[@intFromEnum(source)]; - } - - pub fn replaced( - self: *const Document, - alloc: Allocator, - source: StoredSource, - value: []const u8, - ) !Document { - if (source == .stored_key) { - try validate_entry(alloc, source, value); - return self.transformed(alloc, source, value); - } - var parsed = std.json.parseFromSlice(std.json.Value, alloc, value, .{}) catch |err| switch (err) { - error.OutOfMemory => return error.OutOfMemory, - else => return error.InvalidAuthDocument, - }; - defer parsed.deinit(); - if (parsed.value != .object) return error.InvalidAuthDocument; - const canonical = try stringify_value(alloc, parsed.value); - defer secret.zeroAndFree(alloc, canonical); - return self.transformed(alloc, source, canonical); - } - - pub fn removed( - self: *const Document, - alloc: Allocator, - source: StoredSource, - ) !Document { - return self.transformed(alloc, source, null); - } - - pub fn eql(self: *const Document, other: Document) bool { - for (std.meta.tags(StoredSource)) |source| { - const left = self.get(source); - const right = other.get(source); - if (left == null or right == null) { - if (left != null or right != null) return false; - continue; - } - if (!std.mem.eql(u8, left.?, right.?)) return false; - } - return true; - } - - pub fn parse(alloc: Allocator, bytes: []const u8) !Document { - var parsed = std.json.parseFromSlice(std.json.Value, alloc, bytes, .{}) catch |err| switch (err) { - error.OutOfMemory => return error.OutOfMemory, - else => return error.InvalidAuthDocument, - }; - defer parsed.deinit(); - if (parsed.value != .object or parsed.value.object.count() != 2) { - return error.InvalidAuthDocument; - } - const version = parsed.value.object.get("version") orelse return error.InvalidAuthDocument; - if (version != .integer or version.integer != 2) return error.InvalidAuthDocument; - const credentials = parsed.value.object.get("credentials") orelse return error.InvalidAuthDocument; - if (credentials != .object) return error.InvalidAuthDocument; - - var document: Document = .{}; - errdefer document.deinit(alloc); - var iterator = credentials.object.iterator(); - while (iterator.next()) |item| { - const source = std.meta.stringToEnum(StoredSource, item.key_ptr.*) orelse - return error.InvalidAuthDocument; - if (item.value_ptr.* != .object or item.value_ptr.object.count() != 1) { - return error.InvalidAuthDocument; - } - const value = if (source == .stored_key) value: { - const secret_value = item.value_ptr.object.get("secret") orelse - return error.InvalidAuthDocument; - if (secret_value != .string or secret_value.string.len == 0) { - return error.InvalidAuthDocument; - } - break :value try alloc.dupe(u8, secret_value.string); - } else value: { - const session_value = item.value_ptr.object.get("session") orelse - return error.InvalidAuthDocument; - if (session_value != .object) return error.InvalidAuthDocument; - break :value try stringify_value(alloc, session_value); - }; - document.slots[@intFromEnum(source)] = value; - } - return document; - } - - pub fn stringify(self: *const Document, alloc: Allocator) ![]u8 { - var out: std.Io.Writer.Allocating = .init(alloc); - errdefer out.deinit(); - try out.writer.writeAll("{\"version\":2,\"credentials\":{"); - var wrote_entry = false; - for (std.meta.tags(StoredSource)) |source| { - const value = self.get(source) orelse continue; - try validate_entry(alloc, source, value); - if (wrote_entry) try out.writer.writeByte(','); - wrote_entry = true; - try std.json.Stringify.value(@tagName(source), .{}, &out.writer); - if (source == .stored_key) { - try out.writer.writeAll(":{\"secret\":"); - try std.json.Stringify.value(value, .{}, &out.writer); - try out.writer.writeByte('}'); - } else { - try out.writer.writeAll(":{\"session\":"); - try out.writer.writeAll(value); - try out.writer.writeByte('}'); - } - } - try out.writer.writeAll("}}\n"); - return out.toOwnedSlice(); - } - - fn transformed( - self: *const Document, - alloc: Allocator, - changed_source: StoredSource, - replacement: ?[]const u8, - ) !Document { - var next: Document = .{}; - errdefer next.deinit(alloc); - - for (std.meta.tags(StoredSource)) |source| { - const value = if (source == changed_source) replacement else self.get(source); - if (value) |bytes| next.slots[@intFromEnum(source)] = try alloc.dupe(u8, bytes); - } - return next; - } -}; - -fn validate_entry(alloc: Allocator, source: StoredSource, value: []const u8) !void { - if (value.len == 0) return error.InvalidAuthDocument; - if (source == .stored_key) return; - var parsed = std.json.parseFromSlice(std.json.Value, alloc, value, .{}) catch |err| switch (err) { - error.OutOfMemory => return error.OutOfMemory, - else => return error.InvalidAuthDocument, - }; - defer parsed.deinit(); - if (parsed.value != .object) return error.InvalidAuthDocument; -} - -fn stringify_value(alloc: Allocator, value: std.json.Value) ![]u8 { - var out: std.Io.Writer.Allocating = .init(alloc); - errdefer out.deinit(); - std.json.Stringify.value(value, .{}, &out.writer) catch return error.OutOfMemory; - return out.toOwnedSlice() catch return error.OutOfMemory; -} - -fn check_parse_allocation_failures(alloc: Allocator) !void { - var document = try Document.parse( - alloc, - "{\"version\":2,\"credentials\":{\"stored_key\":{\"secret\":\"key\"},\"fx_login\":{\"session\":{\"version\":1}}}}", - ); - defer document.deinit(alloc); -} - -test "replacing one stored credential preserves the original and unrelated sources" { - const alloc = std.testing.allocator; - const empty: Document = .{}; - - var gateway = try empty.replaced(alloc, .stored_key, "gateway-secret"); - defer gateway.deinit(alloc); - var with_codex = try gateway.replaced(alloc, .chatgpt_subscription, "{\"version\":1}"); - defer with_codex.deinit(alloc); - var replaced_codex = try with_codex.replaced(alloc, .chatgpt_subscription, "{\"version\":2}"); - defer replaced_codex.deinit(alloc); - - try std.testing.expectEqualStrings("gateway-secret", gateway.get(.stored_key).?); - try std.testing.expect(gateway.get(.chatgpt_subscription) == null); - try std.testing.expectEqualStrings("gateway-secret", with_codex.get(.stored_key).?); - try std.testing.expectEqualStrings("{\"version\":1}", with_codex.get(.chatgpt_subscription).?); - try std.testing.expectEqualStrings("gateway-secret", replaced_codex.get(.stored_key).?); - try std.testing.expectEqualStrings("{\"version\":2}", replaced_codex.get(.chatgpt_subscription).?); -} - -test "removing one stored credential is idempotent and preserves unrelated sources" { - const alloc = std.testing.allocator; - const empty: Document = .{}; - - var gateway = try empty.replaced(alloc, .fx_login, "{\"version\":1}"); - defer gateway.deinit(alloc); - var with_grok = try gateway.replaced(alloc, .grok_subscription, "{\"version\":1}"); - defer with_grok.deinit(alloc); - var removed = try with_grok.removed(alloc, .grok_subscription); - defer removed.deinit(alloc); - var removed_again = try removed.removed(alloc, .grok_subscription); - defer removed_again.deinit(alloc); - - try std.testing.expectEqualStrings("{\"version\":1}", removed.get(.fx_login).?); - try std.testing.expect(removed.get(.grok_subscription) == null); - try std.testing.expect(removed.eql(removed_again)); -} - -test "version two auth document round trips every stored source" { - const alloc = std.testing.allocator; - var document: Document = .{}; - defer document.deinit(alloc); - - const fixtures = [_]struct { source: StoredSource, value: []const u8 }{ - .{ .source = .stored_key, .value = "gateway-secret" }, - .{ .source = .fx_login, .value = "{\"version\":1,\"access_token\":\"vercel\"}" }, - .{ .source = .chatgpt_subscription, .value = "{\"version\":1,\"access_token\":\"codex\"}" }, - .{ .source = .grok_subscription, .value = "{\"version\":1,\"access_token\":\"grok\"}" }, - }; - for (fixtures) |fixture| { - const next = try document.replaced(alloc, fixture.source, fixture.value); - document.deinit(alloc); - document = next; - } - - const encoded = try document.stringify(alloc); - defer secret.zeroAndFree(alloc, encoded); - var decoded = try Document.parse(alloc, encoded); - defer decoded.deinit(alloc); - - try std.testing.expect(document.eql(decoded)); -} - -test "stored session replacement canonicalizes insignificant JSON whitespace" { - const alloc = std.testing.allocator; - const empty: Document = .{}; - var document = try empty.replaced( - alloc, - .fx_login, - "{\"version\":1,\"access_token\":\"token\"}\n", - ); - defer document.deinit(alloc); - try std.testing.expectEqualStrings( - "{\"version\":1,\"access_token\":\"token\"}", - document.get(.fx_login).?, - ); -} - -test "auth document rejects malformed or unknown version two state" { - const alloc = std.testing.allocator; - const invalid = [_][]const u8{ - "{}", - "{\"version\":1,\"credentials\":{}}", - "{\"version\":2,\"credentials\":[]}", - "{\"version\":2,\"credentials\":{\"stored_key\":{\"secret\":\"\"}}}", - "{\"version\":2,\"credentials\":{\"fx_login\":\"not-an-object\"}}", - "{\"version\":2,\"credentials\":{\"unknown\":{}}}", - }; - for (invalid) |bytes| { - try std.testing.expectError(error.InvalidAuthDocument, Document.parse(alloc, bytes)); - } -} - -test "auth document parse cleans up allocation failures" { - try std.testing.checkAllAllocationFailures( - std.testing.allocator, - check_parse_allocation_failures, - .{}, - ); -} - -test "load decision keeps inspection pure and migrates only active legacy state" { - const cases = [_]struct { - state: StoreState, - intent: LoadIntent, - expected: LoadDecision, - }{ - .{ .state = .empty, .intent = .inspect, .expected = .missing }, - .{ .state = .empty, .intent = .active, .expected = .missing }, - .{ .state = .legacy, .intent = .inspect, .expected = .use_legacy }, - .{ .state = .legacy, .intent = .active, .expected = .migrate_legacy }, - .{ .state = .current, .intent = .inspect, .expected = .use_current }, - .{ .state = .current, .intent = .active, .expected = .use_current }, - .{ .state = .malformed_current, .intent = .inspect, .expected = .reject_current }, - .{ .state = .malformed_current, .intent = .active, .expected = .reject_current }, - }; - for (cases) |case| { - try std.testing.expectEqual(case.expected, decide_load(case.state, case.intent)); - } -} - -test "auth document parser handles arbitrary bytes" { - try std.testing.fuzz({}, fuzz_auth_document, .{ - .corpus = &.{ - "", - "{}", - "{\"version\":2,\"credentials\":{}}", - "{\"version\":2,\"credentials\":{\"stored_key\":{\"secret\":\"key\"}}}", - }, - }); -} - -fn fuzz_auth_document(_: void, smith: *std.testing.Smith) !void { - var buffer: [4096]u8 = undefined; - const len: usize = @intCast(smith.slice(&buffer)); - var document = Document.parse(std.testing.allocator, buffer[0..len]) catch return; - defer document.deinit(std.testing.allocator); - - const encoded = try document.stringify(std.testing.allocator); - defer secret.zeroAndFree(std.testing.allocator, encoded); - var reparsed = try Document.parse(std.testing.allocator, encoded); - defer reparsed.deinit(std.testing.allocator); - try std.testing.expect(document.eql(reparsed)); -} diff --git a/src/core/auth/chatgpt_oauth.zig b/src/core/auth/chatgpt_oauth.zig index 5d0c3f721..f7d3594a3 100644 --- a/src/core/auth/chatgpt_oauth.zig +++ b/src/core/auth/chatgpt_oauth.zig @@ -383,7 +383,7 @@ pub fn logout() !chatgpt_session.DeleteOutcome { } pub fn sourceExists(alloc: Allocator) !bool { - var session = (try chatgpt_session.loadStored(alloc)) orelse return false; + var session = (try chatgpt_session.load(alloc)) orelse return false; defer session.deinit(alloc); return true; } @@ -394,7 +394,7 @@ pub fn loadAccess( mode: RefreshMode, ) !?Access { if (mode == .stored) { - var session = (try chatgpt_session.loadStored(alloc)) orelse return null; + var session = (try chatgpt_session.load(alloc)) orelse return null; defer session.deinit(alloc); return takeAccess(&session); } @@ -439,15 +439,13 @@ fn refreshSession( defer token.deinit(alloc); const account_id = try extractAccountId(alloc, token.access_token); - var account_id_owned = true; - errdefer if (account_id_owned) alloc.free(account_id); + errdefer alloc.free(account_id); if (!std.mem.eql(u8, account_id, session.account_id)) { return error.ChatGptAccountChanged; } const refresh_token = if (token.refresh_token) |rotated| rotated else try alloc.dupe(u8, session.refresh_token); - var refresh_token_owned = true; - errdefer if (refresh_token_owned) secret.zeroAndFree(alloc, refresh_token); if (token.refresh_token != null) token.refresh_token = null; + errdefer secret.zeroAndFree(alloc, refresh_token); const expires_at_ms = if (token.expires_in) |expires_in| blk: { const duration_ms = std.math.mul(i64, expires_in, std.time.ms_per_s) catch return error.InvalidChatGptOAuthResponse; @@ -461,8 +459,6 @@ fn refreshSession( .account_id = account_id, }; token.access_token = &.{}; - account_id_owned = false; - refresh_token_owned = false; errdefer replacement.deinit(alloc); try mutation.save(alloc, replacement); diff --git a/src/core/auth/chatgpt_session.zig b/src/core/auth/chatgpt_session.zig index 5f70ae681..cc13807dd 100644 --- a/src/core/auth/chatgpt_session.zig +++ b/src/core/auth/chatgpt_session.zig @@ -1,20 +1,25 @@ const std = @import("std"); const debug_trace = @import("../shared/debug_trace.zig"); const host_target = @import("../hosts/target.zig"); -const native_auth_store = if (host_target.is_wasm) struct {} else @import("../hosts/native_auth_store.zig"); const host = @import("../hosts/host.zig"); +const io_mod = @import("../shared/io.zig"); +const profile_paths = @import("../shared/profile_paths.zig"); const types = @import("../shared/types.zig"); const secret = @import("secret.zig"); +const session_presence = @import("session_presence.zig"); const Allocator = std.mem.Allocator; const schema_version: i64 = 1; +const max_auth_file_bytes: usize = 64 * 1024; const expiry_skew_ms: i64 = 60 * 1000; +const mutation_lock_file_name = "chatgpt-auth.lock"; +const mutation_lock_deadline_ms: u64 = 2000; pub const issuer = "https://auth.openai.com"; +pub const auth_file_name = profile_paths.chatgpt_auth_file_name; pub fn presence() host.SecretStorePresence { - if (comptime host_target.is_wasm) return .missing; - return native_auth_store.entry_presence(.chatgpt_subscription); + return session_presence.profileFile(auth_file_name, max_auth_file_bytes); } pub fn refreshDeadlineMs(expires_at_ms: i64) i64 { @@ -45,79 +50,82 @@ pub const DeleteOutcome = enum { deleted_not_durable, }; -pub const Mutation = if (host_target.is_wasm) WasmMutation else NativeMutation; - -const WasmMutation = struct { - pub fn deinit(self: *WasmMutation) void { - self.* = undefined; - } - - pub fn load(_: *WasmMutation, _: Allocator) !?Session { - return null; - } - - pub fn save(_: *WasmMutation, _: Allocator, _: Session) !void { - return error.ChatGptOAuthUnavailable; - } - - pub fn delete(_: *WasmMutation) !DeleteOutcome { - return .missing; - } -}; - -const NativeMutation = struct { - inner: native_auth_store.EntryMutation, +pub const Mutation = struct { + fx_dir: io_mod.VerifiedDir, + lock: io_mod.TimedAdvisoryLock, pub fn deinit(self: *Mutation) void { - self.inner.deinit(); + self.lock.release(); + self.fx_dir.close(); self.* = undefined; } pub fn load(self: *Mutation, alloc: Allocator) !?Session { - const bytes = (try self.inner.load(alloc)) orelse return null; - defer secret.zeroAndFree(alloc, bytes); - return parse(alloc, bytes) catch |err| switch (err) { - error.OutOfMemory => return err, - else => { - debug_trace.logf("auth", "ChatGPT session load failed step=parse_common_mutation err={s}", .{@errorName(err)}); - return null; - }, - }; + return loadFromDir(alloc, &self.fx_dir.dir, true); } pub fn save(self: *Mutation, alloc: Allocator, session: Session) !void { const text = try stringify(alloc, session); defer secret.zeroAndFree(alloc, text); - try self.inner.save(alloc, text); + try io_mod.durableReplaceVerified(alloc, &self.fx_dir, auth_file_name, text); } pub fn delete(self: *Mutation) !DeleteOutcome { - return switch (try self.inner.delete(std.heap.c_allocator)) { - .deleted => .deleted, - .missing => .missing, - .deleted_not_durable => .deleted_not_durable, + self.fx_dir.dir.deleteFile(io_mod.getIo(), auth_file_name) catch |err| switch (err) { + error.FileNotFound => return .missing, + else => return err, }; + const durable: io_mod.DurableOps = .{}; + durable.sync_dir(durable.ctx, self.fx_dir.dir) catch return .deleted_not_durable; + return .deleted; } }; pub fn load(alloc: Allocator) !?Session { if (comptime host_target.is_wasm) return null; - return loadNative(alloc, .active); -} - -pub fn loadStored(alloc: Allocator) !?Session { - if (comptime host_target.is_wasm) return null; - return loadNative(alloc, .inspect); + const home = io_mod.getenv("HOME") orelse return null; + var home_dir = std.Io.Dir.openDirAbsolute(io_mod.getIo(), home, .{ .iterate = true }) catch |err| { + debug_trace.logf("auth", "ChatGPT session load failed step=open_home err={s}", .{@errorName(err)}); + return null; + }; + defer home_dir.close(io_mod.getIo()); + + var fx_dir = home_dir.openDir(io_mod.getIo(), profile_paths.root_dir_name, .{ + .iterate = true, + .follow_symlinks = false, + }) catch |err| { + if (err != error.FileNotFound) { + debug_trace.logf("auth", "ChatGPT session load failed step=open_profile err={s}", .{@errorName(err)}); + } + return null; + }; + defer fx_dir.close(io_mod.getIo()); + return loadFromDir(alloc, &fx_dir, false); } -fn loadNative(alloc: Allocator, intent: @import("auth_store.zig").LoadIntent) !?Session { - const bytes = (native_auth_store.load_entry(alloc, .chatgpt_subscription, intent) catch |err| switch (err) { - error.OutOfMemory => return err, +fn loadFromDir(alloc: Allocator, fx_dir: *std.Io.Dir, report_open_failure: bool) !?Session { + var file = fx_dir.openFile(io_mod.getIo(), auth_file_name, .{ + .mode = .read_only, + .allow_directory = false, + .follow_symlinks = false, + .resolve_beneath = true, + }) catch |err| switch (err) { + error.FileNotFound => return null, else => { - debug_trace.logf("auth", "ChatGPT session load failed step=common_store err={s}", .{@errorName(err)}); + debug_trace.logf("auth", "ChatGPT session load failed step=open_file err={s}", .{@errorName(err)}); + if (report_open_failure) return err; return null; }, - }) orelse return null; + }; + defer file.close(io_mod.getIo()); + + const stat = try file.stat(io_mod.getIo()); + if (stat.kind != .file or stat.permissions.toMode() & 0o077 != 0) { + debug_trace.logf("auth", "ChatGPT session load failed step=permissions err=InsecureAuthFile", .{}); + return null; + } + + const bytes = try io_mod.readFileToEnd(alloc, &file, max_auth_file_bytes); defer secret.zeroAndFree(alloc, bytes); return parse(alloc, bytes) catch |err| switch (err) { error.OutOfMemory => return err, @@ -130,14 +138,67 @@ fn loadNative(alloc: Allocator, intent: @import("auth_store.zig").LoadIntent) !? pub fn saveNewSession(alloc: Allocator, session: Session) !void { if (comptime host_target.is_wasm) return error.ChatGptOAuthUnavailable; - var mutation = try beginExistingMutation() orelse return error.HomeNotSet; + var mutation = try beginMutation(); defer mutation.deinit(); try mutation.save(alloc, session); } pub fn beginExistingMutation() !?Mutation { if (comptime host_target.is_wasm) return null; - return .{ .inner = try native_auth_store.begin_entry_mutation(.chatgpt_subscription) }; + const home = io_mod.getenv("HOME") orelse return error.HomeNotSet; + var home_dir = io_mod.VerifiedDir{ + .dir = try std.Io.Dir.openDirAbsolute(io_mod.getIo(), home, .{ .iterate = true }), + }; + defer home_dir.close(); + + const fx_dir = openExistingPrivateFxDir(&home_dir) catch |err| switch (err) { + error.FileNotFound => return null, + else => return err, + }; + return try lockMutation(fx_dir); +} + +fn beginMutation() !Mutation { + const home = io_mod.getenv("HOME") orelse return error.HomeNotSet; + var home_dir = io_mod.VerifiedDir{ + .dir = try std.Io.Dir.openDirAbsolute(io_mod.getIo(), home, .{ .iterate = true }), + }; + defer home_dir.close(); + + const fx_dir = try io_mod.openOrCreateVerifiedPrivateDir(&home_dir, profile_paths.root_dir_name); + return lockMutation(fx_dir); +} + +fn lockMutation(open_fx_dir: io_mod.VerifiedDir) !Mutation { + var fx_dir = open_fx_dir; + errdefer fx_dir.close(); + var lock = try io_mod.acquireTimedAdvisoryLock( + &fx_dir, + mutation_lock_file_name, + mutation_lock_deadline_ms, + ); + errdefer lock.release(); + return .{ .fx_dir = fx_dir, .lock = lock }; +} + +fn openExistingPrivateFxDir(home_dir: *io_mod.VerifiedDir) !io_mod.VerifiedDir { + var dir = try home_dir.dir.openDir(io_mod.getIo(), profile_paths.root_dir_name, .{ + .iterate = true, + .follow_symlinks = false, + }); + errdefer dir.close(io_mod.getIo()); + + const initial_stat = try dir.stat(io_mod.getIo()); + if (initial_stat.kind != .directory) return error.DurablePathUnsafe; + if (initial_stat.permissions.toMode() & 0o200 == 0) return error.PrivateStatePermissionsUnsupported; + dir.setPermissions(io_mod.getIo(), std.Io.File.Permissions.fromMode(0o700)) catch { + return error.PrivateStatePermissionsUnsupported; + }; + const stat = try dir.stat(io_mod.getIo()); + if (stat.kind != .directory or stat.permissions.toMode() & 0o777 != 0o700) { + return error.PrivateStatePermissionsUnsupported; + } + return .{ .dir = dir }; } pub fn parse(alloc: Allocator, bytes: []const u8) !Session { diff --git a/src/core/auth/credentials.zig b/src/core/auth/credentials.zig index e4e01d99b..800d208bb 100644 --- a/src/core/auth/credentials.zig +++ b/src/core/auth/credentials.zig @@ -444,7 +444,7 @@ pub fn resolvePreferring( if (secret_store.isDisabled()) return .{ .fx_login_status = fx_login_status }; var status: StoredKeyReadStatus = .not_found; - const stored = loadStoredKeyCredential(alloc, secret_store, mode) catch |err| blk: { + const stored = loadSource(alloc, transport, secret_store, .stored_key) catch |err| blk: { if (err == error.OutOfMemory) return err; status = .unavailable; debug_trace.logf("auth", "stored key load failed err={s} status={t}", .{ @errorName(err), status }); @@ -504,7 +504,6 @@ fn loadPreferredSource( .stored => loadStoredGrokCredential(alloc), .refresh_if_needed => loadGrokCredential(alloc, transport, .if_needed), }, - .stored_key => loadStoredKeyCredential(alloc, secret_store, mode), else => loadSource(alloc, transport, secret_store, source), }; } @@ -519,7 +518,7 @@ pub fn loadSource( .vercel_oidc_token => loadEnvCredential(alloc, "VERCEL_OIDC_TOKEN", source), .ai_gateway_api_key => loadEnvCredential(alloc, "AI_GATEWAY_API_KEY", source), .fx_login => loadFxLoginCredential(alloc, transport), - .stored_key => loadStoredKeyCredential(alloc, secret_store, .refresh_if_needed), + .stored_key => loadStoredKeyCredential(alloc, secret_store), .chatgpt_subscription => loadChatGptCredential(alloc, transport, .if_needed), .grok_subscription => loadGrokCredential(alloc, transport, .if_needed), .host_managed => null, @@ -535,7 +534,7 @@ pub fn sourceExists( .vercel_oidc_token => nonEmptyEnvValue("VERCEL_OIDC_TOKEN") != null, .ai_gateway_api_key => nonEmptyEnvValue("AI_GATEWAY_API_KEY") != null, .fx_login => blk: { - const loaded = oauth_session.loadStored(alloc) catch |err| switch (err) { + const loaded = oauth_session.load(alloc) catch |err| switch (err) { error.OutOfMemory => return err, else => { debug_trace.logf("auth", "source probe failed source=fx_login err={s}", .{@errorName(err)}); @@ -606,13 +605,9 @@ fn loadEnvCredential( fn loadStoredKeyCredential( alloc: std.mem.Allocator, secret_store: host.SecretStore, - mode: LoadMode, ) !?Credential { if (secret_store.isDisabled()) return null; - const value = (try if (mode == .stored) - secret_store.loadStored(alloc) - else - secret_store.load(alloc)) orelse return null; + const value = (try secret_store.load(alloc)) orelse return null; return .{ .token = value, .source = .stored_key }; } @@ -687,7 +682,7 @@ pub fn loadFxLoginCredential( } fn loadStoredFxLoginCredential(alloc: std.mem.Allocator) !?Credential { - var session = (try oauth_session.loadStored(alloc)) orelse return null; + var session = (try oauth_session.load(alloc)) orelse return null; defer session.deinit(alloc); return takeCredentialFromSession(&session, null); } @@ -1046,7 +1041,6 @@ const SecretStoreFixture = struct { disabled: bool = false, unreadable: bool = false, load_calls: usize = 0, - stored_load_calls: usize = 0, presence_calls: usize = 0, fn provider(self: *@This()) host.SecretStore { @@ -1056,7 +1050,6 @@ const SecretStoreFixture = struct { .is_disabled_fn = isDisabled, .presence_fn = presence, .load_fn = load, - .load_stored_fn = loadStored, .store_fn = store, .store_interactive_fn = storeInteractive, }; @@ -1080,19 +1073,6 @@ const SecretStoreFixture = struct { ) host.SecretStoreLoadError!?[]u8 { const self: *@This() = @ptrCast(@alignCast(raw_context.?)); self.load_calls += 1; - return self.loadValue(alloc); - } - - fn loadStored( - raw_context: ?*anyopaque, - alloc: std.mem.Allocator, - ) host.SecretStoreLoadError!?[]u8 { - const self: *@This() = @ptrCast(@alignCast(raw_context.?)); - self.stored_load_calls += 1; - return self.loadValue(alloc); - } - - fn loadValue(self: *@This(), alloc: std.mem.Allocator) host.SecretStoreLoadError!?[]u8 { if (self.unreadable) return error.StoredKeyUnreadable; const value = self.value orelse return null; return try alloc.dupe(u8, value); @@ -1218,7 +1198,6 @@ test "a disabled stored key is reported as never attempted, not as absent" { try std.testing.expectEqual(StoredKeyReadStatus.not_attempted, resolution.stored_key_status); } try std.testing.expectEqual(@as(usize, 0), store_fixture.load_calls); - try std.testing.expectEqual(@as(usize, 0), store_fixture.stored_load_calls); } test "credential resolution loads a stored key only through the injected host port" { @@ -1235,8 +1214,7 @@ test "credential resolution loads a stored key only through the injected host po ); defer if (resolution.credential) |*credential| credential.deinit(alloc); - try std.testing.expectEqual(@as(usize, 0), store_fixture.load_calls); - try std.testing.expectEqual(@as(usize, 1), store_fixture.stored_load_calls); + try std.testing.expectEqual(@as(usize, 1), store_fixture.load_calls); try std.testing.expectEqual(StoredKeyReadStatus.not_attempted, resolution.stored_key_status); try std.testing.expectEqual(Source.stored_key, resolution.credential.?.source); try std.testing.expectEqualStrings("injected-test-value", resolution.credential.?.token); @@ -1257,7 +1235,7 @@ test "stored key existence never loads secret bytes" { try std.testing.expectEqual(@as(usize, 1), store_fixture.presence_calls); } -test "credential source presence reads common slot metadata without validating session secrets" { +test "credential source presence reads metadata without parsing session secrets" { const alloc = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); @@ -1267,34 +1245,39 @@ test "credential source presence reads common slot metadata without validating s const env = try CredentialTestEnv.install(alloc, &.{.{ "HOME", home }}); defer env.deinit(); - var file = try tmp.dir.createFile(io_mod.getIo(), ".fx/auth.json", .{ - .truncate = true, - .permissions = std.Io.File.Permissions.fromMode(0o600), - }); - defer file.close(io_mod.getIo()); - try file.writeStreamingAll( - io_mod.getIo(), - "{\"version\":2,\"credentials\":{" ++ - "\"fx_login\":{\"session\":{\"version\":1}}," ++ - "\"chatgpt_subscription\":{\"session\":{\"version\":1}}," ++ - "\"grok_subscription\":{\"session\":{\"version\":1}}}}", - ); + const cases = [_]struct { + source: Source, + file_name: []const u8, + }{ + .{ .source = .fx_login, .file_name = profile_paths.auth_file_name }, + .{ .source = .chatgpt_subscription, .file_name = profile_paths.chatgpt_auth_file_name }, + .{ .source = .grok_subscription, .file_name = profile_paths.grok_auth_file_name }, + }; + for (cases) |case| { + var path_buffer: [std.fs.max_path_bytes]u8 = undefined; + const relative_path = try std.fmt.bufPrint( + &path_buffer, + ".fx/{s}", + .{case.file_name}, + ); + var file = try tmp.dir.createFile(io_mod.getIo(), relative_path, .{ + .truncate = true, + .permissions = std.Io.File.Permissions.fromMode(0o600), + }); + defer file.close(io_mod.getIo()); + try file.writeStreamingAll(io_mod.getIo(), "not valid session JSON"); - const sources = [_]Source{ .fx_login, .chatgpt_subscription, .grok_subscription }; - for (sources) |source| { try std.testing.expectEqual( host.SecretStorePresence.present, - sourcePresence(host.unavailable_secret_store, source), + sourcePresence(host.unavailable_secret_store, case.source), + ); + try file.setPermissions( + io_mod.getIo(), + std.Io.File.Permissions.fromMode(0o644), ); - } - try file.setPermissions( - io_mod.getIo(), - std.Io.File.Permissions.fromMode(0o644), - ); - for (sources) |source| { try std.testing.expectEqual( host.SecretStorePresence.unavailable, - sourcePresence(host.unavailable_secret_store, source), + sourcePresence(host.unavailable_secret_store, case.source), ); } } @@ -1312,8 +1295,7 @@ test "credential resolution preserves unreadable store classification" { .stored, ); - try std.testing.expectEqual(@as(usize, 0), store_fixture.load_calls); - try std.testing.expectEqual(@as(usize, 1), store_fixture.stored_load_calls); + try std.testing.expectEqual(@as(usize, 1), store_fixture.load_calls); try std.testing.expect(resolution.credential == null); try std.testing.expectEqual(StoredKeyReadStatus.unavailable, resolution.stored_key_status); } @@ -1338,7 +1320,6 @@ test "a failed fx-login refresh falls through to the stored key" { try std.testing.expectEqualStrings("stored-key-that-works", credential.token); try std.testing.expectEqual(FxLoginReadStatus.unavailable, resolution.fx_login_status); try std.testing.expectEqual(@as(usize, 1), store_fixture.load_calls); - try std.testing.expectEqual(@as(usize, 0), store_fixture.stored_load_calls); } test "a failed fx-login refresh is still reported when nothing else resolves" { diff --git a/src/core/auth/grok_oauth.zig b/src/core/auth/grok_oauth.zig index 37c8633ef..699980425 100644 --- a/src/core/auth/grok_oauth.zig +++ b/src/core/auth/grok_oauth.zig @@ -484,7 +484,7 @@ pub fn logout(alloc: Allocator, transport: oauth_transport.Provider) !LogoutResu } pub fn sourceExists(alloc: Allocator) !bool { - var session = (try grok_session.loadStored(alloc)) orelse return false; + var session = (try grok_session.load(alloc)) orelse return false; defer session.deinit(alloc); return true; } @@ -495,7 +495,7 @@ pub fn loadAccess( mode: RefreshMode, ) !?Access { if (mode == .stored) { - var session = (try grok_session.loadStored(alloc)) orelse return null; + var session = (try grok_session.load(alloc)) orelse return null; defer session.deinit(alloc); return takeAccess(&session); } @@ -539,15 +539,13 @@ fn refreshSession( defer token.deinit(alloc); const account_id = try fetchAccountId(alloc, transport, token.access_token); - var account_id_owned = true; - errdefer if (account_id_owned) alloc.free(account_id); + errdefer alloc.free(account_id); if (!std.mem.eql(u8, account_id, session.account_id)) { return error.GrokAccountChanged; } const refresh_token = if (token.refresh_token) |rotated| rotated else try alloc.dupe(u8, session.refresh_token); - var refresh_token_owned = true; - errdefer if (refresh_token_owned) secret.zeroAndFree(alloc, refresh_token); if (token.refresh_token != null) token.refresh_token = null; + errdefer secret.zeroAndFree(alloc, refresh_token); const expires_at_ms = if (token.expires_in) |expires_in| blk: { const duration_ms = std.math.mul(i64, expires_in, std.time.ms_per_s) catch return error.InvalidGrokOAuthResponse; @@ -561,8 +559,6 @@ fn refreshSession( .account_id = account_id, }; token.access_token = &.{}; - account_id_owned = false; - refresh_token_owned = false; errdefer replacement.deinit(alloc); try mutation.save(alloc, replacement); diff --git a/src/core/auth/grok_session.zig b/src/core/auth/grok_session.zig index ff29cbc95..488553a85 100644 --- a/src/core/auth/grok_session.zig +++ b/src/core/auth/grok_session.zig @@ -1,19 +1,26 @@ const std = @import("std"); const debug_trace = @import("../shared/debug_trace.zig"); const host_target = @import("../hosts/target.zig"); -const native_auth_store = if (host_target.is_wasm) struct {} else @import("../hosts/native_auth_store.zig"); const host = @import("../hosts/host.zig"); +const io_mod = @import("../shared/io.zig"); +const profile_paths = @import("../shared/profile_paths.zig"); const types = @import("../shared/types.zig"); const secret = @import("secret.zig"); +const session_presence = @import("session_presence.zig"); const Allocator = std.mem.Allocator; const schema_version: i64 = 1; +const max_auth_file_bytes: usize = 64 * 1024; const expiry_skew_ms: i64 = 60 * 1000; +const mutation_lock_file_name = "grok-auth.lock"; +const mutation_lock_deadline_ms: u64 = 2000; + +const auth_file_name = profile_paths.grok_auth_file_name; pub fn presence() host.SecretStorePresence { - if (comptime host_target.is_wasm) return .missing; - return native_auth_store.entry_presence(.grok_subscription); + return session_presence.profileFile(auth_file_name, max_auth_file_bytes); } + pub fn refreshDeadlineMs(expires_at_ms: i64) i64 { return @max(expires_at_ms - expiry_skew_ms, 0); } @@ -46,79 +53,82 @@ pub const DeleteOutcome = enum { deleted_not_durable, }; -pub const Mutation = if (host_target.is_wasm) WasmMutation else NativeMutation; - -const WasmMutation = struct { - pub fn deinit(self: *WasmMutation) void { - self.* = undefined; - } - - pub fn load(_: *WasmMutation, _: Allocator) !?Session { - return null; - } - - pub fn save(_: *WasmMutation, _: Allocator, _: Session) !void { - return error.GrokOAuthUnavailable; - } - - pub fn delete(_: *WasmMutation) !DeleteOutcome { - return .missing; - } -}; - -const NativeMutation = struct { - inner: native_auth_store.EntryMutation, +pub const Mutation = struct { + fx_dir: io_mod.VerifiedDir, + lock: io_mod.TimedAdvisoryLock, pub fn deinit(self: *Mutation) void { - self.inner.deinit(); + self.lock.release(); + self.fx_dir.close(); self.* = undefined; } pub fn load(self: *Mutation, alloc: Allocator) !?Session { - const bytes = (try self.inner.load(alloc)) orelse return null; - defer secret.zeroAndFree(alloc, bytes); - return parse(alloc, bytes) catch |err| switch (err) { - error.OutOfMemory => return err, - else => { - debug_trace.logf("auth", "Grok session load failed step=parse_common_mutation err={s}", .{@errorName(err)}); - return null; - }, - }; + return loadFromDir(alloc, &self.fx_dir.dir, true); } pub fn save(self: *Mutation, alloc: Allocator, session: Session) !void { const text = try stringify(alloc, session); defer secret.zeroAndFree(alloc, text); - try self.inner.save(alloc, text); + try io_mod.durableReplaceVerified(alloc, &self.fx_dir, auth_file_name, text); } pub fn delete(self: *Mutation) !DeleteOutcome { - return switch (try self.inner.delete(std.heap.c_allocator)) { - .deleted => .deleted, - .missing => .missing, - .deleted_not_durable => .deleted_not_durable, + self.fx_dir.dir.deleteFile(io_mod.getIo(), auth_file_name) catch |err| switch (err) { + error.FileNotFound => return .missing, + else => return err, }; + const durable: io_mod.DurableOps = .{}; + durable.sync_dir(durable.ctx, self.fx_dir.dir) catch return .deleted_not_durable; + return .deleted; } }; pub fn load(alloc: Allocator) !?Session { if (comptime host_target.is_wasm) return null; - return loadNative(alloc, .active); -} - -pub fn loadStored(alloc: Allocator) !?Session { - if (comptime host_target.is_wasm) return null; - return loadNative(alloc, .inspect); + const home = io_mod.getenv("HOME") orelse return null; + var home_dir = std.Io.Dir.openDirAbsolute(io_mod.getIo(), home, .{ .iterate = true }) catch |err| { + debug_trace.logf("auth", "Grok session load failed step=open_home err={s}", .{@errorName(err)}); + return null; + }; + defer home_dir.close(io_mod.getIo()); + + var fx_dir = home_dir.openDir(io_mod.getIo(), profile_paths.root_dir_name, .{ + .iterate = true, + .follow_symlinks = false, + }) catch |err| { + if (err != error.FileNotFound) { + debug_trace.logf("auth", "Grok session load failed step=open_profile err={s}", .{@errorName(err)}); + } + return null; + }; + defer fx_dir.close(io_mod.getIo()); + return loadFromDir(alloc, &fx_dir, false); } -fn loadNative(alloc: Allocator, intent: @import("auth_store.zig").LoadIntent) !?Session { - const bytes = (native_auth_store.load_entry(alloc, .grok_subscription, intent) catch |err| switch (err) { - error.OutOfMemory => return err, +fn loadFromDir(alloc: Allocator, fx_dir: *std.Io.Dir, report_open_failure: bool) !?Session { + var file = fx_dir.openFile(io_mod.getIo(), auth_file_name, .{ + .mode = .read_only, + .allow_directory = false, + .follow_symlinks = false, + .resolve_beneath = true, + }) catch |err| switch (err) { + error.FileNotFound => return null, else => { - debug_trace.logf("auth", "Grok session load failed step=common_store err={s}", .{@errorName(err)}); + debug_trace.logf("auth", "Grok session load failed step=open_file err={s}", .{@errorName(err)}); + if (report_open_failure) return err; return null; }, - }) orelse return null; + }; + defer file.close(io_mod.getIo()); + + const stat = try file.stat(io_mod.getIo()); + if (stat.kind != .file or stat.permissions.toMode() & 0o077 != 0) { + debug_trace.logf("auth", "Grok session load failed step=permissions err=InsecureAuthFile", .{}); + return null; + } + + const bytes = try io_mod.readFileToEnd(alloc, &file, max_auth_file_bytes); defer secret.zeroAndFree(alloc, bytes); return parse(alloc, bytes) catch |err| switch (err) { error.OutOfMemory => return err, @@ -131,14 +141,67 @@ fn loadNative(alloc: Allocator, intent: @import("auth_store.zig").LoadIntent) !? pub fn saveNewSession(alloc: Allocator, session: Session) !void { if (comptime host_target.is_wasm) return error.GrokOAuthUnavailable; - var mutation = try beginExistingMutation() orelse return error.HomeNotSet; + var mutation = try beginMutation(); defer mutation.deinit(); try mutation.save(alloc, session); } pub fn beginExistingMutation() !?Mutation { if (comptime host_target.is_wasm) return null; - return .{ .inner = try native_auth_store.begin_entry_mutation(.grok_subscription) }; + const home = io_mod.getenv("HOME") orelse return error.HomeNotSet; + var home_dir = io_mod.VerifiedDir{ + .dir = try std.Io.Dir.openDirAbsolute(io_mod.getIo(), home, .{ .iterate = true }), + }; + defer home_dir.close(); + + const fx_dir = openExistingPrivateFxDir(&home_dir) catch |err| switch (err) { + error.FileNotFound => return null, + else => return err, + }; + return try lockMutation(fx_dir); +} + +fn beginMutation() !Mutation { + const home = io_mod.getenv("HOME") orelse return error.HomeNotSet; + var home_dir = io_mod.VerifiedDir{ + .dir = try std.Io.Dir.openDirAbsolute(io_mod.getIo(), home, .{ .iterate = true }), + }; + defer home_dir.close(); + + const fx_dir = try io_mod.openOrCreateVerifiedPrivateDir(&home_dir, profile_paths.root_dir_name); + return lockMutation(fx_dir); +} + +fn lockMutation(open_fx_dir: io_mod.VerifiedDir) !Mutation { + var fx_dir = open_fx_dir; + errdefer fx_dir.close(); + var lock = try io_mod.acquireTimedAdvisoryLock( + &fx_dir, + mutation_lock_file_name, + mutation_lock_deadline_ms, + ); + errdefer lock.release(); + return .{ .fx_dir = fx_dir, .lock = lock }; +} + +fn openExistingPrivateFxDir(home_dir: *io_mod.VerifiedDir) !io_mod.VerifiedDir { + var dir = try home_dir.dir.openDir(io_mod.getIo(), profile_paths.root_dir_name, .{ + .iterate = true, + .follow_symlinks = false, + }); + errdefer dir.close(io_mod.getIo()); + + const initial_stat = try dir.stat(io_mod.getIo()); + if (initial_stat.kind != .directory) return error.DurablePathUnsafe; + if (initial_stat.permissions.toMode() & 0o200 == 0) return error.PrivateStatePermissionsUnsupported; + dir.setPermissions(io_mod.getIo(), std.Io.File.Permissions.fromMode(0o700)) catch { + return error.PrivateStatePermissionsUnsupported; + }; + const stat = try dir.stat(io_mod.getIo()); + if (stat.kind != .directory or stat.permissions.toMode() & 0o777 != 0o700) { + return error.PrivateStatePermissionsUnsupported; + } + return .{ .dir = dir }; } pub fn parse(alloc: Allocator, bytes: []const u8) !Session { diff --git a/src/core/auth/oauth_session.zig b/src/core/auth/oauth_session.zig index c30e5124b..80e9e9609 100644 --- a/src/core/auth/oauth_session.zig +++ b/src/core/auth/oauth_session.zig @@ -1,11 +1,14 @@ const std = @import("std"); +const builtin = @import("builtin"); const debug_trace = @import("../shared/debug_trace.zig"); const host_contract = @import("../hosts/host.zig"); const host_target = @import("../hosts/target.zig"); -const native_auth_store = if (host_target.is_wasm) struct {} else @import("../hosts/native_auth_store.zig"); +const native_keychain = @import("../hosts/native_keychain.zig"); const io_mod = @import("../shared/io.zig"); +const profile_paths = @import("../shared/profile_paths.zig"); const js_host_auth = @import("js_host_auth.zig"); const secret = @import("secret.zig"); +const session_presence = @import("session_presence.zig"); const Allocator = std.mem.Allocator; @@ -13,23 +16,147 @@ pub const issuer = "https://vercel.com"; pub const client_id_env = "FX_OAUTH_CLIENT_ID"; pub const default_client_id = "cl_zzh5hiOZbwJ9bfqEcYqPIJv3TaPaEYL0"; const e2e_issuer_url_env = "FX_E2E_OAUTH_ISSUER_URL"; +pub const auth_file_name = profile_paths.auth_file_name; const schema_version: i64 = 1; +const max_auth_file_bytes: usize = 64 * 1024; const expiry_skew_ms: i64 = 60 * 1000; +const mutation_lock_file_name = "auth.lock"; +const mutation_lock_deadline_ms: u64 = 2000; +const e2e_lock_contention_file_name = "auth-lock-contention"; +const LoadMode = enum { tolerate_open_failure, report_open_failure }; + +const StorageBackend = enum { + profile_file, + macos_keychain, +}; + +const FileState = enum { absent, valid, unusable }; +const KeychainState = enum { absent, valid, invalid, unavailable }; +const Resolution = enum { + missing, + file_migrate, + file_defer_migration, + keychain, + storage_error, +}; + +const Authority = enum { unresolved, missing, profile_file, keychain }; + +const KeychainError = Allocator.Error || native_keychain.Error; + +const KeychainBackend = struct { + context: ?*anyopaque = null, + load_fn: *const fn (?*anyopaque, Allocator) KeychainError!?[]u8, + store_fn: *const fn (?*anyopaque, []const u8) KeychainError!void, + delete_fn: *const fn (?*anyopaque, Allocator) KeychainError!bool, + + fn load(self: KeychainBackend, alloc: Allocator) KeychainError!?[]u8 { + return self.load_fn(self.context, alloc); + } + + fn store(self: KeychainBackend, value: []const u8) KeychainError!void { + return self.store_fn(self.context, value); + } + + fn delete(self: KeychainBackend, alloc: Allocator) KeychainError!bool { + return self.delete_fn(self.context, alloc); + } +}; + +const native_keychain_backend: KeychainBackend = .{ + .load_fn = nativeKeychainLoad, + .store_fn = nativeKeychainStore, + .delete_fn = nativeKeychainDelete, +}; + +fn nativeKeychainLoad(_: ?*anyopaque, alloc: Allocator) KeychainError!?[]u8 { + return native_keychain.loadOAuthSession(alloc); +} + +fn nativeKeychainStore(_: ?*anyopaque, value: []const u8) KeychainError!void { + return native_keychain.storeOAuthSession(value); +} + +fn nativeKeychainDelete(_: ?*anyopaque, alloc: Allocator) KeychainError!bool { + return native_keychain.deleteOAuthSession(alloc); +} + +fn selectStorageBackend(os_tag: std.Target.Os.Tag, keychain_disabled: bool) StorageBackend { + if (os_tag == .macos and !keychain_disabled) return .macos_keychain; + return .profile_file; +} + +fn storageBackend() StorageBackend { + // A temporary HOME does not isolate the host account's macOS Keychain. + // Keychain-specific tests inject their backend explicitly. + if (comptime builtin.is_test) return .profile_file; + return selectStorageBackend(builtin.os.tag, native_keychain.isDisabled()); +} + pub fn presence() host_contract.SecretStorePresence { - if (comptime host_target.is_wasm) { - const alloc = std.heap.c_allocator; - var stored = (js_host_auth.oauth_session_store.load(alloc) catch return .unavailable) orelse - return .missing; - defer stored.deinit(alloc); - return .present; + const file_presence = session_presence.profileFile( + auth_file_name, + max_auth_file_bytes, + ); + if (storageBackend() == .profile_file or file_presence == .present) { + return file_presence; + } + const keychain_presence = native_keychain.oauthSessionPresence() catch + return .unavailable; + if (keychain_presence == .present) return .present; + if (file_presence == .missing and keychain_presence == .missing) { + return .missing; } - return native_auth_store.entry_presence(.fx_login); + return .unavailable; +} + +fn selectResolution(file: FileState, keychain: KeychainState) Resolution { + return switch (file) { + .valid => if (keychain == .unavailable) .file_defer_migration else .file_migrate, + .absent, .unusable => switch (keychain) { + .absent => .missing, + .valid => .keychain, + .invalid, .unavailable => .storage_error, + }, + }; } pub fn refresh_deadline_ms(expires_at_ms: i64) i64 { return expires_at_ms -| expiry_skew_ms; } +const MutationLockProbe = struct { + fx_dir: std.Io.Dir, + signaled: bool = false, + + fn tryLock(raw_ctx: ?*anyopaque, file: std.Io.File) anyerror!bool { + const locked = try file.tryLock(io_mod.getIo(), .exclusive); + const self: *MutationLockProbe = @ptrCast(@alignCast(raw_ctx.?)); + if (!locked and !self.signaled) { + signalE2ELockContention(self.fx_dir); + self.signaled = true; + } + return locked; + } +}; + +fn signalE2ELockContention(fx_dir: std.Io.Dir) void { + const enabled = io_mod.getenv("FX_E2E_AUTH_LOCK_CONTENTION") orelse return; + if (!std.mem.eql(u8, enabled, "1")) return; + var file = fx_dir.createFile(io_mod.getIo(), e2e_lock_contention_file_name, .{ + .truncate = true, + .permissions = std.Io.File.Permissions.fromMode(0o600), + }) catch return; + defer file.close(io_mod.getIo()); + file.writeStreamingAll(io_mod.getIo(), "contended\n") catch {}; +} + +const DeleteOutcome = enum { + deleted, + missing, + deleted_not_durable, +}; + pub const DeleteResult = struct { session_deleted: bool = false, local_cleanup_failed: bool = false, @@ -63,42 +190,295 @@ pub const Session = struct { } }; -pub const Mutation = if (host_target.is_wasm) HostMutation else CommonNativeMutation; +const FileObservation = union(enum) { + absent, + valid: Session, + unusable, -const CommonNativeMutation = struct { - inner: native_auth_store.EntryMutation, + fn state(self: FileObservation) FileState { + return switch (self) { + .absent => .absent, + .valid => .valid, + .unusable => .unusable, + }; + } - pub fn deinit(self: *CommonNativeMutation) void { - self.inner.deinit(); - self.* = undefined; + fn takeSession(self: *FileObservation) ?Session { + return switch (self.*) { + .valid => |session| blk: { + self.* = .absent; + break :blk session; + }, + else => null, + }; } - pub fn load(self: *CommonNativeMutation, alloc: Allocator) !?Session { - const bytes = (try self.inner.load(alloc)) orelse return null; - defer secret.zeroAndFree(alloc, bytes); - return parse(alloc, bytes) catch |err| switch (err) { - error.OutOfMemory => return err, - else => { - debug_trace.logf("auth", "session load failed step=parse_common_mutation err={s}", .{@errorName(err)}); - return null; + fn deinit(self: *FileObservation, alloc: Allocator) void { + switch (self.*) { + .valid => |*session| session.deinit(alloc), + else => {}, + } + self.* = .absent; + } +}; + +const KeychainObservation = union(enum) { + absent, + valid: Session, + invalid, + unavailable: KeychainError, + + fn state(self: KeychainObservation) KeychainState { + return switch (self) { + .absent => .absent, + .valid => .valid, + .invalid => .invalid, + .unavailable => .unavailable, + }; + } + + fn takeSession(self: *KeychainObservation) ?Session { + return switch (self.*) { + .valid => |session| blk: { + self.* = .absent; + break :blk session; }, + else => null, + }; + } + + fn storageError(self: KeychainObservation) (KeychainError || error{ + InvalidOAuthKeychainSession, + InvalidOAuthStorageState, + }) { + return switch (self) { + .invalid => error.InvalidOAuthKeychainSession, + .unavailable => |err| err, + else => error.InvalidOAuthStorageState, }; } - pub fn save(self: *CommonNativeMutation, alloc: Allocator, session: Session) !void { + fn deinit(self: *KeychainObservation, alloc: Allocator) void { + switch (self.*) { + .valid => |*session| session.deinit(alloc), + else => {}, + } + self.* = .absent; + } +}; + +fn observeAuthFile(alloc: Allocator, fx_dir: *std.Io.Dir) !FileObservation { + var file = fx_dir.openFile(io_mod.getIo(), auth_file_name, .{ + .mode = .read_only, + .allow_directory = false, + .follow_symlinks = false, + .resolve_beneath = true, + }) catch |err| switch (err) { + error.FileNotFound => return .absent, + else => { + debug_trace.logf("auth", "session load failed source=file step=open err={s}", .{@errorName(err)}); + return .unusable; + }, + }; + defer file.close(io_mod.getIo()); + + const stat = file.stat(io_mod.getIo()) catch |err| { + debug_trace.logf("auth", "session load failed source=file step=stat err={s}", .{@errorName(err)}); + return .unusable; + }; + if (stat.kind != .file or stat.nlink != 1 or stat.permissions.toMode() & 0o077 != 0) { + debug_trace.logf("auth", "session load failed source=file step=permissions err=InsecureAuthFile", .{}); + return .unusable; + } + + const bytes = io_mod.readFileToEnd(alloc, &file, max_auth_file_bytes) catch |err| switch (err) { + error.OutOfMemory => return err, + else => { + debug_trace.logf("auth", "session load failed source=file step=read err={s}", .{@errorName(err)}); + return .unusable; + }, + }; + defer secret.zeroAndFree(alloc, bytes); + const session = parse(alloc, bytes) catch |err| switch (err) { + error.OutOfMemory => return err, + else => { + debug_trace.logf("auth", "session load failed source=file step=parse err={s}", .{@errorName(err)}); + return .unusable; + }, + }; + return .{ .valid = session }; +} + +fn observeKeychain(alloc: Allocator, keychain: KeychainBackend) !KeychainObservation { + const maybe_bytes = keychain.load(alloc) catch |err| switch (err) { + error.OutOfMemory => return err, + error.KeychainItemNotFound => return .absent, + else => return .{ .unavailable = err }, + }; + const bytes = maybe_bytes orelse return .absent; + defer secret.zeroAndFree(alloc, bytes); + if (bytes.len > max_auth_file_bytes) return .invalid; + const session = parse(alloc, bytes) catch |err| switch (err) { + error.OutOfMemory => return err, + else => return .invalid, + }; + return .{ .valid = session }; +} + +fn publishAndVerifyKeychain(alloc: Allocator, keychain: KeychainBackend, session: Session) !void { + const bytes = try stringify(alloc, session); + defer secret.zeroAndFree(alloc, bytes); + try keychain.store(bytes); + const persisted = keychain.load(alloc) catch |err| switch (err) { + error.KeychainItemNotFound => return error.OAuthSessionKeychainWriteMismatch, + else => return err, + } orelse return error.OAuthSessionKeychainWriteMismatch; + defer secret.zeroAndFree(alloc, persisted); + if (!std.mem.eql(u8, bytes, persisted)) return error.OAuthSessionKeychainWriteMismatch; +} + +fn authFileExists(fx_dir: *std.Io.Dir) !bool { + _ = fx_dir.statFile(io_mod.getIo(), auth_file_name, .{}) catch |err| switch (err) { + error.FileNotFound => return false, + else => return err, + }; + return true; +} + +pub const Mutation = if (host_target.is_wasm) HostMutation else NativeMutation; + +const NativeMutation = struct { + fx_dir: io_mod.VerifiedDir, + lock: io_mod.TimedAdvisoryLock, + backend: StorageBackend, + keychain: KeychainBackend, + authority: Authority = .unresolved, + + pub fn deinit(self: *Mutation) void { + self.lock.release(); + self.fx_dir.close(); + self.* = undefined; + } + + pub fn load(self: *Mutation, alloc: Allocator) !?Session { + if (self.backend == .profile_file) { + self.authority = .profile_file; + return loadFromDir(alloc, &self.fx_dir.dir, .report_open_failure); + } + return self.loadKeychainResolved(alloc); + } + + pub fn save(self: *Mutation, alloc: Allocator, session: Session) !void { + if (self.backend == .macos_keychain) { + return self.saveKeychainResolved(alloc, session); + } + self.authority = .profile_file; + return self.saveFile(alloc, session); + } + + fn saveFile(self: *Mutation, alloc: Allocator, session: Session) !void { const text = try stringify(alloc, session); defer secret.zeroAndFree(alloc, text); - try self.inner.save(alloc, text); + try io_mod.durableReplaceVerified(alloc, &self.fx_dir, auth_file_name, text); } - pub fn delete(self: *CommonNativeMutation, alloc: Allocator) !DeleteResult { - return switch (try self.inner.delete(alloc)) { - .deleted => .{ .session_deleted = true }, - .missing => .{}, - .deleted_not_durable => .{ - .session_deleted = true, - .local_cleanup_failed = true, + pub fn delete(self: *Mutation, alloc: Allocator) !DeleteResult { + var result: DeleteResult = .{}; + if (self.backend == .macos_keychain) { + const deleted = self.keychain.delete(alloc) catch blk: { + result.local_cleanup_failed = true; + break :blk false; + }; + result.session_deleted = result.session_deleted or deleted; + } + + const file_outcome = deleteAuthFile(&self.fx_dir.dir, .{}) catch { + result.local_cleanup_failed = true; + return result; + }; + switch (file_outcome) { + .missing => {}, + .deleted => result.session_deleted = true, + .deleted_not_durable => { + result.session_deleted = true; + result.local_cleanup_failed = true; + }, + } + self.authority = .missing; + return result; + } + + fn loadKeychainResolved(self: *Mutation, alloc: Allocator) !?Session { + var file = try observeAuthFile(alloc, &self.fx_dir.dir); + defer file.deinit(alloc); + var keychain = try observeKeychain(alloc, self.keychain); + defer keychain.deinit(alloc); + + switch (selectResolution(file.state(), keychain.state())) { + .missing => { + self.authority = .missing; + return null; + }, + .keychain => { + self.authority = .keychain; + return keychain.takeSession().?; + }, + .file_defer_migration => { + self.authority = .profile_file; + return file.takeSession().?; + }, + .file_migrate => { + var session = file.takeSession().?; + errdefer session.deinit(alloc); + const migrated = try self.publishAndCleanup(alloc, session); + self.authority = if (migrated) .keychain else .profile_file; + return session; }, + .storage_error => return keychain.storageError(), + } + } + + fn saveKeychainResolved(self: *Mutation, alloc: Allocator, session: Session) !void { + if (self.authority == .unresolved) { + self.authority = if (try authFileExists(&self.fx_dir.dir)) .profile_file else .keychain; + } + + switch (self.authority) { + .profile_file => { + try self.saveFile(alloc, session); + const migrated = try self.publishAndCleanup(alloc, session); + if (migrated) self.authority = .keychain; + }, + .keychain, .missing => { + try publishAndVerifyKeychain(alloc, self.keychain, session); + self.authority = .keychain; + }, + .unresolved => unreachable, + } + } + + fn publishAndCleanup(self: *Mutation, alloc: Allocator, session: Session) !bool { + publishAndVerifyKeychain(alloc, self.keychain, session) catch |err| { + debug_trace.logf("auth", "session migration deferred step=publish err={s}", .{@errorName(err)}); + return false; + }; + + const outcome = deleteAuthFile(&self.fx_dir.dir, .{}) catch |err| { + const stat = self.fx_dir.dir.statFile(io_mod.getIo(), auth_file_name, .{}) catch |stat_err| switch (stat_err) { + error.FileNotFound => return error.OAuthSessionCleanupUncertain, + else => return stat_err, + }; + if (stat.kind != .file) return error.OAuthSessionCleanupUncertain; + debug_trace.logf("auth", "session migration deferred step=delete err={s}", .{@errorName(err)}); + return false; + }; + return switch (outcome) { + .deleted => true, + .missing => blk: { + try io_mod.syncVerifiedDir(self.fx_dir.dir); + break :blk true; + }, + .deleted_not_durable => error.OAuthSessionCleanupUncertain, }; } }; @@ -213,36 +593,72 @@ fn isLoopbackHttpUrl(url: []const u8, require_origin: bool) bool { pub fn load(alloc: Allocator) !?Session { if (comptime host_target.is_wasm) return loadFromHost(alloc, js_host_auth.oauth_session_store); - return loadNative(alloc, .active); -} + const home = io_mod.getenv("HOME") orelse { + debug_trace.logf("auth", "session load skipped step=home err=HomeNotSet", .{}); + return null; + }; + if (storageBackend() == .macos_keychain) { + if (try beginExistingNativeMutation()) |existing| { + var mutation = existing; + defer mutation.deinit(); + return mutation.load(alloc); + } + return loadKeychainWithoutProfile(alloc); + } + var home_dir = std.Io.Dir.openDirAbsolute(io_mod.getIo(), home, .{ .iterate = true }) catch |err| { + debug_trace.logf("auth", "session load failed step=open_home err={s}", .{@errorName(err)}); + return null; + }; + defer home_dir.close(io_mod.getIo()); -pub fn loadStored(alloc: Allocator) !?Session { - if (comptime host_target.is_wasm) return loadFromHost(alloc, js_host_auth.oauth_session_store); - return loadNative(alloc, .inspect); + var fx_dir = home_dir.openDir(io_mod.getIo(), profile_paths.root_dir_name, .{ + .iterate = true, + .follow_symlinks = false, + }) catch |err| { + debug_trace.logf("auth", "session load failed step=open_profile err={s}", .{@errorName(err)}); + return null; + }; + defer fx_dir.close(io_mod.getIo()); + + return loadFromDir(alloc, &fx_dir, .tolerate_open_failure); } -fn loadNative(alloc: Allocator, intent: @import("auth_store.zig").LoadIntent) !?Session { - const bytes = (native_auth_store.load_entry(alloc, .fx_login, intent) catch |err| switch (err) { +fn loadFromHost(alloc: Allocator, store: js_host_auth.SessionStore) !?Session { + var stored = (try store.load(alloc)) orelse return null; + defer stored.deinit(alloc); + return parse(alloc, stored.bytes) catch |err| switch (err) { error.OutOfMemory => return err, else => { - debug_trace.logf("auth", "session load failed step=common_store err={s}", .{@errorName(err)}); + debug_trace.logf("auth", "session load failed step=parse err={s}", .{@errorName(err)}); return null; }, - }) orelse return null; - defer secret.zeroAndFree(alloc, bytes); - return parse(alloc, bytes) catch |err| switch (err) { - error.OutOfMemory => return err, + }; +} + +fn loadFromDir(alloc: Allocator, fx_dir: *std.Io.Dir, mode: LoadMode) !?Session { + var file = fx_dir.openFile(io_mod.getIo(), auth_file_name, .{ + .mode = .read_only, + .allow_directory = false, + .follow_symlinks = false, + .resolve_beneath = true, + }) catch |err| switch (err) { + error.FileNotFound => return null, else => { - debug_trace.logf("auth", "session load failed step=parse_common err={s}", .{@errorName(err)}); - return null; + debug_trace.logf("auth", "session load failed step=open_file err={s}", .{@errorName(err)}); + if (mode == .tolerate_open_failure) return null else return err; }, }; -} + defer file.close(io_mod.getIo()); -fn loadFromHost(alloc: Allocator, store: js_host_auth.SessionStore) !?Session { - var stored = (try store.load(alloc)) orelse return null; - defer stored.deinit(alloc); - return parse(alloc, stored.bytes) catch |err| switch (err) { + const stat = try file.stat(io_mod.getIo()); + if (stat.kind != .file or stat.permissions.toMode() & 0o077 != 0) { + debug_trace.logf("auth", "session load failed step=permissions err=InsecureAuthFile", .{}); + return null; + } + + const bytes = try io_mod.readFileToEnd(alloc, &file, max_auth_file_bytes); + defer secret.zeroAndFree(alloc, bytes); + return parse(alloc, bytes) catch |err| switch (err) { error.OutOfMemory => return err, else => { debug_trace.logf("auth", "session load failed step=parse err={s}", .{@errorName(err)}); @@ -258,7 +674,7 @@ pub fn saveNewSession(alloc: Allocator, session: Session) !void { try mutation.captureRevision(alloc); return mutation.save(alloc, session); } - var mutation = try beginExistingMutation() orelse return error.HomeNotSet; + var mutation = try beginMutation(); defer mutation.deinit(); try mutation.save(alloc, session); } @@ -267,7 +683,133 @@ pub fn beginExistingMutation() !?Mutation { if (comptime host_target.is_wasm) { return @as(?Mutation, HostMutation.init(js_host_auth.oauth_session_store)); } - return .{ .inner = try native_auth_store.begin_entry_mutation(.fx_login) }; + if (storageBackend() == .macos_keychain) { + return @as(?Mutation, try beginMutation()); + } + return beginExistingNativeMutation(); +} + +fn beginExistingNativeMutation() !?Mutation { + const home = io_mod.getenv("HOME") orelse return error.HomeNotSet; + var home_dir = io_mod.VerifiedDir{ + .dir = try std.Io.Dir.openDirAbsolute(io_mod.getIo(), home, .{ .iterate = true }), + }; + defer home_dir.close(); + + const fx_dir = openExistingPrivateFxDir(&home_dir) catch |err| switch (err) { + error.FileNotFound => return null, + else => return err, + }; + return @as(?Mutation, try lockMutation(fx_dir)); +} + +fn loadKeychainWithoutProfile(alloc: Allocator) !?Session { + var keychain = try observeKeychain(alloc, native_keychain_backend); + defer keychain.deinit(alloc); + + if (try beginExistingNativeMutation()) |existing| { + var mutation = existing; + defer mutation.deinit(); + return mutation.load(alloc); + } + + return switch (selectResolution(.absent, keychain.state())) { + .missing => null, + .keychain => keychain.takeSession().?, + .storage_error => keychain.storageError(), + .file_migrate, .file_defer_migration => unreachable, + }; +} + +fn beginMutation() !Mutation { + const home = io_mod.getenv("HOME") orelse return error.HomeNotSet; + var home_dir = io_mod.VerifiedDir{ + .dir = try std.Io.Dir.openDirAbsolute(io_mod.getIo(), home, .{ .iterate = true }), + }; + defer home_dir.close(); + + const fx_dir = try io_mod.openOrCreateVerifiedPrivateDir(&home_dir, profile_paths.root_dir_name); + return lockMutation(fx_dir); +} + +fn lockMutation(open_fx_dir: io_mod.VerifiedDir) !Mutation { + var probe = MutationLockProbe{ .fx_dir = open_fx_dir.dir }; + return lockMutationWithOps(open_fx_dir, mutation_lock_deadline_ms, .{ + .ctx = &probe, + .try_lock = MutationLockProbe.tryLock, + }); +} + +fn lockMutationWithOps( + open_fx_dir: io_mod.VerifiedDir, + deadline_ms: u64, + ops: io_mod.LockOps, +) !Mutation { + return lockMutationWithBackend( + open_fx_dir, + deadline_ms, + ops, + storageBackend(), + native_keychain_backend, + ); +} + +fn lockMutationWithBackend( + open_fx_dir: io_mod.VerifiedDir, + deadline_ms: u64, + ops: io_mod.LockOps, + backend: StorageBackend, + keychain: KeychainBackend, +) !Mutation { + var fx_dir = open_fx_dir; + errdefer fx_dir.close(); + + var lock = try io_mod.acquireTimedAdvisoryLockWithOps( + &fx_dir, + mutation_lock_file_name, + deadline_ms, + ops, + ); + errdefer lock.release(); + + return .{ + .fx_dir = fx_dir, + .lock = lock, + .backend = backend, + .keychain = keychain, + }; +} + +fn openExistingPrivateFxDir(home_dir: *io_mod.VerifiedDir) !io_mod.VerifiedDir { + var dir = try home_dir.dir.openDir(io_mod.getIo(), profile_paths.root_dir_name, .{ + .iterate = true, + .follow_symlinks = false, + }); + errdefer dir.close(io_mod.getIo()); + + const initial_stat = try dir.stat(io_mod.getIo()); + if (initial_stat.kind != .directory) return error.DurablePathUnsafe; + if (initial_stat.permissions.toMode() & 0o200 == 0) { + return error.PrivateStatePermissionsUnsupported; + } + dir.setPermissions(io_mod.getIo(), std.Io.File.Permissions.fromMode(0o700)) catch { + return error.PrivateStatePermissionsUnsupported; + }; + const stat = try dir.stat(io_mod.getIo()); + if (stat.kind != .directory) return error.DurablePathUnsafe; + if (stat.permissions.toMode() & 0o777 != 0o700) { + return error.PrivateStatePermissionsUnsupported; + } + return .{ .dir = dir }; +} + +fn deleteAuthFile(fx_dir: *std.Io.Dir, ops: io_mod.DurableOps) !DeleteOutcome { + fx_dir.deleteFile(io_mod.getIo(), auth_file_name) catch |err| switch (err) { + error.FileNotFound => return .missing, + else => return err, + }; + ops.sync_dir(ops.ctx, fx_dir.*) catch return .deleted_not_durable; + return .deleted; } pub fn parse(alloc: Allocator, bytes: []const u8) !Session { @@ -430,6 +972,11 @@ fn check_parse_allocation_failures(alloc: Allocator) !void { defer session.deinit(alloc); } +fn check_load_allocation_failures(alloc: Allocator, dir: *std.Io.Dir) !void { + var session = (try loadFromDir(alloc, dir, .report_open_failure)) orelse return error.TestUnexpectedMissingSession; + defer session.deinit(alloc); +} + test "oauth session stringifies and parses" { var session = Session{ .issuer = try std.testing.allocator.dupe(u8, issuer), @@ -455,6 +1002,254 @@ test "oauth session stringifies and parses" { try std.testing.expectEqualStrings("team_123", parsed.team_id.?); } +test "OAuth storage backend selection is platform scoped and explicitly disableable" { + try std.testing.expectEqual(StorageBackend.macos_keychain, selectStorageBackend(.macos, false)); + try std.testing.expectEqual(StorageBackend.profile_file, selectStorageBackend(.macos, true)); + try std.testing.expectEqual(StorageBackend.profile_file, selectStorageBackend(.linux, false)); + try std.testing.expectEqual(StorageBackend.profile_file, selectStorageBackend(.windows, false)); +} + +test "OAuth test builds use profile file storage" { + try std.testing.expectEqual(StorageBackend.profile_file, storageBackend()); +} + +test "OAuth source resolution covers every file and Keychain state" { + const cases = [_]struct { + file: FileState, + keychain: KeychainState, + expected: Resolution, + }{ + .{ .file = .absent, .keychain = .absent, .expected = .missing }, + .{ .file = .absent, .keychain = .valid, .expected = .keychain }, + .{ .file = .absent, .keychain = .invalid, .expected = .storage_error }, + .{ .file = .absent, .keychain = .unavailable, .expected = .storage_error }, + .{ .file = .valid, .keychain = .absent, .expected = .file_migrate }, + .{ .file = .valid, .keychain = .valid, .expected = .file_migrate }, + .{ .file = .valid, .keychain = .invalid, .expected = .file_migrate }, + .{ .file = .valid, .keychain = .unavailable, .expected = .file_defer_migration }, + .{ .file = .unusable, .keychain = .valid, .expected = .keychain }, + .{ .file = .unusable, .keychain = .absent, .expected = .missing }, + .{ .file = .unusable, .keychain = .invalid, .expected = .storage_error }, + .{ .file = .unusable, .keychain = .unavailable, .expected = .storage_error }, + }; + + for (cases) |case| { + try std.testing.expectEqual(case.expected, selectResolution(case.file, case.keychain)); + } +} + +const alternate_test_session_json = "{\"version\":1,\"issuer\":\"https://vercel.com\",\"client_id\":\"client\",\"access_token\":\"keychain-access\",\"refresh_token\":\"keychain-refresh\",\"expires_at_ms\":2,\"scope\":\"openid offline_access\",\"token_type\":\"Bearer\"}"; + +const FakeOAuthKeychain = struct { + alloc: Allocator, + value: ?[]u8 = null, + store_calls: usize = 0, + delete_calls: usize = 0, + fail_store: bool = false, + fail_delete: bool = false, + + fn deinit(self: *FakeOAuthKeychain) void { + if (self.value) |value| secret.zeroAndFree(self.alloc, value); + self.* = undefined; + } + + fn backend(self: *FakeOAuthKeychain) KeychainBackend { + return .{ + .context = self, + .load_fn = loadCallback, + .store_fn = storeCallback, + .delete_fn = deleteCallback, + }; + } + + fn loadCallback(raw: ?*anyopaque, alloc: Allocator) KeychainError!?[]u8 { + const self: *FakeOAuthKeychain = @ptrCast(@alignCast(raw.?)); + const value = self.value orelse return null; + return try alloc.dupe(u8, value); + } + + fn storeCallback(raw: ?*anyopaque, value: []const u8) KeychainError!void { + const self: *FakeOAuthKeychain = @ptrCast(@alignCast(raw.?)); + self.store_calls += 1; + if (self.fail_store) return error.KeychainWriteFailed; + const replacement = try self.alloc.dupe(u8, value); + if (self.value) |previous| secret.zeroAndFree(self.alloc, previous); + self.value = replacement; + } + + fn deleteCallback(raw: ?*anyopaque, _: Allocator) KeychainError!bool { + const self: *FakeOAuthKeychain = @ptrCast(@alignCast(raw.?)); + self.delete_calls += 1; + if (self.fail_delete) return error.KeychainDeleteFailed; + const previous = self.value orelse return false; + secret.zeroAndFree(self.alloc, previous); + self.value = null; + return true; + } +}; + +fn writeTestAuthFile(dir: std.Io.Dir, contents: []const u8) !void { + var file = try dir.createFile(std.testing.io, auth_file_name, .{ + .truncate = true, + .permissions = std.Io.File.Permissions.fromMode(0o600), + }); + defer file.close(std.testing.io); + try file.writeStreamingAll(std.testing.io, contents); +} + +fn testKeychainMutation(dir: std.Io.Dir, keychain: KeychainBackend) !Mutation { + return lockMutationWithBackend( + .{ .dir = try dir.openDir(std.testing.io, ".", .{ .iterate = true }) }, + 0, + .{}, + .macos_keychain, + keychain, + ); +} + +test "OAuth migration keeps a valid file authoritative until verified cleanup" { + const alloc = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try writeTestAuthFile(tmp.dir, test_session_json); + + var fake = FakeOAuthKeychain{ + .alloc = alloc, + .value = try alloc.dupe(u8, alternate_test_session_json), + }; + defer fake.deinit(); + var mutation = try testKeychainMutation(tmp.dir, fake.backend()); + defer mutation.deinit(); + + var loaded = (try mutation.load(alloc)).?; + defer loaded.deinit(alloc); + try std.testing.expectEqualStrings("access", loaded.access_token); + try std.testing.expectEqual(@as(usize, 1), fake.store_calls); + try std.testing.expectEqual(Authority.keychain, mutation.authority); + try std.testing.expectError( + error.FileNotFound, + tmp.dir.statFile(std.testing.io, auth_file_name, .{}), + ); + + var persisted = try parse(alloc, fake.value.?); + defer persisted.deinit(alloc); + try std.testing.expectEqualStrings("access", persisted.access_token); + + secret.zeroAndFree(alloc, loaded.access_token); + loaded.access_token = try alloc.dupe(u8, "keychain-only-update"); + try mutation.save(alloc, loaded); + try std.testing.expectError( + error.FileNotFound, + tmp.dir.statFile(std.testing.io, auth_file_name, .{}), + ); + var updated = try parse(alloc, fake.value.?); + defer updated.deinit(alloc); + try std.testing.expectEqualStrings("keychain-only-update", updated.access_token); +} + +test "new OAuth login replaces an existing file before deferred migration" { + const alloc = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try writeTestAuthFile(tmp.dir, test_session_json); + + var fake = FakeOAuthKeychain{ .alloc = alloc, .fail_store = true }; + defer fake.deinit(); + var mutation = try testKeychainMutation(tmp.dir, fake.backend()); + defer mutation.deinit(); + var replacement = try parse(alloc, alternate_test_session_json); + defer replacement.deinit(alloc); + + try mutation.save(alloc, replacement); + var persisted = (try loadFromDir(alloc, &tmp.dir, .report_open_failure)).?; + defer persisted.deinit(alloc); + try std.testing.expectEqualStrings("keychain-access", persisted.access_token); + try std.testing.expectEqual(Authority.profile_file, mutation.authority); +} + +test "OAuth migration preserves and updates the file when Keychain publication fails" { + const alloc = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try writeTestAuthFile(tmp.dir, test_session_json); + + var fake = FakeOAuthKeychain{ .alloc = alloc, .fail_store = true }; + defer fake.deinit(); + var mutation = try testKeychainMutation(tmp.dir, fake.backend()); + defer mutation.deinit(); + + var loaded = (try mutation.load(alloc)).?; + defer loaded.deinit(alloc); + try std.testing.expectEqual(Authority.profile_file, mutation.authority); + secret.zeroAndFree(alloc, loaded.access_token); + loaded.access_token = try alloc.dupe(u8, "updated-access"); + try mutation.save(alloc, loaded); + + var persisted = (try loadFromDir(alloc, &tmp.dir, .report_open_failure)).?; + defer persisted.deinit(alloc); + try std.testing.expectEqualStrings("updated-access", persisted.access_token); + try std.testing.expectEqual(@as(usize, 2), fake.store_calls); +} + +test "OAuth resolver uses valid Keychain state without deleting an unusable file" { + const alloc = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try writeTestAuthFile(tmp.dir, "not-json"); + + var fake = FakeOAuthKeychain{ + .alloc = alloc, + .value = try alloc.dupe(u8, alternate_test_session_json), + }; + defer fake.deinit(); + var mutation = try testKeychainMutation(tmp.dir, fake.backend()); + defer mutation.deinit(); + + var loaded = (try mutation.load(alloc)).?; + defer loaded.deinit(alloc); + try std.testing.expectEqualStrings("keychain-access", loaded.access_token); + _ = try tmp.dir.statFile(std.testing.io, auth_file_name, .{}); +} + +test "OAuth resolver reports invalid Keychain state when no valid file exists" { + const alloc = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var fake = FakeOAuthKeychain{ + .alloc = alloc, + .value = try alloc.dupe(u8, "not-json"), + }; + defer fake.deinit(); + var mutation = try testKeychainMutation(tmp.dir, fake.backend()); + defer mutation.deinit(); + + try std.testing.expectError(error.InvalidOAuthKeychainSession, mutation.load(alloc)); +} + +test "OAuth logout deletion attempts Keychain and file cleanup independently" { + const alloc = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try writeTestAuthFile(tmp.dir, test_session_json); + var fake = FakeOAuthKeychain{ + .alloc = alloc, + .value = try alloc.dupe(u8, alternate_test_session_json), + .fail_delete = true, + }; + defer fake.deinit(); + var mutation = try testKeychainMutation(tmp.dir, fake.backend()); + defer mutation.deinit(); + + const result = try mutation.delete(alloc); + try std.testing.expect(result.session_deleted); + try std.testing.expect(result.local_cleanup_failed); + try std.testing.expectEqual(@as(usize, 1), fake.delete_calls); + try std.testing.expectError( + error.FileNotFound, + tmp.dir.statFile(std.testing.io, auth_file_name, .{}), + ); +} + test "JS host OAuth session load commit and remove preserve the native format and revision" { var state: HostStoreTestState = .{}; var loaded = (try loadFromHost(std.testing.allocator, state.provider())).?; @@ -502,6 +1297,39 @@ test "oauth session parse rejects non-object JSON" { try std.testing.expectError(error.InvalidAuthSession, parse(std.testing.allocator, "[]")); } +test "oauth session loading propagates allocation failures" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + var file = try tmp.dir.createFile(std.testing.io, auth_file_name, .{ + .permissions = std.Io.File.Permissions.fromMode(0o600), + }); + try file.writeStreamingAll( + std.testing.io, + test_session_json, + ); + file.close(std.testing.io); + + try std.testing.checkAllAllocationFailures( + std.testing.allocator, + check_load_allocation_failures, + .{&tmp.dir}, + ); +} + +test "OAuth mutation loads report auth file open failures" { + if (comptime @import("builtin").os.tag == .windows) return error.SkipZigTest; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.symLink(std.testing.io, "missing-auth-target", auth_file_name, .{ .is_directory = false }); + + try std.testing.expect((try loadFromDir(std.testing.allocator, &tmp.dir, .tolerate_open_failure)) == null); + try std.testing.expectError( + error.SymLinkLoop, + loadFromDir(std.testing.allocator, &tmp.dir, .report_open_failure), + ); +} + test "oauth session rejects invalid saved issuers" { try std.testing.expectError( error.InvalidAuthSession, @@ -528,6 +1356,84 @@ test "oauth session treats near-expiry as expired" { try std.testing.expect(session.expired(std.math.maxInt(i64))); } +const DeleteSyncProbe = struct { + sync_count: usize = 0, + fail: bool = false, + + fn syncDir(raw_ctx: ?*anyopaque, _: std.Io.Dir) anyerror!void { + const self: *DeleteSyncProbe = @ptrCast(@alignCast(raw_ctx.?)); + self.sync_count += 1; + if (self.fail) return error.InjectedSyncFailure; + } +}; + +test "OAuth session deletion reports deleted and missing files" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + var file = try tmp.dir.createFile(std.testing.io, auth_file_name, .{}); + file.close(std.testing.io); + + var probe: DeleteSyncProbe = .{}; + const ops = io_mod.DurableOps{ + .ctx = &probe, + .sync_dir = DeleteSyncProbe.syncDir, + }; + const deleted = try deleteAuthFile(&tmp.dir, ops); + try std.testing.expectEqual(DeleteOutcome.deleted, deleted); + try std.testing.expectEqual(@as(usize, 1), probe.sync_count); + const missing = try deleteAuthFile(&tmp.dir, ops); + try std.testing.expectEqual(DeleteOutcome.missing, missing); + try std.testing.expectEqual(@as(usize, 1), probe.sync_count); +} + +test "OAuth session deletion reports directory sync failure after unlink" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + var file = try tmp.dir.createFile(std.testing.io, auth_file_name, .{}); + file.close(std.testing.io); + + var probe = DeleteSyncProbe{ .fail = true }; + const ops = io_mod.DurableOps{ + .ctx = &probe, + .sync_dir = DeleteSyncProbe.syncDir, + }; + const outcome = try deleteAuthFile(&tmp.dir, ops); + try std.testing.expectEqual(DeleteOutcome.deleted_not_durable, outcome); + try std.testing.expectEqual(@as(usize, 1), probe.sync_count); + try std.testing.expectError( + error.FileNotFound, + tmp.dir.statFile(std.testing.io, auth_file_name, .{}), + ); + + probe.fail = false; + const missing = try deleteAuthFile(&tmp.dir, ops); + try std.testing.expectEqual(DeleteOutcome.missing, missing); + try std.testing.expectEqual(@as(usize, 1), probe.sync_count); +} + +test "OAuth session mutation lock serializes independent handles" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + var first = try lockMutationWithOps( + .{ .dir = try tmp.dir.openDir(std.testing.io, ".", .{ .iterate = true }) }, + 0, + .{}, + ); + defer first.deinit(); + + try std.testing.expectError( + error.LockBusy, + lockMutationWithOps( + .{ .dir = try tmp.dir.openDir(std.testing.io, ".", .{ .iterate = true }) }, + 0, + .{}, + ), + ); +} + test "oauth E2E issuer override accepts loopback HTTP only" { try std.testing.expectEqualStrings( "http://127.0.0.1:43123", diff --git a/src/core/hosts/host.zig b/src/core/hosts/host.zig index 8e80c7e4f..caf03eab0 100644 --- a/src/core/hosts/host.zig +++ b/src/core/hosts/host.zig @@ -102,10 +102,6 @@ pub const SecretStore = struct { ?*anyopaque, std.mem.Allocator, ) SecretStoreLoadError!?[]u8, - load_stored_fn: ?*const fn ( - ?*anyopaque, - std.mem.Allocator, - ) SecretStoreLoadError!?[]u8 = null, store_fn: *const fn ( ?*anyopaque, std.mem.Allocator, @@ -134,15 +130,6 @@ pub const SecretStore = struct { return self.load_fn(self.context, alloc); } - pub fn loadStored( - self: SecretStore, - alloc: std.mem.Allocator, - ) SecretStoreLoadError!?[]u8 { - // Hosts with migration-sensitive storage provide a read-only path. - const load_stored = self.load_stored_fn orelse self.load_fn; - return load_stored(self.context, alloc); - } - /// Borrows `value` for this call. The caller retains ownership. pub fn store( self: SecretStore, @@ -397,7 +384,6 @@ test "unavailable URL opener keeps the manual fallback available" { test "unavailable secret store reports absence and refuses writes" { try std.testing.expect(!unavailable_secret_store.isDisabled()); try std.testing.expect((try unavailable_secret_store.load(std.testing.allocator)) == null); - try std.testing.expect((try unavailable_secret_store.loadStored(std.testing.allocator)) == null); try std.testing.expectError( error.StoredKeyWriteFailed, unavailable_secret_store.store(std.testing.allocator, "secret"), diff --git a/src/core/hosts/native_auth_store.zig b/src/core/hosts/native_auth_store.zig deleted file mode 100644 index 1f86c1d9c..000000000 --- a/src/core/hosts/native_auth_store.zig +++ /dev/null @@ -1,1132 +0,0 @@ -const std = @import("std"); -const builtin = @import("builtin"); -const auth_store = @import("../auth/auth_store.zig"); -const debug_trace = @import("../shared/debug_trace.zig"); -const host = @import("host.zig"); -const io_mod = @import("../shared/io.zig"); -const native_keychain = @import("native_keychain.zig"); -const profile_paths = @import("../shared/profile_paths.zig"); -const secret = @import("../auth/secret.zig"); - -const Allocator = std.mem.Allocator; -const max_auth_document_bytes: usize = 256 * 1024; -const max_legacy_api_key_bytes: usize = 8 * 1024; -const max_legacy_session_bytes: usize = 64 * 1024; -const mutation_lock_file_name = "auth.lock"; -const mutation_lock_deadline_ms: u64 = 2000; -const e2e_lock_contention_file_name = "auth-lock-contention"; - -const MutationLockProbe = struct { - fx_dir: std.Io.Dir, - signaled: bool = false, - - fn try_lock(raw: ?*anyopaque, file: std.Io.File) anyerror!bool { - const locked = try file.tryLock(io_mod.getIo(), .exclusive); - const self: *MutationLockProbe = @ptrCast(@alignCast(raw.?)); - if (!locked and !self.signaled) { - signal_e2e_lock_contention(self.fx_dir); - self.signaled = true; - } - return locked; - } -}; - -fn signal_e2e_lock_contention(fx_dir: std.Io.Dir) void { - const enabled = io_mod.getenv("FX_E2E_AUTH_LOCK_CONTENTION") orelse return; - if (!std.mem.eql(u8, enabled, "1")) return; - var file = fx_dir.createFile(io_mod.getIo(), e2e_lock_contention_file_name, .{ - .truncate = true, - .permissions = std.Io.File.Permissions.fromMode(0o600), - }) catch return; - defer file.close(io_mod.getIo()); - file.writeStreamingAll(io_mod.getIo(), "contended\n") catch {}; -} - -const StorageBackend = enum { - profile_file, - macos_keychain, -}; - -const KeychainError = Allocator.Error || native_keychain.Error; - -fn storage_backend() StorageBackend { - if (comptime builtin.is_test) return .profile_file; - if (comptime builtin.os.tag == .macos) { - if (!native_keychain.isDisabled()) return .macos_keychain; - } - return .profile_file; -} - -const KeychainBackend = struct { - context: ?*anyopaque = null, - load_document_fn: *const fn (?*anyopaque, Allocator) KeychainError!?[]u8, - store_document_fn: *const fn (?*anyopaque, []const u8) KeychainError!void, - load_api_key_fn: *const fn (?*anyopaque, Allocator) KeychainError!?[]u8, - delete_api_key_fn: *const fn (?*anyopaque, Allocator) KeychainError!bool, - - fn load_document(self: KeychainBackend, alloc: Allocator) KeychainError!?[]u8 { - return self.load_document_fn(self.context, alloc); - } - - fn store_document(self: KeychainBackend, value: []const u8) KeychainError!void { - return self.store_document_fn(self.context, value); - } - - fn load_api_key(self: KeychainBackend, alloc: Allocator) KeychainError!?[]u8 { - return self.load_api_key_fn(self.context, alloc); - } - - fn delete_api_key(self: KeychainBackend, alloc: Allocator) KeychainError!bool { - return self.delete_api_key_fn(self.context, alloc); - } -}; - -const native_keychain_backend = KeychainBackend{ - .load_document_fn = native_load_document, - .store_document_fn = native_store_document, - .load_api_key_fn = native_load_api_key, - .delete_api_key_fn = native_delete_api_key, -}; - -fn native_load_document(_: ?*anyopaque, alloc: Allocator) KeychainError!?[]u8 { - return native_keychain.loadOAuthSession(alloc) catch |err| switch (err) { - error.KeychainItemNotFound => null, - else => return err, - }; -} - -fn native_store_document(_: ?*anyopaque, value: []const u8) KeychainError!void { - return native_keychain.storeOAuthSession(value); -} - -fn native_load_api_key(_: ?*anyopaque, alloc: Allocator) KeychainError!?[]u8 { - return native_keychain.load(alloc) catch |err| switch (err) { - error.KeychainItemNotFound => null, - else => return err, - }; -} - -fn native_delete_api_key(_: ?*anyopaque, alloc: Allocator) KeychainError!bool { - return native_keychain.delete(alloc); -} - -const ProfileMutation = struct { - fx_dir: io_mod.VerifiedDir, - lock: io_mod.TimedAdvisoryLock, - - fn deinit(self: *ProfileMutation) void { - self.lock.release(); - self.fx_dir.close(); - self.* = undefined; - } - - fn load(self: *ProfileMutation, alloc: Allocator) !?auth_store.Document { - var file = self.fx_dir.dir.openFile(io_mod.getIo(), profile_paths.auth_file_name, .{ - .mode = .read_only, - .allow_directory = false, - .follow_symlinks = false, - .resolve_beneath = true, - }) catch |err| switch (err) { - error.FileNotFound => return null, - else => return err, - }; - defer file.close(io_mod.getIo()); - - const stat = try file.stat(io_mod.getIo()); - if (stat.kind != .file or stat.nlink != 1 or stat.permissions.toMode() & 0o077 != 0) { - return error.AuthDocumentInsecure; - } - const bytes = try io_mod.readFileToEnd(alloc, &file, max_auth_document_bytes); - defer secret.zeroAndFree(alloc, bytes); - return try auth_store.Document.parse(alloc, bytes); - } - - fn commit( - self: *ProfileMutation, - alloc: Allocator, - document: auth_store.Document, - ) !void { - const bytes = try document.stringify(alloc); - defer secret.zeroAndFree(alloc, bytes); - try io_mod.durableReplaceVerified( - alloc, - &self.fx_dir, - profile_paths.auth_file_name, - bytes, - ); - } -}; - -fn begin_profile_mutation(home: []const u8) !ProfileMutation { - var home_dir = io_mod.VerifiedDir{ - .dir = try std.Io.Dir.openDirAbsolute(io_mod.getIo(), home, .{ .iterate = true }), - }; - defer home_dir.close(); - - var fx_dir = try io_mod.openOrCreateVerifiedPrivateDir(&home_dir, profile_paths.root_dir_name); - errdefer fx_dir.close(); - var probe = MutationLockProbe{ .fx_dir = fx_dir.dir }; - var lock = try io_mod.acquireTimedAdvisoryLockWithOps( - &fx_dir, - mutation_lock_file_name, - mutation_lock_deadline_ms, - .{ .ctx = &probe, .try_lock = MutationLockProbe.try_lock }, - ); - errdefer lock.release(); - return .{ .fx_dir = fx_dir, .lock = lock }; -} - -const ProfileObservation = struct { - state: auth_store.StoreState, - document: ?auth_store.Document = null, - - fn deinit(self: *ProfileObservation, alloc: Allocator) void { - if (self.document) |*document| document.deinit(alloc); - self.* = .{ .state = .empty }; - } - - fn take_document(self: *ProfileObservation) ?auth_store.Document { - const document = self.document; - self.document = null; - return document; - } -}; - -pub const DeleteOutcome = enum { - deleted, - missing, - deleted_not_durable, -}; - -pub const EntryMutation = struct { - profile: ProfileMutation, - source: auth_store.StoredSource, - backend: StorageBackend = .profile_file, - keychain: KeychainBackend = native_keychain_backend, - - pub fn deinit(self: *EntryMutation) void { - self.profile.deinit(); - self.* = undefined; - } - - pub fn load(self: *EntryMutation, alloc: Allocator) !?[]u8 { - var observation = switch (self.backend) { - .profile_file => try observe_profile(alloc, &self.profile.fx_dir.dir), - .macos_keychain => try observe_keychain_profile(alloc, &self.profile.fx_dir.dir, self.keychain), - }; - defer observation.deinit(alloc); - try self.migrate_observation(alloc, &observation); - const document = observation.document orelse return null; - const value = document.get(self.source) orelse return null; - return try alloc.dupe(u8, value); - } - - fn migrate_observation( - self: *EntryMutation, - alloc: Allocator, - observation: *ProfileObservation, - ) !void { - switch (auth_store.decide_load(observation.state, .active)) { - .missing, .use_current => return, - .migrate_legacy => {}, - .use_legacy, .reject_current => unreachable, - } - const document = observation.document orelse return error.InvalidAuthDocument; - const migrated = switch (self.backend) { - .profile_file => publish_profile_migration(&self.profile, alloc, document), - .macos_keychain => publish_keychain_migration( - alloc, - &self.profile.fx_dir.dir, - document, - self.keychain, - ), - }; - if (migrated) { - observation.state = .current; - } - } - - pub fn save(self: *EntryMutation, alloc: Allocator, value: []const u8) !void { - var observation = switch (self.backend) { - .profile_file => try observe_profile(alloc, &self.profile.fx_dir.dir), - .macos_keychain => try observe_keychain_profile(alloc, &self.profile.fx_dir.dir, self.keychain), - }; - defer observation.deinit(alloc); - const empty: auth_store.Document = .{}; - const document = if (observation.document) |*current| current else ∅ - var next = try document.replaced(alloc, self.source, value); - defer next.deinit(alloc); - switch (self.backend) { - .profile_file => { - try commit_and_verify(&self.profile, alloc, next); - delete_legacy_profile_files(&self.profile.fx_dir.dir) catch |err| { - debug_trace.logf("auth", "common auth cleanup incomplete backend=profile err={s}", .{@errorName(err)}); - }; - }, - .macos_keychain => { - try commit_and_verify_keychain(alloc, next, self.keychain); - delete_keychain_legacy_profile_files(&self.profile.fx_dir.dir) catch |err| { - debug_trace.logf("auth", "common auth cleanup incomplete backend=keychain source=profile err={s}", .{@errorName(err)}); - }; - _ = self.keychain.delete_api_key(alloc) catch |err| failed: { - debug_trace.logf("auth", "common auth cleanup incomplete backend=keychain source=stored_key err={s}", .{@errorName(err)}); - break :failed false; - }; - }, - } - } - - pub fn delete(self: *EntryMutation, alloc: Allocator) !DeleteOutcome { - var observation = switch (self.backend) { - .profile_file => try observe_profile(alloc, &self.profile.fx_dir.dir), - .macos_keychain => try observe_keychain_profile(alloc, &self.profile.fx_dir.dir, self.keychain), - }; - defer observation.deinit(alloc); - const document = observation.document orelse return .missing; - if (document.get(self.source) == null) return .missing; - var next = try document.removed(alloc, self.source); - defer next.deinit(alloc); - var cleanup_failed = false; - switch (self.backend) { - .profile_file => { - commit_and_verify(&self.profile, alloc, next) catch |err| switch (err) { - error.DurableReplacePostRenameFailed => return .deleted_not_durable, - else => return err, - }; - delete_legacy_profile_files(&self.profile.fx_dir.dir) catch |err| { - debug_trace.logf("auth", "common auth cleanup incomplete backend=profile err={s}", .{@errorName(err)}); - cleanup_failed = true; - }; - }, - .macos_keychain => { - try commit_and_verify_keychain(alloc, next, self.keychain); - delete_keychain_legacy_profile_files(&self.profile.fx_dir.dir) catch |err| { - debug_trace.logf("auth", "common auth cleanup incomplete backend=keychain source=profile err={s}", .{@errorName(err)}); - cleanup_failed = true; - }; - _ = self.keychain.delete_api_key(alloc) catch |err| failed: { - debug_trace.logf("auth", "common auth cleanup incomplete backend=keychain source=stored_key err={s}", .{@errorName(err)}); - cleanup_failed = true; - break :failed false; - }; - }, - } - return if (cleanup_failed) .deleted_not_durable else .deleted; - } -}; - -pub fn load_entry( - alloc: Allocator, - source: auth_store.StoredSource, - intent: auth_store.LoadIntent, -) !?[]u8 { - const home = io_mod.getenv("HOME") orelse return null; - return switch (storage_backend()) { - .profile_file => load_profile_entry(alloc, home, source, intent), - .macos_keychain => blk: { - var document = (try load_keychain_document( - alloc, - home, - intent, - native_keychain_backend, - )) orelse break :blk null; - defer document.deinit(alloc); - const value = document.get(source) orelse break :blk null; - break :blk try alloc.dupe(u8, value); - }, - }; -} - -/// Reports whether one slot exists in the common auth document. The document -/// is inspected without migration, and all loaded credential bytes are zeroed -/// before returning this metadata-only result. -pub fn entry_presence(source: auth_store.StoredSource) host.SecretStorePresence { - const alloc = std.heap.c_allocator; - const home = io_mod.getenv("HOME") orelse return .unavailable; - var document = (switch (storage_backend()) { - .profile_file => load_profile_document(alloc, home, .inspect), - .macos_keychain => load_keychain_document( - alloc, - home, - .inspect, - native_keychain_backend, - ), - } catch return .unavailable) orelse return .missing; - defer document.deinit(alloc); - return if (document.get(source) != null) .present else .missing; -} - -pub fn begin_entry_mutation(source: auth_store.StoredSource) !EntryMutation { - const home = io_mod.getenv("HOME") orelse return error.HomeNotSet; - var mutation = try begin_profile_entry_mutation(home, source); - mutation.backend = storage_backend(); - return mutation; -} - -fn begin_profile_entry_mutation( - home: []const u8, - source: auth_store.StoredSource, -) !EntryMutation { - return .{ - .profile = try begin_profile_mutation(home), - .source = source, - }; -} - -fn load_profile_entry( - alloc: Allocator, - home: []const u8, - source: auth_store.StoredSource, - intent: auth_store.LoadIntent, -) !?[]u8 { - var document = (try load_profile_document(alloc, home, intent)) orelse return null; - defer document.deinit(alloc); - const value = document.get(source) orelse return null; - return try alloc.dupe(u8, value); -} - -fn commit_and_verify( - mutation: *ProfileMutation, - alloc: Allocator, - document: auth_store.Document, -) !void { - try mutation.commit(alloc, document); - var verified = (try mutation.load(alloc)) orelse return error.AuthDocumentWriteMismatch; - defer verified.deinit(alloc); - if (!document.eql(verified)) return error.AuthDocumentWriteMismatch; -} - -fn publish_profile_migration( - mutation: *ProfileMutation, - alloc: Allocator, - document: auth_store.Document, -) bool { - commit_and_verify(mutation, alloc, document) catch |err| { - debug_trace.logf("auth", "common auth migration deferred backend=profile step=publish err={s}", .{@errorName(err)}); - return false; - }; - delete_legacy_profile_files(&mutation.fx_dir.dir) catch |err| { - debug_trace.logf("auth", "common auth migration cleanup incomplete backend=profile err={s}", .{@errorName(err)}); - }; - return true; -} - -fn load_profile_document( - alloc: Allocator, - home: []const u8, - intent: auth_store.LoadIntent, -) !?auth_store.Document { - var initial = try observe_profile_home(alloc, home); - defer initial.deinit(alloc); - switch (auth_store.decide_load(initial.state, intent)) { - .missing => return null, - .use_legacy, .use_current => return initial.take_document(), - .reject_current => unreachable, - .migrate_legacy => {}, - } - - var mutation = try begin_profile_mutation(home); - defer mutation.deinit(); - var observation = try observe_profile(alloc, &mutation.fx_dir.dir); - defer observation.deinit(alloc); - return switch (auth_store.decide_load(observation.state, intent)) { - .missing => null, - .use_current => observation.take_document(), - .migrate_legacy => migrated: { - const document = observation.document orelse return error.InvalidAuthDocument; - _ = publish_profile_migration(&mutation, alloc, document); - break :migrated observation.take_document(); - }, - .use_legacy, .reject_current => unreachable, - }; -} - -fn observe_profile_home(alloc: Allocator, home: []const u8) !ProfileObservation { - var home_dir = std.Io.Dir.openDirAbsolute(io_mod.getIo(), home, .{ .iterate = true }) catch |err| switch (err) { - error.FileNotFound => return .{ .state = .empty }, - else => return err, - }; - defer home_dir.close(io_mod.getIo()); - var fx_dir = home_dir.openDir(io_mod.getIo(), profile_paths.root_dir_name, .{ - .iterate = true, - .follow_symlinks = false, - }) catch |err| switch (err) { - error.FileNotFound => return .{ .state = .empty }, - else => return err, - }; - defer fx_dir.close(io_mod.getIo()); - return observe_profile(alloc, &fx_dir); -} - -fn load_keychain_document( - alloc: Allocator, - home: []const u8, - intent: auth_store.LoadIntent, - keychain: KeychainBackend, -) !?auth_store.Document { - var initial = try observe_keychain_home(alloc, home, keychain); - defer initial.deinit(alloc); - switch (auth_store.decide_load(initial.state, intent)) { - .missing => return null, - .use_legacy, .use_current => return initial.take_document(), - .reject_current => unreachable, - .migrate_legacy => {}, - } - - var mutation = try begin_profile_mutation(home); - defer mutation.deinit(); - var observation = try observe_keychain_profile(alloc, &mutation.fx_dir.dir, keychain); - defer observation.deinit(alloc); - return switch (auth_store.decide_load(observation.state, intent)) { - .missing => null, - .use_current => observation.take_document(), - .migrate_legacy => migrated: { - const document = observation.document orelse return error.InvalidAuthDocument; - _ = publish_keychain_migration(alloc, &mutation.fx_dir.dir, document, keychain); - break :migrated observation.take_document(); - }, - .use_legacy, .reject_current => unreachable, - }; -} - -fn observe_keychain_home( - alloc: Allocator, - home: []const u8, - keychain: KeychainBackend, -) !ProfileObservation { - var home_dir = std.Io.Dir.openDirAbsolute(io_mod.getIo(), home, .{ .iterate = true }) catch |err| switch (err) { - error.FileNotFound => return observe_keychain_without_profile(alloc, keychain), - else => return err, - }; - defer home_dir.close(io_mod.getIo()); - var fx_dir = home_dir.openDir(io_mod.getIo(), profile_paths.root_dir_name, .{ - .iterate = true, - .follow_symlinks = false, - }) catch |err| switch (err) { - error.FileNotFound => return observe_keychain_without_profile(alloc, keychain), - else => return err, - }; - defer fx_dir.close(io_mod.getIo()); - return observe_keychain_profile(alloc, &fx_dir, keychain); -} - -fn observe_keychain_without_profile( - alloc: Allocator, - keychain: KeychainBackend, -) !ProfileObservation { - var document: auth_store.Document = .{}; - errdefer document.deinit(alloc); - var found = false; - const stored = try keychain.load_document(alloc); - defer if (stored) |bytes| secret.zeroAndFree(alloc, bytes); - if (stored) |bytes| { - const version = (try declared_version(alloc, bytes)) orelse return error.InvalidAuthDocument; - switch (version) { - 2 => return .{ .state = .current, .document = try auth_store.Document.parse(alloc, bytes) }, - 1 => { - const next = try document.replaced(alloc, .fx_login, bytes); - document.deinit(alloc); - document = next; - found = true; - }, - else => return error.InvalidAuthDocument, - } - } - if (try keychain.load_api_key(alloc)) |key| { - defer secret.zeroAndFree(alloc, key); - const next = try document.replaced(alloc, .stored_key, key); - document.deinit(alloc); - document = next; - found = true; - } - return if (found) - .{ .state = .legacy, .document = document } - else blk: { - document.deinit(alloc); - break :blk .{ .state = .empty }; - }; -} - -fn observe_keychain_profile( - alloc: Allocator, - fx_dir: *std.Io.Dir, - keychain: KeychainBackend, -) !ProfileObservation { - const stored = try keychain.load_document(alloc); - defer if (stored) |bytes| secret.zeroAndFree(alloc, bytes); - const stored_version = if (stored) |bytes| try declared_version(alloc, bytes) else null; - if (stored) |bytes| { - if (stored_version == 2) { - return .{ .state = .current, .document = try auth_store.Document.parse(alloc, bytes) }; - } - if (stored_version != null and stored_version != 1) return error.InvalidAuthDocument; - } - - var profile = try observe_profile(alloc, fx_dir); - defer profile.deinit(alloc); - var document = profile.take_document() orelse auth_store.Document{}; - errdefer document.deinit(alloc); - var found = profile.state != .empty; - if (stored) |bytes| { - if (document.get(.fx_login) == null) { - if (stored_version == 1) { - const next = try document.replaced(alloc, .fx_login, bytes); - document.deinit(alloc); - document = next; - found = true; - } - } - if (stored_version == null and !found) return error.InvalidAuthDocument; - } - if (try keychain.load_api_key(alloc)) |key| { - defer secret.zeroAndFree(alloc, key); - const next = try document.replaced(alloc, .stored_key, key); - document.deinit(alloc); - document = next; - found = true; - } - return if (found) - .{ .state = .legacy, .document = document } - else blk: { - document.deinit(alloc); - break :blk .{ .state = .empty }; - }; -} - -fn commit_and_verify_keychain( - alloc: Allocator, - document: auth_store.Document, - keychain: KeychainBackend, -) !void { - const bytes = try document.stringify(alloc); - defer secret.zeroAndFree(alloc, bytes); - try keychain.store_document(bytes); - const persisted = (try keychain.load_document(alloc)) orelse return error.AuthDocumentWriteMismatch; - defer secret.zeroAndFree(alloc, persisted); - var verified = try auth_store.Document.parse(alloc, persisted); - defer verified.deinit(alloc); - if (!document.eql(verified)) return error.AuthDocumentWriteMismatch; -} - -fn publish_keychain_migration( - alloc: Allocator, - fx_dir: *std.Io.Dir, - document: auth_store.Document, - keychain: KeychainBackend, -) bool { - commit_and_verify_keychain(alloc, document, keychain) catch |err| { - debug_trace.logf("auth", "common auth migration deferred backend=keychain step=publish err={s}", .{@errorName(err)}); - return false; - }; - delete_keychain_legacy_profile_files(fx_dir) catch |err| { - debug_trace.logf("auth", "common auth migration cleanup incomplete backend=keychain source=profile err={s}", .{@errorName(err)}); - }; - _ = keychain.delete_api_key(alloc) catch |err| failed: { - debug_trace.logf("auth", "common auth migration cleanup incomplete backend=keychain source=stored_key err={s}", .{@errorName(err)}); - break :failed false; - }; - return true; -} - -fn observe_profile(alloc: Allocator, fx_dir: *std.Io.Dir) !ProfileObservation { - const auth_bytes = try read_optional_private_file( - alloc, - fx_dir, - profile_paths.auth_file_name, - max_auth_document_bytes, - ); - defer if (auth_bytes) |bytes| secret.zeroAndFree(alloc, bytes); - if (auth_bytes) |bytes| { - const version = (try declared_version(alloc, bytes)) orelse return error.InvalidAuthDocument; - if (version == 2) { - return .{ .state = .current, .document = try auth_store.Document.parse(alloc, bytes) }; - } - if (version != 1) return error.InvalidAuthDocument; - } - - var document: auth_store.Document = .{}; - errdefer document.deinit(alloc); - var found_legacy = false; - if (auth_bytes) |bytes| { - if ((try declared_version(alloc, bytes)) == 1) { - const next = try document.replaced(alloc, .fx_login, bytes); - document.deinit(alloc); - document = next; - found_legacy = true; - } - } - if (try read_optional_private_file( - alloc, - fx_dir, - profile_paths.api_key_file_name, - max_legacy_api_key_bytes, - )) |raw_key| { - defer secret.zeroAndFree(alloc, raw_key); - const key = std.mem.trim(u8, raw_key, "\r\n"); - if (key.len > 0) { - const next = try document.replaced(alloc, .stored_key, key); - document.deinit(alloc); - document = next; - found_legacy = true; - } - } - if (try read_optional_private_file( - alloc, - fx_dir, - profile_paths.chatgpt_auth_file_name, - max_legacy_session_bytes, - )) |bytes| { - defer secret.zeroAndFree(alloc, bytes); - if ((try declared_version(alloc, bytes)) == 1) { - const next = try document.replaced(alloc, .chatgpt_subscription, bytes); - document.deinit(alloc); - document = next; - found_legacy = true; - } - } - if (try read_optional_private_file( - alloc, - fx_dir, - profile_paths.grok_auth_file_name, - max_legacy_session_bytes, - )) |bytes| { - defer secret.zeroAndFree(alloc, bytes); - if ((try declared_version(alloc, bytes)) == 1) { - const next = try document.replaced(alloc, .grok_subscription, bytes); - document.deinit(alloc); - document = next; - found_legacy = true; - } - } - - return if (found_legacy) - .{ .state = .legacy, .document = document } - else blk: { - document.deinit(alloc); - break :blk .{ .state = .empty }; - }; -} - -fn declared_version(alloc: Allocator, bytes: []const u8) !?i64 { - var parsed = std.json.parseFromSlice(std.json.Value, alloc, bytes, .{}) catch |err| switch (err) { - error.OutOfMemory => return error.OutOfMemory, - else => return null, - }; - defer parsed.deinit(); - if (parsed.value != .object) return null; - const version = parsed.value.object.get("version") orelse return null; - return if (version == .integer) version.integer else null; -} - -fn read_optional_private_file( - alloc: Allocator, - dir: *std.Io.Dir, - name: []const u8, - max_bytes: usize, -) !?[]u8 { - var file = dir.openFile(io_mod.getIo(), name, .{ - .mode = .read_only, - .allow_directory = false, - .follow_symlinks = false, - .resolve_beneath = true, - }) catch |err| switch (err) { - error.FileNotFound => return null, - else => return err, - }; - defer file.close(io_mod.getIo()); - const stat = try file.stat(io_mod.getIo()); - if (stat.kind != .file or stat.nlink != 1 or stat.permissions.toMode() & 0o077 != 0) { - return error.AuthDocumentInsecure; - } - return try io_mod.readFileToEnd(alloc, &file, max_bytes); -} - -fn read_profile_file(alloc: Allocator, home: []const u8, name: []const u8) ![]u8 { - var home_dir = try std.Io.Dir.openDirAbsolute(io_mod.getIo(), home, .{ .iterate = true }); - defer home_dir.close(io_mod.getIo()); - var fx_dir = try home_dir.openDir(io_mod.getIo(), profile_paths.root_dir_name, .{ - .iterate = true, - .follow_symlinks = false, - }); - defer fx_dir.close(io_mod.getIo()); - return (try read_optional_private_file(alloc, &fx_dir, name, max_auth_document_bytes)) orelse - error.FileNotFound; -} - -fn delete_legacy_profile_files(fx_dir: *std.Io.Dir) !void { - var deleted = false; - for ([_][]const u8{ - profile_paths.api_key_file_name, - profile_paths.chatgpt_auth_file_name, - profile_paths.grok_auth_file_name, - }) |name| { - fx_dir.deleteFile(io_mod.getIo(), name) catch |err| switch (err) { - error.FileNotFound => continue, - else => return err, - }; - deleted = true; - } - if (deleted) try io_mod.syncVerifiedDir(fx_dir.*); -} - -fn delete_keychain_legacy_profile_files(fx_dir: *std.Io.Dir) !void { - var deleted = false; - for ([_][]const u8{ - profile_paths.auth_file_name, - profile_paths.api_key_file_name, - profile_paths.chatgpt_auth_file_name, - profile_paths.grok_auth_file_name, - }) |name| { - fx_dir.deleteFile(io_mod.getIo(), name) catch |err| switch (err) { - error.FileNotFound => continue, - else => return err, - }; - deleted = true; - } - if (deleted) try io_mod.syncVerifiedDir(fx_dir.*); -} - -test "profile auth store commits one private document and reloads every source" { - const alloc = std.testing.allocator; - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - const home = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "."); - defer alloc.free(home); - - var document: auth_store.Document = .{}; - defer document.deinit(alloc); - const with_key = try document.replaced(alloc, .stored_key, "gateway-secret"); - document.deinit(alloc); - document = with_key; - const with_codex = try document.replaced( - alloc, - .chatgpt_subscription, - "{\"version\":1,\"access_token\":\"codex\"}", - ); - document.deinit(alloc); - document = with_codex; - - var mutation = try begin_profile_mutation(home); - try mutation.commit(alloc, document); - mutation.deinit(); - - var fx_dir = try tmp.dir.openDir(std.testing.io, ".fx", .{}); - defer fx_dir.close(std.testing.io); - const stat = try fx_dir.statFile(std.testing.io, "auth.json", .{}); - try std.testing.expectEqual(@as(std.posix.mode_t, 0o600), stat.permissions.toMode() & 0o777); - - var reopened = try begin_profile_mutation(home); - defer reopened.deinit(); - var loaded = (try reopened.load(alloc)) orelse return error.TestExpectedAuthDocument; - defer loaded.deinit(alloc); - try std.testing.expect(document.eql(loaded)); -} - -test "empty active load stays read only" { - const alloc = std.testing.allocator; - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - const home = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "."); - defer alloc.free(home); - - try std.testing.expect((try load_profile_document(alloc, home, .active)) == null); - try std.testing.expectError( - error.FileNotFound, - tmp.dir.statFile(std.testing.io, profile_paths.root_dir_name, .{}), - ); -} - -test "malformed common document blocks legacy fallback and migration" { - const alloc = std.testing.allocator; - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - const home = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "."); - defer alloc.free(home); - - var verified_home = io_mod.VerifiedDir{ - .dir = try tmp.dir.openDir(std.testing.io, ".", .{ .iterate = true }), - }; - defer verified_home.close(); - var fx_dir = try io_mod.openOrCreateVerifiedPrivateDir(&verified_home, profile_paths.root_dir_name); - defer fx_dir.close(); - try io_mod.durableReplaceVerified( - alloc, - &fx_dir, - profile_paths.auth_file_name, - "{\"version\":2,\"credentials\":[]}", - ); - try io_mod.durableReplaceVerified( - alloc, - &fx_dir, - profile_paths.api_key_file_name, - "gateway-secret", - ); - - try std.testing.expectError( - error.InvalidAuthDocument, - load_profile_document(alloc, home, .active), - ); - _ = try fx_dir.dir.statFile(std.testing.io, profile_paths.api_key_file_name, .{}); -} - -test "legacy inspection is read only and active load publishes one common document" { - const alloc = std.testing.allocator; - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - const home = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "."); - defer alloc.free(home); - - var verified_home = io_mod.VerifiedDir{ - .dir = try tmp.dir.openDir(std.testing.io, ".", .{ .iterate = true }), - }; - defer verified_home.close(); - var fx_dir = try io_mod.openOrCreateVerifiedPrivateDir(&verified_home, profile_paths.root_dir_name); - defer fx_dir.close(); - - const vercel = - "{\"version\":1,\"issuer\":\"https://vercel.com\",\"client_id\":\"client\",\"access_token\":\"vercel-access\",\"refresh_token\":\"vercel-refresh\",\"expires_at_ms\":4102444800000,\"scope\":\"openid offline_access\",\"token_type\":\"Bearer\",\"team_slug\":\"team-slug\",\"team_id\":\"team-id\"}"; - const codex = - "{\"version\":1,\"access_token\":\"codex-access\",\"refresh_token\":\"codex-refresh\",\"expires_at_ms\":4102444800000,\"account_id\":\"codex-account\"}"; - const grok = - "{\"version\":1,\"access_token\":\"grok-access\",\"refresh_token\":\"grok-refresh\",\"expires_at_ms\":4102444800000,\"account_id\":\"grok-account\"}"; - try io_mod.durableReplaceVerified(alloc, &fx_dir, profile_paths.auth_file_name, vercel); - try io_mod.durableReplaceVerified(alloc, &fx_dir, profile_paths.api_key_file_name, "gateway-secret"); - try io_mod.durableReplaceVerified(alloc, &fx_dir, profile_paths.chatgpt_auth_file_name, codex); - try io_mod.durableReplaceVerified(alloc, &fx_dir, profile_paths.grok_auth_file_name, grok); - - var inspected = (try load_profile_document(alloc, home, .inspect)) orelse - return error.TestExpectedLegacyDocument; - defer inspected.deinit(alloc); - try std.testing.expectEqualStrings("gateway-secret", inspected.get(.stored_key).?); - try std.testing.expectEqualStrings(vercel, inspected.get(.fx_login).?); - try std.testing.expectEqualStrings(codex, inspected.get(.chatgpt_subscription).?); - try std.testing.expectEqualStrings(grok, inspected.get(.grok_subscription).?); - _ = try fx_dir.dir.statFile(std.testing.io, profile_paths.api_key_file_name, .{}); - _ = try fx_dir.dir.statFile(std.testing.io, profile_paths.chatgpt_auth_file_name, .{}); - _ = try fx_dir.dir.statFile(std.testing.io, profile_paths.grok_auth_file_name, .{}); - - const before = try read_profile_file(alloc, home, profile_paths.auth_file_name); - defer secret.zeroAndFree(alloc, before); - try std.testing.expect(std.mem.find(u8, before, "\"version\":1") != null); - - var migrated = (try load_profile_document(alloc, home, .active)) orelse - return error.TestExpectedMigratedDocument; - defer migrated.deinit(alloc); - try std.testing.expect(inspected.eql(migrated)); - - const after = try read_profile_file(alloc, home, profile_paths.auth_file_name); - defer secret.zeroAndFree(alloc, after); - var parsed_after = try auth_store.Document.parse(alloc, after); - defer parsed_after.deinit(alloc); - try std.testing.expect(migrated.eql(parsed_after)); - - var migrated_fx_dir = try tmp.dir.openDir(std.testing.io, ".fx", .{}); - defer migrated_fx_dir.close(std.testing.io); - for ([_][]const u8{ - profile_paths.api_key_file_name, - profile_paths.chatgpt_auth_file_name, - profile_paths.grok_auth_file_name, - }) |name| { - try std.testing.expectError( - error.FileNotFound, - migrated_fx_dir.statFile(std.testing.io, name, .{}), - ); - } -} - -test "source mutations share one document without losing unrelated credentials" { - const alloc = std.testing.allocator; - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - const home = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "."); - defer alloc.free(home); - - var key_mutation = try begin_profile_entry_mutation(home, .stored_key); - try key_mutation.save(alloc, "gateway-secret"); - key_mutation.deinit(); - - var codex_mutation = try begin_profile_entry_mutation(home, .chatgpt_subscription); - try codex_mutation.save(alloc, "{\"version\":1,\"access_token\":\"codex\"}"); - codex_mutation.deinit(); - - const key = (try load_profile_entry(alloc, home, .stored_key, .inspect)) orelse - return error.TestExpectedStoredKey; - defer secret.zeroAndFree(alloc, key); - try std.testing.expectEqualStrings("gateway-secret", key); - - var delete_codex = try begin_profile_entry_mutation(home, .chatgpt_subscription); - try std.testing.expectEqual(DeleteOutcome.deleted, try delete_codex.delete(alloc)); - delete_codex.deinit(); - - const preserved_key = (try load_profile_entry(alloc, home, .stored_key, .inspect)) orelse - return error.TestExpectedPreservedKey; - defer secret.zeroAndFree(alloc, preserved_key); - try std.testing.expectEqualStrings("gateway-secret", preserved_key); - try std.testing.expect((try load_profile_entry( - alloc, - home, - .chatgpt_subscription, - .inspect, - )) == null); -} - -test "active entry mutation migrates an unexpired legacy subscription" { - const alloc = std.testing.allocator; - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - const home = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "."); - defer alloc.free(home); - - var verified_home = io_mod.VerifiedDir{ - .dir = try tmp.dir.openDir(std.testing.io, ".", .{ .iterate = true }), - }; - defer verified_home.close(); - var fx_dir = try io_mod.openOrCreateVerifiedPrivateDir(&verified_home, profile_paths.root_dir_name); - defer fx_dir.close(); - const codex = - "{\"version\":1,\"access_token\":\"codex-access\",\"refresh_token\":\"codex-refresh\",\"expires_at_ms\":4102444800000,\"account_id\":\"codex-account\"}"; - try io_mod.durableReplaceVerified(alloc, &fx_dir, profile_paths.chatgpt_auth_file_name, codex); - - var mutation = try begin_profile_entry_mutation(home, .chatgpt_subscription); - defer mutation.deinit(); - const loaded = (try mutation.load(alloc)) orelse return error.TestExpectedLegacyCredential; - defer secret.zeroAndFree(alloc, loaded); - - try std.testing.expectEqualStrings(codex, loaded); - const persisted = try read_profile_file(alloc, home, profile_paths.auth_file_name); - defer secret.zeroAndFree(alloc, persisted); - var document = try auth_store.Document.parse(alloc, persisted); - defer document.deinit(alloc); - try std.testing.expectEqualStrings(codex, document.get(.chatgpt_subscription).?); - try std.testing.expectError( - error.FileNotFound, - fx_dir.dir.statFile(std.testing.io, profile_paths.chatgpt_auth_file_name, .{}), - ); -} - -test "Keychain migration publishes every source before removing legacy credentials" { - const alloc = std.testing.allocator; - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - const home = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "."); - defer alloc.free(home); - - var verified_home = io_mod.VerifiedDir{ - .dir = try tmp.dir.openDir(std.testing.io, ".", .{ .iterate = true }), - }; - defer verified_home.close(); - var fx_dir = try io_mod.openOrCreateVerifiedPrivateDir(&verified_home, profile_paths.root_dir_name); - defer fx_dir.close(); - const codex = - "{\"version\":1,\"access_token\":\"codex-access\",\"refresh_token\":\"codex-refresh\",\"expires_at_ms\":4102444800000,\"account_id\":\"codex-account\"}"; - const grok = - "{\"version\":1,\"access_token\":\"grok-access\",\"refresh_token\":\"grok-refresh\",\"expires_at_ms\":4102444800000,\"account_id\":\"grok-account\"}"; - try io_mod.durableReplaceVerified(alloc, &fx_dir, profile_paths.chatgpt_auth_file_name, codex); - try io_mod.durableReplaceVerified(alloc, &fx_dir, profile_paths.grok_auth_file_name, grok); - - var fake = FakeKeychain{ - .alloc = alloc, - .document = try alloc.dupe( - u8, - "{\"version\":1,\"issuer\":\"https://vercel.com\",\"client_id\":\"client\",\"access_token\":\"vercel-access\",\"refresh_token\":\"vercel-refresh\",\"expires_at_ms\":4102444800000,\"scope\":\"openid offline_access\",\"token_type\":\"Bearer\",\"team_slug\":\"team-slug\",\"team_id\":\"team-id\"}", - ), - .api_key = try alloc.dupe(u8, "gateway-secret"), - }; - defer fake.deinit(); - - var loaded = (try load_keychain_document(alloc, home, .active, fake.backend())) orelse - return error.TestExpectedKeychainDocument; - defer loaded.deinit(alloc); - try std.testing.expect(loaded.get(.stored_key) != null); - try std.testing.expect(loaded.get(.fx_login) != null); - try std.testing.expect(loaded.get(.chatgpt_subscription) != null); - try std.testing.expect(loaded.get(.grok_subscription) != null); - try std.testing.expect(fake.api_key == null); - - var stored = try auth_store.Document.parse(alloc, fake.document.?); - defer stored.deinit(alloc); - try std.testing.expect(loaded.eql(stored)); -} - -test "Keychain publication failure keeps legacy credentials authoritative" { - const alloc = std.testing.allocator; - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - const home = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "."); - defer alloc.free(home); - - var verified_home = io_mod.VerifiedDir{ - .dir = try tmp.dir.openDir(std.testing.io, ".", .{ .iterate = true }), - }; - defer verified_home.close(); - var fx_dir = try io_mod.openOrCreateVerifiedPrivateDir(&verified_home, profile_paths.root_dir_name); - defer fx_dir.close(); - const codex = - "{\"version\":1,\"access_token\":\"codex-access\",\"refresh_token\":\"codex-refresh\",\"expires_at_ms\":4102444800000,\"account_id\":\"codex-account\"}"; - try io_mod.durableReplaceVerified(alloc, &fx_dir, profile_paths.chatgpt_auth_file_name, codex); - - var fake = FakeKeychain{ .alloc = alloc, .fail_store = true }; - defer fake.deinit(); - var mutation = try begin_profile_entry_mutation(home, .chatgpt_subscription); - defer mutation.deinit(); - mutation.backend = .macos_keychain; - mutation.keychain = fake.backend(); - const loaded = (try mutation.load(alloc)) orelse - return error.TestExpectedLegacyDocument; - defer secret.zeroAndFree(alloc, loaded); - - try std.testing.expectEqualStrings(codex, loaded); - try std.testing.expect(fake.document == null); - _ = try fx_dir.dir.statFile(std.testing.io, profile_paths.chatgpt_auth_file_name, .{}); -} - -const FakeKeychain = struct { - alloc: Allocator, - document: ?[]u8 = null, - api_key: ?[]u8 = null, - fail_store: bool = false, - - fn deinit(self: *FakeKeychain) void { - if (self.document) |value| secret.zeroAndFree(self.alloc, value); - if (self.api_key) |value| secret.zeroAndFree(self.alloc, value); - self.* = undefined; - } - - fn backend(self: *FakeKeychain) KeychainBackend { - return .{ - .context = self, - .load_document_fn = load_document, - .store_document_fn = store_document, - .load_api_key_fn = load_api_key, - .delete_api_key_fn = delete_api_key, - }; - } - - fn load_document(raw: ?*anyopaque, alloc: Allocator) KeychainError!?[]u8 { - const self: *FakeKeychain = @ptrCast(@alignCast(raw.?)); - const value = self.document orelse return null; - return try alloc.dupe(u8, value); - } - - fn store_document(raw: ?*anyopaque, value: []const u8) KeychainError!void { - const self: *FakeKeychain = @ptrCast(@alignCast(raw.?)); - if (self.fail_store) return error.KeychainWriteFailed; - const replacement = try self.alloc.dupe(u8, value); - if (self.document) |old| secret.zeroAndFree(self.alloc, old); - self.document = replacement; - } - - fn load_api_key(raw: ?*anyopaque, alloc: Allocator) KeychainError!?[]u8 { - const self: *FakeKeychain = @ptrCast(@alignCast(raw.?)); - const value = self.api_key orelse return null; - return try alloc.dupe(u8, value); - } - - fn delete_api_key(raw: ?*anyopaque, _: Allocator) KeychainError!bool { - const self: *FakeKeychain = @ptrCast(@alignCast(raw.?)); - const value = self.api_key orelse return false; - secret.zeroAndFree(self.alloc, value); - self.api_key = null; - return true; - } -}; diff --git a/src/core/hosts/native_keychain.zig b/src/core/hosts/native_keychain.zig index 06e634b94..bdb6c1a6e 100644 --- a/src/core/hosts/native_keychain.zig +++ b/src/core/hosts/native_keychain.zig @@ -29,7 +29,7 @@ pub const AccountBuffer = [256]u8; const passwd_scratch_bytes = 2048; const max_mcp_credentials_bytes: usize = 1024 * 1024; -const max_oauth_session_bytes: usize = 256 * 1024; +const max_oauth_session_bytes: usize = 64 * 1024; const keychain_process_timeout: std.Io.Timeout = .{ .duration = .{ .raw = .{ .nanoseconds = 10 * std.time.ns_per_s }, @@ -417,10 +417,6 @@ pub fn deleteMcpCredentials(alloc: std.mem.Allocator) Error!bool { return deleteMcpValueMac(alloc, mcp_credentials_service_name); } -pub fn delete(alloc: std.mem.Allocator) Error!bool { - return deleteServiceItem(alloc, service_name); -} - pub fn deleteOAuthSession(alloc: std.mem.Allocator) Error!bool { return deleteMcpValueMac(alloc, oauth_session_service_name); } diff --git a/src/core/hosts/native_secret_store.zig b/src/core/hosts/native_secret_store.zig index ba1a44a9a..067f3899c 100644 --- a/src/core/hosts/native_secret_store.zig +++ b/src/core/hosts/native_secret_store.zig @@ -1,10 +1,10 @@ const std = @import("std"); const builtin = @import("builtin"); -const auth_store = @import("../auth/auth_store.zig"); const debug_trace = @import("../shared/debug_trace.zig"); const host = @import("host.zig"); +const io_mod = @import("../shared/io.zig"); const keychain = @import("native_keychain.zig"); -const native_auth_store = @import("native_auth_store.zig"); +const profile_paths = @import("../shared/profile_paths.zig"); const secret = @import("../auth/secret.zig"); const Allocator = std.mem.Allocator; @@ -13,6 +13,8 @@ const Allocator = std.mem.Allocator; /// stored key lives without knowing how the backend is selected. const backend_label = if (builtin.os.tag == .macos) "macOS Keychain" else "profile file"; +const max_key_file_bytes: usize = 8 * 1024; + const LoadError = host.SecretStoreLoadError; const StoreError = host.SecretStoreWriteError; @@ -21,7 +23,6 @@ pub const provider: host.SecretStore = .{ .is_disabled_fn = isDisabledCallback, .presence_fn = presenceCallback, .load_fn = loadCallback, - .load_stored_fn = loadStoredCallback, .store_fn = storeCallback, .store_interactive_fn = storeInteractiveCallback, }; @@ -34,31 +35,17 @@ fn isDisabled() bool { /// Returns the stored key, or null when no key is stored. An error means the store /// could not be read, which callers must keep distinct from absence. fn load(alloc: Allocator) LoadError!?[]u8 { - return loadWithIntent(alloc, .active); -} - -fn loadStored(alloc: Allocator) LoadError!?[]u8 { - return loadWithIntent(alloc, .inspect); -} - -fn loadWithIntent( - alloc: Allocator, - intent: auth_store.LoadIntent, -) LoadError!?[]u8 { - return native_auth_store.load_entry(alloc, .stored_key, intent) catch |err| switch (err) { - error.OutOfMemory => error.OutOfMemory, - error.AuthDocumentInsecure => error.StoredKeyInsecure, - else => error.StoredKeyUnreadable, - }; + if (comptime builtin.os.tag == .macos) return loadFromKeychain(alloc); + return loadFromProfile(alloc); } fn store(alloc: Allocator, value: []const u8) StoreError!void { if (value.len == 0) return error.StoredKeyWriteFailed; - var mutation = native_auth_store.begin_entry_mutation(.stored_key) catch |err| { - return writeFailed("begin_auth_store", err); - }; - defer mutation.deinit(); - mutation.save(alloc, value) catch |err| return writeFailed("commit_auth_store", err); + if (comptime builtin.os.tag == .macos) { + keychain.storeValue(value) catch |err| return writeFailed("keychain", err); + return; + } + return storeInProfile(alloc, value); } /// Let the platform credential store own terminal input when it supports a @@ -66,15 +53,6 @@ fn store(alloc: Allocator, value: []const u8) StoreError!void { fn storeInteractive() StoreError!bool { if (comptime builtin.os.tag == .macos) { keychain.storeInteractive() catch |err| return writeFailed("keychain_interactive", err); - const alloc = std.heap.c_allocator; - const value = (keychain.load(alloc) catch |err| return writeFailed("keychain_interactive_readback", err)) orelse - return writeFailed("keychain_interactive_readback", error.KeychainItemNotFound); - defer secret.zeroAndFree(alloc, value); - var mutation = native_auth_store.begin_entry_mutation(.stored_key) catch |err| { - return writeFailed("keychain_interactive_begin", err); - }; - defer mutation.deinit(); - mutation.save(alloc, value) catch |err| return writeFailed("keychain_interactive_commit", err); return true; } return false; @@ -86,15 +64,30 @@ fn isDisabledCallback(_: ?*anyopaque) bool { fn presenceCallback(_: ?*anyopaque) host.SecretStorePresence { if (isDisabled()) return .missing; - return native_auth_store.entry_presence(.stored_key); + if (comptime builtin.os.tag == .macos) { + return keychain.contains() catch .unavailable; + } + return presenceInProfile(); } -fn loadCallback(_: ?*anyopaque, alloc: Allocator) LoadError!?[]u8 { - return load(alloc); +fn presenceInProfile() host.SecretStorePresence { + const home = io_mod.getenv("HOME") orelse return .unavailable; + var home_dir = std.Io.Dir.openDirAbsolute(io_mod.getIo(), home, .{}) catch + return .unavailable; + defer home_dir.close(io_mod.getIo()); + var fx_dir = home_dir.openDir(io_mod.getIo(), profile_paths.root_dir_name, .{ + .follow_symlinks = false, + }) catch |err| return if (err == error.FileNotFound) .missing else .unavailable; + defer fx_dir.close(io_mod.getIo()); + const stat = fx_dir.statFile(io_mod.getIo(), profile_paths.api_key_file_name, .{ + .follow_symlinks = false, + }) catch |err| return if (err == error.FileNotFound) .missing else .unavailable; + if (stat.kind != .file or stat.permissions.toMode() & 0o077 != 0) return .unavailable; + return if (stat.size == 0) .missing else .present; } -fn loadStoredCallback(_: ?*anyopaque, alloc: Allocator) LoadError!?[]u8 { - return loadStored(alloc); +fn loadCallback(_: ?*anyopaque, alloc: Allocator) LoadError!?[]u8 { + return load(alloc); } fn storeCallback( @@ -109,6 +102,109 @@ fn storeInteractiveCallback(_: ?*anyopaque) StoreError!bool { return storeInteractive(); } +fn loadFromKeychain(alloc: Allocator) LoadError!?[]u8 { + return keychain.load(alloc) catch |err| switch (err) { + error.OutOfMemory => error.OutOfMemory, + error.KeychainItemNotFound => null, + else => error.StoredKeyUnreadable, + }; +} + +fn loadFromProfile(alloc: Allocator) LoadError!?[]u8 { + const home = io_mod.getenv("HOME") orelse { + debug_trace.logf("stored_key", "load failed step=home err=HomeNotSet", .{}); + return error.StoredKeyUnreadable; + }; + var home_dir = std.Io.Dir.openDirAbsolute(io_mod.getIo(), home, .{ .iterate = true }) catch |err| { + debug_trace.logf("stored_key", "load failed step=open_home err={s}", .{@errorName(err)}); + return error.StoredKeyUnreadable; + }; + defer home_dir.close(io_mod.getIo()); + + var fx_dir = home_dir.openDir(io_mod.getIo(), profile_paths.root_dir_name, .{ + .iterate = true, + .follow_symlinks = false, + }) catch |err| switch (err) { + error.FileNotFound => return null, + else => { + debug_trace.logf("stored_key", "load failed step=open_profile err={s}", .{@errorName(err)}); + return error.StoredKeyUnreadable; + }, + }; + defer fx_dir.close(io_mod.getIo()); + + return loadFromDir(alloc, &fx_dir); +} + +fn loadFromDir(alloc: Allocator, fx_dir: *std.Io.Dir) LoadError!?[]u8 { + var file = fx_dir.openFile(io_mod.getIo(), profile_paths.api_key_file_name, .{ + .mode = .read_only, + .allow_directory = false, + .follow_symlinks = false, + .resolve_beneath = true, + }) catch |err| switch (err) { + error.FileNotFound => return null, + else => { + debug_trace.logf("stored_key", "load failed step=open_file err={s}", .{@errorName(err)}); + return error.StoredKeyUnreadable; + }, + }; + defer file.close(io_mod.getIo()); + + const stat = file.stat(io_mod.getIo()) catch |err| { + debug_trace.logf("stored_key", "load failed step=stat err={s}", .{@errorName(err)}); + return error.StoredKeyUnreadable; + }; + if (stat.kind != .file or stat.permissions.toMode() & 0o077 != 0) { + debug_trace.logf("stored_key", "load failed step=permissions err=StoredKeyInsecure", .{}); + return error.StoredKeyInsecure; + } + + const bytes = io_mod.readFileToEnd(alloc, &file, max_key_file_bytes) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => { + debug_trace.logf("stored_key", "load failed step=read err={s}", .{@errorName(err)}); + return error.StoredKeyUnreadable; + }, + }; + var borrowed = false; + defer if (!borrowed) secret.zeroAndFree(alloc, bytes); + + const trimmed = std.mem.trim(u8, bytes, "\r\n"); + if (trimmed.len == 0) return null; + if (trimmed.len == bytes.len) { + borrowed = true; + return bytes; + } + return try alloc.dupe(u8, trimmed); +} + +fn storeInProfile(alloc: Allocator, value: []const u8) StoreError!void { + const home = io_mod.getenv("HOME") orelse return writeFailed("home", error.HomeNotSet); + var home_dir = io_mod.VerifiedDir{ + .dir = std.Io.Dir.openDirAbsolute(io_mod.getIo(), home, .{ .iterate = true }) catch |err| { + return writeFailed("open_home", err); + }, + }; + defer home_dir.close(); + + var fx_dir = io_mod.openOrCreateVerifiedPrivateDir(&home_dir, profile_paths.root_dir_name) catch |err| { + return writeFailed("open_profile", err); + }; + defer fx_dir.close(); + + return storeInDir(alloc, &fx_dir, value); +} + +/// `durableReplaceVerified` creates the file at 0600 and re-stats it after the rename, +/// so the mode this store depends on is enforced rather than assumed. +fn storeInDir(alloc: Allocator, fx_dir: *io_mod.VerifiedDir, value: []const u8) StoreError!void { + io_mod.durableReplaceVerified(alloc, fx_dir, profile_paths.api_key_file_name, value) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => return writeFailed("replace", err), + }; +} + fn writeFailed(step: []const u8, err: anyerror) StoreError { debug_trace.logf("stored_key", "store failed step={s} err={s}", .{ step, @errorName(err) }); return error.StoredKeyWriteFailed; @@ -123,6 +219,68 @@ test "stored key backend label names the platform store" { try std.testing.expectEqualStrings(backend_label, provider.backend_label); } -test "stored key rejects an empty value" { +test "stored key file round-trips byte-identically at mode 0600" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var fx_dir = io_mod.VerifiedDir{ + .dir = try tmp.dir.openDir(io_mod.getIo(), ".", .{ .iterate = true, .follow_symlinks = false }), + }; + defer fx_dir.close(); + + const written = "vt1-file-round-trip-value"; + try storeInDir(std.testing.allocator, &fx_dir, written); + + const stat = try tmp.dir.statFile(std.testing.io, profile_paths.api_key_file_name, .{}); + try std.testing.expect(stat.permissions.toMode() & 0o777 == 0o600); + + const read_back = (try loadFromDir(std.testing.allocator, &fx_dir.dir)) orelse + return error.TestUnexpectedMissingStoredKey; + defer secret.zeroAndFree(std.testing.allocator, read_back); + try std.testing.expectEqualStrings(written, read_back); +} + +test "stored key file refusal stays distinguishable from absence" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var fx_dir = io_mod.VerifiedDir{ + .dir = try tmp.dir.openDir(io_mod.getIo(), ".", .{ .iterate = true, .follow_symlinks = false }), + }; + defer fx_dir.close(); + + try std.testing.expect((try loadFromDir(std.testing.allocator, &fx_dir.dir)) == null); + + try storeInDir(std.testing.allocator, &fx_dir, "vt2-secret-value"); + for ([_]std.posix.mode_t{ 0o640, 0o604, 0o644 }) |mode| { + var file = try tmp.dir.openFile(std.testing.io, profile_paths.api_key_file_name, .{ .mode = .read_write }); + try file.setPermissions(std.testing.io, std.Io.File.Permissions.fromMode(mode)); + file.close(std.testing.io); + + try std.testing.expectError( + error.StoredKeyInsecure, + loadFromDir(std.testing.allocator, &fx_dir.dir), + ); + } + + try tmp.dir.deleteFile(std.testing.io, profile_paths.api_key_file_name); + try std.testing.expect((try loadFromDir(std.testing.allocator, &fx_dir.dir)) == null); +} + +test "stored key file tolerates a trailing newline and rejects an empty value" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var fx_dir = io_mod.VerifiedDir{ + .dir = try tmp.dir.openDir(io_mod.getIo(), ".", .{ .iterate = true, .follow_symlinks = false }), + }; + defer fx_dir.close(); + + try storeInDir(std.testing.allocator, &fx_dir, "hand-edited-value\n"); + const read_back = (try loadFromDir(std.testing.allocator, &fx_dir.dir)) orelse + return error.TestUnexpectedMissingStoredKey; + defer secret.zeroAndFree(std.testing.allocator, read_back); + try std.testing.expectEqualStrings("hand-edited-value", read_back); + + try storeInDir(std.testing.allocator, &fx_dir, "\n\n"); + try std.testing.expect((try loadFromDir(std.testing.allocator, &fx_dir.dir)) == null); + try std.testing.expectError(error.StoredKeyWriteFailed, store(std.testing.allocator, "")); } diff --git a/tests/e2e/acp.test.ts b/tests/e2e/acp.test.ts index d8361f805..0487bc0e9 100644 --- a/tests/e2e/acp.test.ts +++ b/tests/e2e/acp.test.ts @@ -202,7 +202,6 @@ function fakeGatewayEnv( FX_GATEWAY_CHAT_URL: gateway.chatUrl, FX_MODEL: FAKE_GATEWAY_MODEL, FX_AUTO_UPGRADE: "0", - FX_DISABLE_KEYCHAIN: "1", }; } @@ -8251,14 +8250,6 @@ describe.skipIf(!HAS_API_KEY)("acp: model-backed protocol", () => { const prompt = await runPrompt(client, "Answer directly.", TIMEOUT); expect(prompt.promptResult.result.stopReason).toBe("end_turn"); expect(JSON.stringify(prompt.messages)).toContain("ACP_CHATGPT_RESPONSE"); - const commonAuth = JSON.parse( - readFileSync(join(root.home, ".fx", "auth.json"), "utf8"), - ); - expect(commonAuth.version).toBe(2); - expect(commonAuth.credentials.chatgpt_subscription.session.account_id).toBe( - "acct_acp_e2e", - ); - expect(existsSync(join(root.home, ".fx", "chatgpt-auth.json"))).toBe(false); const secondPrompt = await runPrompt(client, "Answer again.", TIMEOUT); expect(secondPrompt.promptResult.result.stopReason).toBe("end_turn"); expect(codex.requests).toHaveLength(3); @@ -8315,14 +8306,6 @@ describe.skipIf(!HAS_API_KEY)("acp: model-backed protocol", () => { const prompt = await runPrompt(client, "Answer directly.", TIMEOUT); expect(prompt.promptResult.result.stopReason).toBe("end_turn"); expect(JSON.stringify(prompt.messages)).toContain("ACP_GROK_RESPONSE"); - const commonAuth = JSON.parse( - readFileSync(join(root.home, ".fx", "auth.json"), "utf8"), - ); - expect(commonAuth.version).toBe(2); - expect(commonAuth.credentials.grok_subscription.session.account_id).toBe( - "acct_grok_acp", - ); - expect(existsSync(join(root.home, ".fx", "grok-auth.json"))).toBe(false); const secondPrompt = await runPrompt(client, "Answer again.", TIMEOUT); expect(secondPrompt.promptResult.result.stopReason).toBe("end_turn"); diff --git a/tests/e2e/auth-refresh.test.ts b/tests/e2e/auth-refresh.test.ts index f741ed9ff..e66146aaf 100644 --- a/tests/e2e/auth-refresh.test.ts +++ b/tests/e2e/auth-refresh.test.ts @@ -202,10 +202,7 @@ test( ).toBe(0); expect(logoutResult.stdout).toBe("Signed out of fx.\n"); expect(tokenRequestCount).toBe(1); - const afterLogout = JSON.parse( - readFileSync(join(home, ".fx", "auth.json"), "utf8"), - ); - expect(afterLogout.credentials.fx_login).toBeUndefined(); + expect(existsSync(join(home, ".fx", "auth.json"))).toBe(false); const revocations = oauth.requests.filter( (request) => request.path === "/oauth/revoke", ); @@ -286,9 +283,7 @@ test( const persisted = JSON.parse( readFileSync(join(home, ".fx", "auth.json"), "utf8"), ); - expect(persisted.credentials.fx_login.session.access_token).toBe( - RETRY_REFRESH_TOKEN, - ); + expect(persisted.access_token).toBe(RETRY_REFRESH_TOKEN); expect(result.stdout).not.toContain(EXPIRED_REFRESH_TOKEN); expect(result.stdout).not.toContain(RETRY_REFRESH_TOKEN); expect(result.stderr).not.toContain(EXPIRED_REFRESH_TOKEN); @@ -579,7 +574,7 @@ test( const persisted = JSON.parse( readFileSync(join(home, ".fx", "auth.json"), "utf8"), ); - expect(persisted.credentials.fx_login.session).toMatchObject({ + expect(persisted).toMatchObject({ issuer: issuerA.issuerUrl, access_token: ISSUER_A_ACCESS_TOKEN, refresh_token: "rotated-refresh-token", diff --git a/tests/e2e/cli.test.ts b/tests/e2e/cli.test.ts index fd9e37642..433f20bd9 100644 --- a/tests/e2e/cli.test.ts +++ b/tests/e2e/cli.test.ts @@ -68,20 +68,6 @@ function doctorSessionDiagnosticsLimit(): number { } const SEEDED_GATEWAY_TOKEN = "seeded-access-token"; -const OAUTH_KEYCHAIN_SERVICE = "FX_OAUTH_SESSION_V1"; - -function commonCredential(home: string, source: string) { - const path = join(home, ".fx", "auth.json"); - if (!existsSync(path)) return undefined; - const parsed = JSON.parse(readFileSync(path, "utf8")); - return parsed.version === 2 ? parsed.credentials?.[source] : parsed; -} - -function fxLoginPresentAtPath(path: string): boolean { - if (!existsSync(path)) return false; - const parsed = JSON.parse(readFileSync(path, "utf8")); - return parsed.version === 2 ? parsed.credentials?.fx_login !== undefined : true; -} function writeSeededFxAuth( home: string, @@ -186,7 +172,7 @@ function startLogoutIssuer( typeof tokenTypeHint === "string" ? tokenTypeHint : "missing", validForm, ...(authPath - ? { localSessionPresent: fxLoginPresentAtPath(authPath) } + ? { localSessionPresent: existsSync(authPath) } : {}), }); const configuredStatus = revokeStatuses[revokeAttempt] ?? 200; @@ -1563,7 +1549,7 @@ describe("cli: logout", () => { expect(logout.code).toBe(0); expect(logout.stdout).toBe("Signed out of fx.\n"); expect(logout.stderr).toBe(""); - expect(commonCredential(home, "fx_login")).toBeUndefined(); + expect(existsSync(authPath)).toBe(false); expect(issuer.requests).toEqual([ { method: "GET", path: "/.well-known/openid-configuration" }, { @@ -1619,7 +1605,7 @@ describe("cli: logout", () => { expect(logout.stderr).toBe( "Warning: signed out locally, but the remote session could not be revoked.\n", ); - expect(commonCredential(home, "fx_login")).toBeUndefined(); + expect(existsSync(authPath)).toBe(false); expect(issuer.requests).toEqual([ { method: "GET", path: "/.well-known/openid-configuration" }, ]); @@ -1655,7 +1641,7 @@ describe("cli: logout", () => { expect(logout.stderr).toBe( "Warning: signed out locally, but the remote session could not be revoked.\n", ); - expect(commonCredential(home, "fx_login")).toBeUndefined(); + expect(existsSync(authPath)).toBe(false); expect(issuer.requests).toEqual([ { method: "GET", path: "/.well-known/openid-configuration" }, ]); @@ -1669,7 +1655,7 @@ describe("cli: logout", () => { ); test( - "fx logout fails closed for a common auth document with unsafe permissions", + "fx logout removes a saved login rejected for unsafe permissions", async () => { const home = mkdtempSync(join(tmpdir(), "fx-e2e-logout-rejected-login-")); const issuer = startLogoutIssuer([200, 200]); @@ -1686,13 +1672,10 @@ describe("cli: logout", () => { }, }); - expect(logout.code).toBe(1); - expect(logout.stdout).toBe(""); - expect(logout.stderr).toBe( - "fx logout: failed to durably remove saved fx login\n" + - "Warning: signed out locally, but the remote session could not be revoked.\n", - ); - expect(existsSync(authPath)).toBe(true); + expect(logout.code).toBe(0); + expect(logout.stdout).toBe("Signed out of fx.\n"); + expect(logout.stderr).toBe(""); + expect(existsSync(authPath)).toBe(false); expect(issuer.requests).toEqual([]); for (const secret of [ SEEDED_GATEWAY_TOKEN, @@ -1719,8 +1702,7 @@ describe("cli: logout", () => { const authPath = join(fxDir, "auth.json"); try { writeSeededFxAuth(home, undefined, issuer.issuerUrl); - rmSync(authPath); - mkdirSync(authPath, { mode: 0o700 }); + chmodSync(fxDir, 0o500); const env = { ...NO_GATEWAY_AUTH, @@ -1733,14 +1715,13 @@ describe("cli: logout", () => { expect(logout.code).toBe(1); expect(logout.stdout).toBe(""); expect(logout.stderr).toBe( - "fx logout: failed to durably remove saved fx login\n" + - "Warning: signed out locally, but the remote session could not be revoked.\n", + "fx logout: failed to durably remove saved fx login\n", ); expect(existsSync(authPath)).toBe(true); - expect(JSON.parse(status.stdout).auth).toBe("missing"); + expect(JSON.parse(status.stdout).auth).toBe("fx login"); expect(issuer.requests).toEqual([]); } finally { - rmSync(authPath, { recursive: true, force: true }); + chmodSync(fxDir, 0o700); issuer.stop(); rmSync(home, { recursive: true, force: true }); } @@ -1771,7 +1752,7 @@ describe("cli: logout", () => { expect(logout.stderr).toBe( "Warning: signed out locally, but the remote session could not be revoked.\n", ); - expect(commonCredential(home, "fx_login")).toBeUndefined(); + expect(existsSync(join(home, ".fx", "auth.json"))).toBe(false); expect(issuer.requests).toEqual([ { method: "GET", path: "/.well-known/openid-configuration" }, { @@ -1864,7 +1845,7 @@ describe("cli: logout", () => { ); test.skipIf(platform() !== "darwin")( - "fx logout preserves the macOS Keychain API key in the common store", + "fx logout leaves the macOS Keychain API key untouched", async () => { const runId = `${process.pid}-${Date.now()}`; const account = `fx-e2e-logout-${runId}`; @@ -1904,8 +1885,9 @@ describe("cli: logout", () => { expect(logout.code).toBe(0); expect(logout.stderr).toBe(""); expect(existsSync(join(home, ".fx", "auth.json"))).toBe(false); - expect(stored.status).not.toBe(0); - expect(JSON.parse(status.stdout).auth).toBe("stored API key (macOS Keychain)"); + expect(stored.status).toBe(0); + expect(stored.stdout.trim()).toBe(keychainToken); + expect(JSON.parse(status.stdout).auth).not.toBe("fx login"); expect(logout.stdout).not.toContain(keychainToken); expect(status.stdout).not.toContain(keychainToken); } finally { @@ -1915,11 +1897,6 @@ describe("cli: logout", () => { ["delete-generic-password", "-a", account, "-s", KEYCHAIN_SERVICE], { encoding: "utf8" }, ); - spawnSync( - "/usr/bin/security", - ["delete-generic-password", "-a", account, "-s", OAUTH_KEYCHAIN_SERVICE], - { encoding: "utf8" }, - ); rmSync(root, { recursive: true, force: true }); } }, @@ -1990,7 +1967,6 @@ describe("cli: stored key file backend", () => { mkdirSync(fxDir, { recursive: true, mode: 0o700 }); chmodSync(fxDir, 0o700); const keyPath = join(fxDir, "api-key"); - const authPath = join(fxDir, "auth.json"); writeFileSync(keyPath, "vca_file_backend_key", { mode: 0o600 }); chmodSync(keyPath, 0o600); const env = { ...NO_GATEWAY_AUTH, HOME: realpathSync(home) }; @@ -2002,8 +1978,6 @@ describe("cli: stored key file backend", () => { expect(readableJson.auth).toBe("stored API key (profile file)"); expect(readableJson.auth_help).toBeUndefined(); expect(readable.stdout).not.toContain("vca_file_backend_key"); - expect(existsSync(keyPath)).toBe(true); - expect(existsSync(authPath)).toBe(false); chmodSync(keyPath, 0o644); const refused = await runFx(["status", "--json"], { env }); @@ -2117,21 +2091,7 @@ describe("cli: Keychain authentication", () => { "Keychain ask complete", ); expect(result.stdout).not.toContain(fakeKey); - expect(existsSync(join(home, ".fx", "auth.json"))).toBe(false); - const legacy = spawnSync( - "/usr/bin/security", - ["find-generic-password", "-a", account, "-s", KEYCHAIN_SERVICE, "-w"], - { encoding: "utf8", env: { ...process.env, HOME: realpathSync(home), USER: account } }, - ); - expect(legacy.status).not.toBe(0); - const status = await runFx(["status", "--json"], { - env: { - ...NO_GATEWAY_AUTH, - HOME: realpathSync(home), - USER: account, - }, - }); - expect(JSON.parse(status.stdout).auth).toBe("stored API key (macOS Keychain)"); + expect(existsSync(join(home, ".fx"))).toBe(false); expect(gateway.requests).toHaveLength(1); expect(gateway.requests[0]!.headers.get("authorization")).toBe( `Bearer ${fakeKey}`, @@ -2149,17 +2109,6 @@ describe("cli: Keychain authentication", () => { ], { encoding: "utf8" }, ); - spawnSync( - "/usr/bin/security", - [ - "delete-generic-password", - "-a", - account, - "-s", - OAUTH_KEYCHAIN_SERVICE, - ], - { encoding: "utf8" }, - ); rmSync(root, { recursive: true, force: true }); } }, diff --git a/tests/e2e/oauth-keychain-migration.test.ts b/tests/e2e/oauth-keychain-migration.test.ts index 49fad18c6..4f4eea8ca 100644 --- a/tests/e2e/oauth-keychain-migration.test.ts +++ b/tests/e2e/oauth-keychain-migration.test.ts @@ -221,8 +221,14 @@ keychainTest( const first = await runFx(["status", "--json"], { env, timeoutMs: TIMEOUT }); expect(first.code, `stdout: ${first.stdout}\nstderr: ${first.stderr}`).toBe(0); expect(JSON.parse(first.stdout).auth).toBe("fx login"); - expect(existsSync(join(home, ".fx", "auth.json"))).toBe(true); - expect(loadKeychainItem(account, home)).toBeNull(); + expect( + existsSync(join(home, ".fx", "auth.json")), + readFileSync(join(home, "oauth-keychain-trace.log"), "utf8"), + ).toBe(false); + + const stored = loadKeychainItem(account, home); + expect(stored).not.toBeNull(); + expect(JSON.parse(stored!).access_token).toBe(`keychain-access-${account}`); const refreshed = await runFx( ["ask", "--json", "--no-save", "Refresh the saved login."], @@ -235,17 +241,10 @@ keychainTest( expect(JSON.parse(refreshed.stdout).output).toContain( "Keychain refresh complete", ); - expect( - existsSync(join(home, ".fx", "auth.json")), - readFileSync(join(home, "oauth-keychain-trace.log"), "utf8"), - ).toBe(false); - - const stored = loadKeychainItem(account, home); - expect(stored).not.toBeNull(); - expect(JSON.parse(stored!).credentials.fx_login.session.access_token).toBe( + expect(existsSync(join(home, ".fx", "auth.json"))).toBe(false); + expect(JSON.parse(loadKeychainItem(account, home)!).access_token).toBe( "keychain-refreshed-access", ); - expect(existsSync(join(home, ".fx", "auth.json"))).toBe(false); expect( issuer.requests.filter((request) => request.path === "/oauth/token"), ).toHaveLength(1); @@ -259,7 +258,7 @@ keychainTest( const logout = await runFx(["logout"], { env, timeoutMs: TIMEOUT }); expect(logout.code, `stdout: ${logout.stdout}\nstderr: ${logout.stderr}`).toBe(0); expect(logout.stdout).toBe("Signed out of fx.\n"); - expect(JSON.parse(loadKeychainItem(account, home)!).credentials.fx_login).toBeUndefined(); + expect(loadKeychainItem(account, home)).toBeNull(); expect(issuer.requests.filter((request) => request.path === "/oauth/revoke")).toHaveLength(2); } finally { cleanup(); @@ -282,7 +281,6 @@ keychainTest( const home = mkdtempSync(join(tmpdir(), "fx-oauth-keychain-failure-")); attachSystemKeychain(home); const issuer = startOAuthIssuer(); - const gateway = startFakeGateway([fakeGatewayFinalText("migration complete")]); const cleanup = () => deleteKeychainItem(account); activeCleanups.add(cleanup); cleanup(); @@ -290,13 +288,8 @@ keychainTest( let injectedFailureObserved = false; try { - const status = await runFx(["ask", "--json", "--no-save", "Migrate auth."], { - env: { - ...keychainEnv(home, account, issuer.issuer), - FX_GATEWAY_BASE_URL: gateway.baseUrl, - FX_GATEWAY_CHAT_URL: gateway.chatUrl, - FX_MODEL: FAKE_GATEWAY_MODEL, - }, + const status = await runFx(["status", "--json"], { + env: keychainEnv(home, account, issuer.issuer), timeoutMs: TIMEOUT, }); expect(status.code).toBe(0); @@ -308,7 +301,6 @@ keychainTest( } finally { cleanup(); activeCleanups.delete(cleanup); - gateway.stop(); issuer.stop(); } diff --git a/tests/e2e/tui-auth-source-selection.test.ts b/tests/e2e/tui-auth-source-selection.test.ts index 45788cc5e..d25f5fc44 100644 --- a/tests/e2e/tui-auth-source-selection.test.ts +++ b/tests/e2e/tui-auth-source-selection.test.ts @@ -165,30 +165,6 @@ function writeSeededGrokLogin(testHome: string, accessToken: string, accountId = chmodSync(authPath, 0o600); } -function readCommonAuth(testHome: string): { - version: number; - credentials: Record }>; -} { - return JSON.parse(readFileSync(join(testHome, ".fx", "auth.json"), "utf8")); -} - -function commonSession(testHome: string, source: "fx_login" | "chatgpt_subscription" | "grok_subscription") { - return readCommonAuth(testHome).credentials[source]?.session; -} - -function persistedSession( - testHome: string, - source: "fx_login" | "chatgpt_subscription" | "grok_subscription", - legacyFile: string, -) { - const commonPath = join(testHome, ".fx", "auth.json"); - if (existsSync(commonPath)) { - const session = commonSession(testHome, source); - if (session) return session; - } - return JSON.parse(readFileSync(join(testHome, ".fx", legacyFile), "utf8")); -} - function readSingleUsageSnapshot(testHome: string): { billing: string; next_sequence: number; @@ -1195,7 +1171,7 @@ tmuxTest( await session.waitForComposer(TIMEOUT); expect(session.isAlive()).toBe(true); - expect(existsSync(join(home, ".fx", "auth.json"))).toBe(false); + expect(existsSync(join(home, ".fx", "chatgpt-auth.json"))).toBe(false); expect(await session.captureFullScrollback()).not.toContain("Signed in with Codex."); expect(readFileSync(stderrPath, "utf8")).toBe(""); }, @@ -1236,10 +1212,9 @@ tmuxTest( await completeDisplayedCodexLogin(session, chatgptOauth); await session.waitForText("Switched to Codex subscription with gpt-5.6-sol.", TIMEOUT); - const authPath = join(home, ".fx", "auth.json"); + const authPath = join(home, ".fx", "chatgpt-auth.json"); expect(existsSync(authPath)).toBe(true); expect(statSync(authPath).mode & 0o077).toBe(0); - expect(commonSession(home, "chatgpt_subscription")).toBeDefined(); await session.sendText("/status"); await session.waitForText( @@ -1354,7 +1329,7 @@ tmuxTest( .toHaveLength(authorizeRequestsBeforeRoundTrip); await session.sendText("/logout codex"); await session.waitForText("Signed out of Codex.", TIMEOUT); - expect(commonSession(home, "chatgpt_subscription")).toBeUndefined(); + expect(existsSync(authPath)).toBe(false); await session.sendText("/status"); await session.waitForText("model_source=Codex subscription", TIMEOUT); chatgptOauth.setModels([ @@ -1693,10 +1668,9 @@ profileStoredKeyTmuxTest( await session.waitForText("auth=stored API key (profile file)", TIMEOUT); expect(savedCredentialSource(home)).toBe("stored_key"); - const authPath = join(home, ".fx", "auth.json"); - expect(readCommonAuth(home).credentials.stored_key?.secret).toBe(STORED_TOKEN); - expect(statSync(authPath).mode & 0o777).toBe(0o600); - expect(existsSync(join(home, ".fx", "api-key"))).toBe(false); + const keyPath = join(home, ".fx", "api-key"); + expect(readFileSync(keyPath, "utf8")).toBe(STORED_TOKEN); + expect(statSync(keyPath).mode & 0o777).toBe(0o600); await session.kill(); session = await startFx(home, stderrPath, gateway, undefined, undefined, { @@ -1826,7 +1800,7 @@ tmuxTest( await session.waitForText("auth=fx login", TIMEOUT); expect(savedCredentialSource(home)).toBe("fx_login"); - const savedAuth = commonSession(home, "fx_login") as { + const savedAuth = JSON.parse(readFileSync(join(home, ".fx", "auth.json"), "utf8")) as { team_id?: string; team_slug?: string; }; @@ -1896,13 +1870,12 @@ tmuxTest( await session.sendKeys("Enter"); await session.sendText("/status"); await session.waitForText("auth=fx login", TIMEOUT); - const migratedAuthFile = readFileSync(authPath, "utf8"); - expect(readCommonAuth(home).version).toBe(2); + expect(readFileSync(authPath, "utf8")).toBe(seededAuthFile); await session.sendText("use the selected login credential"); await session.waitForText(LOGIN_RESPONSE, TIMEOUT); expect(gateway.requests).toHaveLength(2); expect(gateway.requests[1].headers.get("authorization")).toBe(`Bearer ${LOGIN_TOKEN}`); - expect(readFileSync(authPath, "utf8")).toBe(migratedAuthFile); + expect(readFileSync(authPath, "utf8")).toBe(seededAuthFile); const firstRunOutput = await session.captureFullScrollback(); const firstRunStderr = readFileSync(stderrPath, "utf8"); @@ -1913,7 +1886,7 @@ tmuxTest( // The switch above is remembered, so the restart keeps fx login rather than // letting AI_GATEWAY_API_KEY reclaim it through precedence. await session.waitForText("auth=fx login", TIMEOUT); - expect(readFileSync(authPath, "utf8")).toBe(migratedAuthFile); + expect(readFileSync(authPath, "utf8")).toBe(seededAuthFile); await session.sendText("use the remembered credential after restart"); await session.waitForText(RESTART_RESPONSE, TIMEOUT); expect(gateway.requests).toHaveLength(3); @@ -1937,7 +1910,7 @@ tmuxTest( expect(oauth.requests[3].authorization).toBe(`Bearer ${ACQUIRED_LOGIN_TOKEN}`); expect(oauth.requests[1].clientId).toBe("test-client"); expect(oauth.requests[2].clientId).toBe("test-client"); - const acquiredAuth = commonSession(home, "fx_login") as { + const acquiredAuth = JSON.parse(readFileSync(authPath, "utf8")) as { issuer: string; client_id: string; access_token: string; @@ -1962,7 +1935,7 @@ tmuxTest( await session.sendText("/logout"); const loggedOut = await session.waitForText("Signed out of fx.", TIMEOUT); expect(loggedOut).not.toContain("remote session could not be revoked"); - expect(commonSession(home, "fx_login")).toBeUndefined(); + expect(existsSync(authPath)).toBe(false); expect( oauth.requests .filter((request) => request.path === "/oauth/revoke") @@ -2218,7 +2191,7 @@ test( expect(tokenRequests).toHaveLength(1); expect(tokenRequests[0].clientId).toBe(fallbackClientId); - const persisted = commonSession(home, "fx_login") as { + const persisted = JSON.parse(readFileSync(authPath, "utf8")) as { client_id: string; access_token: string; team_id?: string; @@ -2275,7 +2248,9 @@ test( expect(result.code, `stdout: ${result.stdout}\nstderr: ${result.stderr}`).toBe(0); expect(result.stdout).toContain("Selected Vercel team: Vercel Labs (vercel-labs)."); expect(savedCredentialSource(home)).toBe("fx_login"); - const persisted = commonSession(home, "fx_login") as { + const persisted = JSON.parse( + readFileSync(join(home, ".fx", "auth.json"), "utf8"), + ) as { team_id?: string; team_slug?: string; }; @@ -2325,7 +2300,9 @@ test( expect(result.stdout).not.toContain("Selected Vercel team"); expect(result.stderr).toContain("selected team could not access AI Gateway"); expect(savedCredentialSource(home)).toBeUndefined(); - const persisted = commonSession(home, "fx_login") as { + const persisted = JSON.parse( + readFileSync(join(home, ".fx", "auth.json"), "utf8"), + ) as { team_id?: string; }; expect(persisted.team_id).toBe("team_old"); @@ -2359,7 +2336,7 @@ test("fx logout clears a remembered fx login source", async () => { expect(result.code, `stdout: ${result.stdout}\nstderr: ${result.stderr}`).toBe(0); expect(result.stdout).toContain("Signed out of fx."); expect(savedCredentialSource(home)).toBeUndefined(); - expect(readCommonAuth(home).credentials.fx_login).toBeUndefined(); + expect(existsSync(join(home, ".fx", "auth.json"))).toBe(false); }); test("fx models does not retry anonymously for an explicit credential", async () => { @@ -2451,10 +2428,9 @@ test( expect(login.stdout).not.toContain("Code:"); expect(login.stderr).toBe(""); - const authPath = join(home, ".fx", "auth.json"); + const authPath = join(home, ".fx", "chatgpt-auth.json"); expect(existsSync(authPath)).toBe(true); expect(statSync(authPath).mode & 0o077).toBe(0); - expect(commonSession(home, "chatgpt_subscription")).toBeDefined(); const settingsPath = join(home, ".fx", "settings.json"); const selected = JSON.parse(readFileSync(settingsPath, "utf8")); expect(selected.provider).toBe("codex"); @@ -2519,7 +2495,7 @@ test( const logout = await runFx(["logout", "codex"], { env, timeoutMs: TIMEOUT }); expect(logout.code).toBe(0); expect(logout.stdout).toContain("Signed out of Codex."); - expect(commonSession(home, "chatgpt_subscription")).toBeUndefined(); + expect(existsSync(authPath)).toBe(false); }, 60_000, ); @@ -2549,10 +2525,9 @@ test( expect(login.stdout).toContain("Signed in with Grok."); expect(login.stderr).toBe(""); - const authPath = join(home, ".fx", "auth.json"); + const authPath = join(home, ".fx", "grok-auth.json"); expect(existsSync(authPath)).toBe(true); expect(statSync(authPath).mode & 0o077).toBe(0); - expect(commonSession(home, "grok_subscription")).toBeDefined(); const settings = JSON.parse(readFileSync(join(home, ".fx", "settings.json"), "utf8")); expect(settings.provider).toBe("grok"); expect(settings.models.grok).toBe("grok-4.20"); @@ -2611,7 +2586,7 @@ test( expect(logout.code, `stdout: ${logout.stdout}\nstderr: ${logout.stderr}`).toBe(0); expect(logout.stdout).toContain("Signed out of Grok."); expect(grok.requests.some((request) => request.path === "/oauth2/revoke")).toBe(true); - expect(commonSession(home, "grok_subscription")).toBeUndefined(); + expect(existsSync(authPath)).toBe(false); } finally { grok.stop(); } @@ -2644,7 +2619,7 @@ test( expect(result.stdout).not.toContain("grok-code"); expect(result.stderr).toBe(""); expect(grok.tokenCalls()).toBe(1); - expect(commonSession(home, "grok_subscription")).toBeDefined(); + expect(existsSync(join(home, ".fx", "grok-auth.json"))).toBe(true); } finally { grok.stop(); } @@ -2662,6 +2637,7 @@ test("Grok logout removes local credentials when remote revocation fails", async JSON.stringify({ provider: "grok", grok_model: "grok-4.20" }) + "\n", { mode: 0o600 }, ); + const authPath = join(home, ".fx", "grok-auth.json"); const result = await runFx(["logout", "grok"], { env: { HOME: home, @@ -2674,7 +2650,7 @@ test("Grok logout removes local credentials when remote revocation fails", async expect(result.code, `stdout: ${result.stdout}\nstderr: ${result.stderr}`).toBe(0); expect(result.stdout).toContain("Signed out of Grok."); expect(result.stderr).toContain("remote revocation could not be confirmed"); - expect(commonSession(home, "grok_subscription")).toBeUndefined(); + expect(existsSync(authPath)).toBe(false); expect(JSON.parse(readFileSync(join(home, ".fx", "settings.json"), "utf8")).provider) .toBe("grok"); const ask = await runFx(["ask", "--json", "--no-save", "Still Grok?"], { @@ -2715,7 +2691,7 @@ test("Grok 401 replay refuses a different account before the second provider sen }); expect(ask.code).toBe(1); expect(grok.requests.filter((request) => request.path === "/v1/responses")).toHaveLength(1); - const saved = persistedSession(home, "grok_subscription", "grok-auth.json") as { + const saved = JSON.parse(readFileSync(join(home, ".fx", "grok-auth.json"), "utf8")) as { access_token: string; account_id: string; }; @@ -2910,7 +2886,7 @@ tmuxTest( const scrollback = await session.captureFullScrollback(); expect(scrollback).not.toContain("grok-code"); expect(grok.tokenCalls()).toBe(1); - expect(commonSession(home, "grok_subscription")).toBeDefined(); + expect(existsSync(join(home, ".fx", "grok-auth.json"))).toBe(true); expect(readFileSync(stderrPath, "utf8")).toBe(""); } finally { grok.stop(); @@ -3160,7 +3136,7 @@ test( expect(login.code).toBe(1); expect(login.stdout).not.toContain("Signed in with Codex."); expect(login.stderr).toContain("fx login: could not load the target model catalog (malformed_response)"); - expect(commonSession(home, "chatgpt_subscription")).toBeDefined(); + expect(existsSync(join(home, ".fx", "chatgpt-auth.json"))).toBe(true); const settingsPath = join(home, ".fx", "settings.json"); expect(existsSync(settingsPath)).toBe(false); }, @@ -3192,7 +3168,7 @@ test( expect(login.code).toBe(1); expect(login.stdout).not.toContain("Signed in with Grok."); expect(login.stderr).toContain("fx login: target model catalog is empty"); - expect(commonSession(home, "grok_subscription")).toBeDefined(); + expect(existsSync(join(home, ".fx", "grok-auth.json"))).toBe(true); expect(existsSync(join(home, ".fx", "settings.json"))).toBe(false); } finally { grok.stop(); @@ -3310,7 +3286,7 @@ test( ); test( - "saved provider switching uses one common auth document and one profile ledger", + "saved provider switching publishes Gateway, Codex, and Grok usage to one profile ledger", async () => { home = mkdtempSync(join(tmpdir(), "fx-provider-usage-ledger-")); const workspace = join(home, "workspace"); @@ -3363,12 +3339,11 @@ test( 5, ); try { - writeSeededFxLogin(home, Date.now() + 60 * 60 * 1000, "https://vercel.com", "team_123"); writeSeededChatGptLogin(home, chatgptAccessToken("acct_usage")); writeSeededGrokLogin(home, "grok-usage-token", "acct_usage"); const env = { HOME: home, - AI_GATEWAY_API_KEY: undefined, + AI_GATEWAY_API_KEY: "gateway-usage-key", VERCEL_OIDC_TOKEN: undefined, FX_DISABLE_KEYCHAIN: "1", FX_AUTO_UPGRADE: "0", @@ -3423,13 +3398,6 @@ test( expect(gateway.requests).toHaveLength(1); expect(codex.responses).toBe(1); expect(grok.responses).toBe(1); - const auth = readCommonAuth(home); - expect(auth.version).toBe(2); - expect(auth.credentials.fx_login?.session).toBeDefined(); - expect(auth.credentials.chatgpt_subscription?.session).toBeDefined(); - expect(auth.credentials.grok_subscription?.session).toBeDefined(); - expect(existsSync(join(home, ".fx", "chatgpt-auth.json"))).toBe(false); - expect(existsSync(join(home, ".fx", "grok-auth.json"))).toBe(false); } finally { codex.stop(); grok.stop(); @@ -3764,7 +3732,7 @@ tmuxTest( expect( loggedOut.match(/remote session could not be revoked/g) ?? [], ).toHaveLength(1); - expect(commonSession(home, "fx_login")).toBeUndefined(); + expect(existsSync(join(home, ".fx", "auth.json"))).toBe(false); await session.sendText("/status"); await session.waitForText("auth=AI_GATEWAY_API_KEY", TIMEOUT); @@ -3821,7 +3789,7 @@ tmuxTest( await session.waitForComposer(TIMEOUT); await session.sendText("/logout"); await session.waitForText("Signed out of fx.", TIMEOUT); - expect(commonSession(home, "fx_login")).toBeUndefined(); + expect(existsSync(join(home, ".fx", "auth.json"))).toBe(false); await session.sendText("/status"); await session.waitForText("auth=AI_GATEWAY_API_KEY", TIMEOUT); @@ -3835,7 +3803,7 @@ tmuxTest( ); tmuxTest( - "logout fails closed for a common auth document with unsafe permissions", + "logout removes an fx login rejected for unsafe permissions", async () => { home = mkdtempSync(join(tmpdir(), "fx-tui-logout-rejected-login-")); stderrPath = join(home, "stderr.log"); @@ -3849,12 +3817,8 @@ tmuxTest( session = await startFx(home, stderrPath, gateway, oauth.issuerUrl); await session.waitForComposer(TIMEOUT); await session.sendText("/logout"); - const failed = await session.waitForText( - "Could not confirm durable fx logout. The active source was recalculated.", - TIMEOUT, - ); - expect(failed).not.toContain("Signed out of fx."); - expect(existsSync(authPath)).toBe(true); + const loggedOut = await session.waitForText("Signed out of fx.", TIMEOUT); + expect(existsSync(authPath)).toBe(false); await session.sendText("/status"); await session.waitForText("auth=AI_GATEWAY_API_KEY", TIMEOUT); @@ -3865,7 +3829,7 @@ tmuxTest( "seeded-refresh-token", oauth.providerDetail, ]) { - expect(failed).not.toContain(secret); + expect(loggedOut).not.toContain(secret); } }, 60_000, From fb122aaa33c849957a05be60e6e550eb290e7a75 Mon Sep 17 00:00:00 2001 From: Pranit Date: Wed, 2 Sep 2026 12:20:38 -0400 Subject: [PATCH 11/12] Document host-managed authentication --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index b2ed77ac8..1337ba794 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,8 @@ To use an AI Gateway API key instead: fx setup ``` +Embedding hosts that inject provider authentication at the network boundary can set `FX_AUTH_MODE=host-managed`. In this mode, fx does not read, refresh, or write local model-provider credentials and does not add authentication-owned headers to Gateway, Codex, or Grok requests. The host must authenticate those forwarded requests. + Run fx from a project: ```bash From 1e441abc43ea24dad17abeb4141513a63a928e24 Mon Sep 17 00:00:00 2001 From: Pranit Date: Wed, 2 Sep 2026 14:43:25 -0400 Subject: [PATCH 12/12] Keep host-managed auth coverage in its existing suite Move the four scenarios into the auth-source owner so they inherit its verification classification without triggering the unrelated PGSO training pipeline. --- scripts/pgso/corpus.json | 1 - scripts/pgso/tests/test_corpus.py | 5 +- tests/e2e/ci-shard-weights.json | 3 +- tests/e2e/host-managed-auth.test.ts | 261 -------------------- tests/e2e/tui-auth-source-selection.test.ts | 250 ++++++++++++++++++- 5 files changed, 252 insertions(+), 268 deletions(-) delete mode 100644 tests/e2e/host-managed-auth.test.ts diff --git a/scripts/pgso/corpus.json b/scripts/pgso/corpus.json index ee070e953..948d9483d 100644 --- a/scripts/pgso/corpus.json +++ b/scripts/pgso/corpus.json @@ -78,7 +78,6 @@ {"name": "e2e-config-persistence", "argv": ["bun", "test", "--max-concurrency", "1", "./config-persistence.test.ts"], "test_file": "config-persistence.test.ts"}, {"name": "e2e-prompt-history", "argv": ["bun", "test", "--max-concurrency", "1", "./prompt-history.test.ts"], "test_file": "prompt-history.test.ts"}, {"name": "e2e-auth-refresh", "argv": ["bun", "test", "--max-concurrency", "1", "./auth-refresh.test.ts"], "test_file": "auth-refresh.test.ts"}, - {"name": "e2e-host-managed-auth", "argv": ["bun", "test", "--max-concurrency", "1", "./host-managed-auth.test.ts"], "test_file": "host-managed-auth.test.ts"}, {"name": "e2e-file-tool-paths", "argv": ["bun", "test", "--max-concurrency", "1", "./file-tool-paths.test.ts"], "test_file": "file-tool-paths.test.ts"}, {"name": "e2e-file-tool-permissions", "argv": ["bun", "test", "--max-concurrency", "1", "./file-tool-permissions.test.ts"], "test_file": "file-tool-permissions.test.ts"}, {"name": "e2e-gateway-stream-lifecycle", "argv": ["bun", "test", "--max-concurrency", "1", "./gateway-stream-lifecycle.test.ts"], "test_file": "gateway-stream-lifecycle.test.ts"}, diff --git a/scripts/pgso/tests/test_corpus.py b/scripts/pgso/tests/test_corpus.py index 968f85a5b..5c3495450 100644 --- a/scripts/pgso/tests/test_corpus.py +++ b/scripts/pgso/tests/test_corpus.py @@ -25,7 +25,6 @@ "config-persistence.test.ts", "prompt-history.test.ts", "auth-refresh.test.ts", - "host-managed-auth.test.ts", "file-tool-paths.test.ts", "file-tool-permissions.test.ts", "gateway-stream-lifecycle.test.ts", @@ -364,8 +363,8 @@ def test_production_manifest_classifies_every_e2e_file(self) -> None: EXCLUDED_E2E_TESTS, tuple(test_file for test_file, _ in corpus.intentional_exclusions), ) - self.assertEqual(35, len(corpus.scenarios)) - self.assertEqual(52, len(corpus.candidate_scenarios)) + self.assertEqual(34, len(corpus.scenarios)) + self.assertEqual(51, len(corpus.candidate_scenarios)) self.assertEqual( { "direct-help": 100, diff --git a/tests/e2e/ci-shard-weights.json b/tests/e2e/ci-shard-weights.json index 64c4bb364..978b04724 100644 --- a/tests/e2e/ci-shard-weights.json +++ b/tests/e2e/ci-shard-weights.json @@ -10,7 +10,6 @@ { "file": "file-tool-paths.test.ts", "weight": 6 }, { "file": "file-tool-permissions.test.ts", "weight": 2 }, { "file": "gateway-stream-lifecycle.test.ts", "weight": 98 }, - { "file": "host-managed-auth.test.ts", "weight": 2 }, { "file": "mcp-auth.test.ts", "weight": 105 }, { "file": "mcp-http.test.ts", "weight": 10 }, { "file": "mcp-legacy-remote.test.ts", "weight": 19 }, @@ -23,7 +22,7 @@ { "file": "terminal-host.test.ts", "weight": 273 }, { "file": "tmux-helpers.test.ts", "weight": 2 }, { "file": "tui-agent.test.ts", "weight": 1 }, - { "file": "tui-auth-source-selection.test.ts", "weight": 49 }, + { "file": "tui-auth-source-selection.test.ts", "weight": 51 }, { "file": "tui-command-permissions.test.ts", "weight": 153 }, { "file": "tui-composer-edit-contracts.test.ts", "weight": 168 }, { "file": "tui-cost.test.ts", "weight": 16 }, diff --git a/tests/e2e/host-managed-auth.test.ts b/tests/e2e/host-managed-auth.test.ts deleted file mode 100644 index 6cd9bf131..000000000 --- a/tests/e2e/host-managed-auth.test.ts +++ /dev/null @@ -1,261 +0,0 @@ -import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { runFx } from "../evals/eval-helpers"; -import { fakeGatewayFinalText, TmuxSession } from "./tmux-helpers"; - -const TIMEOUT = 30_000; - -type CapturedRequest = { - path: string; - method: string; - headers: Headers; -}; - -describe("host-managed authentication", () => { - let root = ""; - let home = ""; - let workspace = ""; - let requests: CapturedRequest[] = []; - let server: ReturnType; - let baseUrl = ""; - let codexUnauthorizedResponses = 0; - - beforeAll(() => { - root = mkdtempSync(join(tmpdir(), "fx-host-managed-auth-")); - home = join(root, "home"); - workspace = join(root, "workspace"); - mkdirSync(home, { recursive: true }); - mkdirSync(workspace, { recursive: true }); - server = Bun.serve({ - hostname: "127.0.0.1", - port: 0, - async fetch(request) { - const path = new URL(request.url).pathname; - requests.push({ - path, - method: request.method, - headers: new Headers(request.headers), - }); - if (path === "/gateway/models") { - return Response.json({ - data: [{ id: "test/gateway-model", type: "language", tags: ["tool-use"] }], - }); - } - if (path === "/gateway/responses") { - return fakeGatewayFinalText("GATEWAY_HOST_MANAGED_OK"); - } - if (path === "/codex/models") { - return Response.json({ models: [{ - slug: "gpt-5.4-mini", - visibility: "list", - supported_in_api: true, - priority: 1, - supported_reasoning_levels: [{ effort: "low" }], - additional_speed_tiers: [], - input_modalities: ["text"], - context_window: 272000, - }] }); - } - if (path === "/codex/responses") { - if (codexUnauthorizedResponses > 0) { - codexUnauthorizedResponses -= 1; - return Response.json({ error: { message: "host rejected request" } }, { status: 401 }); - } - return new Response( - 'data: {"type":"response.output_text.delta","delta":"CODEX_HOST_MANAGED_OK"}\n\n' + - 'data: {"type":"response.completed","response":{"id":"resp_codex_host","status":"completed","usage":{"input_tokens":4,"output_tokens":2}}}\n\n', - { headers: { "content-type": "text/event-stream" } }, - ); - } - if (path === "/grok/models") { - return Response.json({ data: [{ - id: "grok-4.20", - model: "grok-4.20", - api_backend: "responses", - context_window: 1000000, - supports_reasoning_effort: false, - reasoning_efforts: [], - }] }); - } - if (path === "/grok/modalities") { - return Response.json({ models: [{ - id: "grok-4.20", - input_modalities: ["text"], - output_modalities: ["text"], - }] }); - } - if (path === "/grok/responses") { - return new Response( - 'data: {"type":"response.output_text.delta","delta":"GROK_HOST_MANAGED_OK"}\n\n' + - 'data: {"type":"response.completed","response":{"id":"resp_grok_host","status":"completed","usage":{"input_tokens":4,"output_tokens":2}}}\n\n', - { headers: { "content-type": "text/event-stream" } }, - ); - } - return new Response("not found", { status: 404 }); - }, - }); - baseUrl = `http://127.0.0.1:${server.port}`; - }); - - afterAll(() => { - server.stop(true); - rmSync(root, { recursive: true, force: true }); - }); - - function env(): Record { - return { - HOME: home, - AI_GATEWAY_API_KEY: undefined, - VERCEL_OIDC_TOKEN: undefined, - FX_AUTH_MODE: "host-managed", - FX_AUTO_UPGRADE: "0", - FX_DISABLE_KEYCHAIN: "1", - FX_SKIP_ONBOARDING: "1", - FX_SOUND: "0", - FX_E2E_GATEWAY_MODELS_URL: `${baseUrl}/gateway/models`, - FX_E2E_GATEWAY_CHAT_URL: `${baseUrl}/gateway/responses`, - FX_E2E_OPENAI_CODEX_MODELS_URL: `${baseUrl}/codex/models`, - FX_E2E_OPENAI_CODEX_RESPONSES_URL: `${baseUrl}/codex/responses`, - FX_E2E_XAI_GROK_MODELS_URL: `${baseUrl}/grok/models`, - FX_E2E_XAI_GROK_MODALITIES_URL: `${baseUrl}/grok/modalities`, - FX_E2E_XAI_GROK_RESPONSES_URL: `${baseUrl}/grok/responses`, - }; - } - - test("runs Gateway Codex and Grok without local authentication headers", async () => { - const childEnv = env(); - const status = await runFx(["status", "--json"], { cwd: workspace, env: childEnv }); - expect(status.code).toBe(0); - expect(status.stderr).toBe(""); - expect(JSON.parse(status.stdout).auth).toBe("host managed"); - - for (const command of [["login"], ["logout"], ["setup"], ["teams"]]) { - const result = await runFx(command, { cwd: workspace, env: childEnv }); - expect(result.code).toBe(0); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("Authentication is managed by the host.\n"); - } - expect(existsSync(join(home, ".fx", "auth.json"))).toBe(false); - - for (const [provider, marker] of [ - ["gateway", "GATEWAY_HOST_MANAGED_OK"], - ["codex", "CODEX_HOST_MANAGED_OK"], - ["grok", "GROK_HOST_MANAGED_OK"], - ] as const) { - const selected = await runFx(["provider", provider], { - cwd: workspace, - env: childEnv, - timeoutMs: TIMEOUT, - }); - expect(selected.code).toBe(0); - expect(selected.stderr).toBe(""); - - const models = await runFx(["models", "--json"], { - cwd: workspace, - env: childEnv, - timeoutMs: TIMEOUT, - }); - expect(models.code).toBe(0); - expect(models.stderr).toBe(""); - - const asked = await runFx(["ask", "--json", "--no-save", "Reply once."], { - cwd: workspace, - env: childEnv, - timeoutMs: TIMEOUT, - }); - expect(asked.code).toBe(0); - expect(asked.stderr).toBe(""); - expect(asked.stdout).toContain(marker); - } - - expect(requests.length).toBeGreaterThan(0); - for (const request of requests) { - expect(request.headers.get("authorization"), request.path).toBeNull(); - expect(request.headers.get("x-vercel-ai-gateway-team"), request.path).toBeNull(); - expect(request.headers.get("chatgpt-account-id"), request.path).toBeNull(); - expect(request.headers.get("x-xai-token-auth"), request.path).toBeNull(); - expect(request.headers.get("x-authenticateresponse"), request.path).toBeNull(); - expect(request.headers.get("x-grok-user-id"), request.path).toBeNull(); - expect(request.headers.get("x-userid"), request.path).toBeNull(); - } - expect(existsSync(join(home, ".fx", "auth.json"))).toBe(false); - }, TIMEOUT); - - test("rejects malformed auth mode before provider I/O", async () => { - const before = requests.length; - const result = await runFx(["ask", "--json", "--no-save", "Do nothing."], { - cwd: workspace, - env: { ...env(), FX_AUTH_MODE: "host_managed" }, - timeoutMs: TIMEOUT, - }); - expect(result.code).toBe(1); - expect(result.stderr).toContain("FX_AUTH_MODE must be local or host-managed"); - expect(requests.length).toBe(before); - }, TIMEOUT); - - test("final provider 401 does not enter local refresh or replay", async () => { - const childEnv = env(); - const selected = await runFx(["provider", "codex"], { - cwd: workspace, - env: childEnv, - timeoutMs: TIMEOUT, - }); - expect(selected.code).toBe(0); - - const before = requests.filter((request) => request.path === "/codex/responses").length; - codexUnauthorizedResponses = 1; - const asked = await runFx(["ask", "--json", "--no-save", "Reply once."], { - cwd: workspace, - env: childEnv, - timeoutMs: TIMEOUT, - }); - expect(asked.code).toBe(1); - const after = requests.filter((request) => request.path === "/codex/responses").length; - expect(after - before).toBe(1); - expect(existsSync(join(home, ".fx", "auth.json"))).toBe(false); - }, TIMEOUT); - - test("interactive host-managed session streams through the same authority", async () => { - const childEnv = env(); - const selected = await runFx(["provider", "gateway"], { - cwd: workspace, - env: childEnv, - timeoutMs: TIMEOUT, - }); - expect(selected.code).toBe(0); - - const stderrPath = join(root, "tui.stderr"); - const tracePath = join(root, "tui.trace"); - const before = requests.length; - const session = await TmuxSession.create({ - cwd: workspace, - env: { - ...childEnv, - FX_TRACE_LOG: tracePath, - FX_TRACE_SCOPES: "auth,session,worker,gateway", - }, - stderrPath, - isolated: true, - }); - try { - await session.waitForComposer(TIMEOUT); - await session.sendText("Reply once."); - const pane = await session.waitForText("GATEWAY_HOST_MANAGED_OK", TIMEOUT); - expect(pane).toContain("GATEWAY_HOST_MANAGED_OK"); - } catch (error) { - const trace = existsSync(tracePath) ? readFileSync(tracePath, "utf8") : ""; - throw new Error(`${String(error)}\ntrace:\n${trace}`); - } finally { - await session.kill(); - } - - expect(readFileSync(stderrPath, "utf8")).toBe(""); - expect(requests.length).toBeGreaterThan(before); - for (const request of requests.slice(before)) { - expect(request.headers.get("authorization"), request.path).toBeNull(); - expect(request.headers.get("x-vercel-ai-gateway-team"), request.path).toBeNull(); - } - }, TIMEOUT * 2); -}); diff --git a/tests/e2e/tui-auth-source-selection.test.ts b/tests/e2e/tui-auth-source-selection.test.ts index f22b07edd..dac6ab7cc 100644 --- a/tests/e2e/tui-auth-source-selection.test.ts +++ b/tests/e2e/tui-auth-source-selection.test.ts @@ -1,4 +1,4 @@ -import { afterEach, expect, test } from "bun:test"; +import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test"; import { spawn as nodeSpawn } from "node:child_process"; import { createHash } from "node:crypto"; import { @@ -4762,3 +4762,251 @@ for (const scenario of [ 60_000, ); } + +type HostManagedCapturedRequest = { + path: string; + headers: Headers; +}; + +describe("host-managed authentication", () => { + let hostRoot = ""; + let hostHome = ""; + let hostWorkspace = ""; + let hostRequests: HostManagedCapturedRequest[] = []; + let hostServer: ReturnType; + let hostBaseUrl = ""; + let codexUnauthorizedResponses = 0; + + beforeAll(() => { + hostRoot = mkdtempSync(join(tmpdir(), "fx-host-managed-auth-")); + hostHome = join(hostRoot, "home"); + hostWorkspace = join(hostRoot, "workspace"); + mkdirSync(hostHome, { recursive: true }); + mkdirSync(hostWorkspace, { recursive: true }); + hostServer = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request) { + const path = new URL(request.url).pathname; + hostRequests.push({ path, headers: new Headers(request.headers) }); + if (path === "/gateway/models") { + return Response.json({ + data: [{ id: "test/gateway-model", type: "language", tags: ["tool-use"] }], + }); + } + if (path === "/gateway/responses") { + return fakeGatewayFinalText("GATEWAY_HOST_MANAGED_OK"); + } + if (path === "/codex/models") { + return Response.json({ models: [{ + slug: "gpt-5.4-mini", + visibility: "list", + supported_in_api: true, + priority: 1, + supported_reasoning_levels: [{ effort: "low" }], + additional_speed_tiers: [], + input_modalities: ["text"], + context_window: 272000, + }] }); + } + if (path === "/codex/responses") { + if (codexUnauthorizedResponses > 0) { + codexUnauthorizedResponses -= 1; + return Response.json({ error: { message: "host rejected request" } }, { status: 401 }); + } + return new Response( + 'data: {"type":"response.output_text.delta","delta":"CODEX_HOST_MANAGED_OK"}\n\n' + + 'data: {"type":"response.completed","response":{"id":"resp_codex_host","status":"completed","usage":{"input_tokens":4,"output_tokens":2}}}\n\n', + { headers: { "content-type": "text/event-stream" } }, + ); + } + if (path === "/grok/models") { + return Response.json({ data: [{ + id: "grok-4.20", + model: "grok-4.20", + api_backend: "responses", + context_window: 1000000, + supports_reasoning_effort: false, + reasoning_efforts: [], + }] }); + } + if (path === "/grok/modalities") { + return Response.json({ models: [{ + id: "grok-4.20", + input_modalities: ["text"], + output_modalities: ["text"], + }] }); + } + if (path === "/grok/responses") { + return new Response( + 'data: {"type":"response.output_text.delta","delta":"GROK_HOST_MANAGED_OK"}\n\n' + + 'data: {"type":"response.completed","response":{"id":"resp_grok_host","status":"completed","usage":{"input_tokens":4,"output_tokens":2}}}\n\n', + { headers: { "content-type": "text/event-stream" } }, + ); + } + return new Response("not found", { status: 404 }); + }, + }); + hostBaseUrl = `http://127.0.0.1:${hostServer.port}`; + }); + + afterAll(() => { + hostServer.stop(true); + rmSync(hostRoot, { recursive: true, force: true }); + }); + + function hostManagedEnv(): Record { + return { + HOME: hostHome, + AI_GATEWAY_API_KEY: undefined, + VERCEL_OIDC_TOKEN: undefined, + FX_AUTH_MODE: "host-managed", + FX_AUTO_UPGRADE: "0", + FX_DISABLE_KEYCHAIN: "1", + FX_SKIP_ONBOARDING: "1", + FX_SOUND: "0", + FX_E2E_GATEWAY_MODELS_URL: `${hostBaseUrl}/gateway/models`, + FX_E2E_GATEWAY_CHAT_URL: `${hostBaseUrl}/gateway/responses`, + FX_E2E_OPENAI_CODEX_MODELS_URL: `${hostBaseUrl}/codex/models`, + FX_E2E_OPENAI_CODEX_RESPONSES_URL: `${hostBaseUrl}/codex/responses`, + FX_E2E_XAI_GROK_MODELS_URL: `${hostBaseUrl}/grok/models`, + FX_E2E_XAI_GROK_MODALITIES_URL: `${hostBaseUrl}/grok/modalities`, + FX_E2E_XAI_GROK_RESPONSES_URL: `${hostBaseUrl}/grok/responses`, + }; + } + + test("runs Gateway Codex and Grok without local authentication headers", async () => { + const childEnv = hostManagedEnv(); + const status = await runFx(["status", "--json"], { cwd: hostWorkspace, env: childEnv }); + expect(status.code).toBe(0); + expect(status.stderr).toBe(""); + expect(JSON.parse(status.stdout).auth).toBe("host managed"); + + for (const command of [["login"], ["logout"], ["setup"], ["teams"]]) { + const result = await runFx(command, { cwd: hostWorkspace, env: childEnv }); + expect(result.code).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toBe("Authentication is managed by the host.\n"); + } + expect(existsSync(join(hostHome, ".fx", "auth.json"))).toBe(false); + + for (const [provider, marker] of [ + ["gateway", "GATEWAY_HOST_MANAGED_OK"], + ["codex", "CODEX_HOST_MANAGED_OK"], + ["grok", "GROK_HOST_MANAGED_OK"], + ] as const) { + const selected = await runFx(["provider", provider], { + cwd: hostWorkspace, + env: childEnv, + timeoutMs: TIMEOUT, + }); + expect(selected.code).toBe(0); + expect(selected.stderr).toBe(""); + + const models = await runFx(["models", "--json"], { + cwd: hostWorkspace, + env: childEnv, + timeoutMs: TIMEOUT, + }); + expect(models.code).toBe(0); + expect(models.stderr).toBe(""); + + const asked = await runFx(["ask", "--json", "--no-save", "Reply once."], { + cwd: hostWorkspace, + env: childEnv, + timeoutMs: TIMEOUT, + }); + expect(asked.code).toBe(0); + expect(asked.stderr).toBe(""); + expect(asked.stdout).toContain(marker); + } + + expect(hostRequests.length).toBeGreaterThan(0); + for (const request of hostRequests) { + expect(request.headers.get("authorization"), request.path).toBeNull(); + expect(request.headers.get("x-vercel-ai-gateway-team"), request.path).toBeNull(); + expect(request.headers.get("chatgpt-account-id"), request.path).toBeNull(); + expect(request.headers.get("x-xai-token-auth"), request.path).toBeNull(); + expect(request.headers.get("x-authenticateresponse"), request.path).toBeNull(); + expect(request.headers.get("x-grok-user-id"), request.path).toBeNull(); + expect(request.headers.get("x-userid"), request.path).toBeNull(); + } + expect(existsSync(join(hostHome, ".fx", "auth.json"))).toBe(false); + }, TIMEOUT); + + test("rejects malformed auth mode before provider I/O", async () => { + const before = hostRequests.length; + const result = await runFx(["ask", "--json", "--no-save", "Do nothing."], { + cwd: hostWorkspace, + env: { ...hostManagedEnv(), FX_AUTH_MODE: "host_managed" }, + timeoutMs: TIMEOUT, + }); + expect(result.code).toBe(1); + expect(result.stderr).toContain("FX_AUTH_MODE must be local or host-managed"); + expect(hostRequests.length).toBe(before); + }, TIMEOUT); + + test("final provider 401 does not enter local refresh or replay", async () => { + const childEnv = hostManagedEnv(); + const selected = await runFx(["provider", "codex"], { + cwd: hostWorkspace, + env: childEnv, + timeoutMs: TIMEOUT, + }); + expect(selected.code).toBe(0); + + const before = hostRequests.filter((request) => request.path === "/codex/responses").length; + codexUnauthorizedResponses = 1; + const asked = await runFx(["ask", "--json", "--no-save", "Reply once."], { + cwd: hostWorkspace, + env: childEnv, + timeoutMs: TIMEOUT, + }); + expect(asked.code).toBe(1); + const after = hostRequests.filter((request) => request.path === "/codex/responses").length; + expect(after - before).toBe(1); + expect(existsSync(join(hostHome, ".fx", "auth.json"))).toBe(false); + }, TIMEOUT); + + test("interactive host-managed session streams through the same authority", async () => { + const childEnv = hostManagedEnv(); + const selected = await runFx(["provider", "gateway"], { + cwd: hostWorkspace, + env: childEnv, + timeoutMs: TIMEOUT, + }); + expect(selected.code).toBe(0); + + const hostStderrPath = join(hostRoot, "tui.stderr"); + const tracePath = join(hostRoot, "tui.trace"); + const before = hostRequests.length; + const hostSession = await TmuxSession.create({ + cwd: hostWorkspace, + env: { + ...childEnv, + FX_TRACE_LOG: tracePath, + FX_TRACE_SCOPES: "auth,session,worker,gateway", + }, + stderrPath: hostStderrPath, + isolated: true, + }); + try { + await hostSession.waitForComposer(TIMEOUT); + await hostSession.sendText("Reply once."); + const pane = await hostSession.waitForText("GATEWAY_HOST_MANAGED_OK", TIMEOUT); + expect(pane).toContain("GATEWAY_HOST_MANAGED_OK"); + } catch (error) { + const trace = existsSync(tracePath) ? readFileSync(tracePath, "utf8") : ""; + throw new Error(`${String(error)}\ntrace:\n${trace}`); + } finally { + await hostSession.kill(); + } + + expect(readFileSync(hostStderrPath, "utf8")).toBe(""); + expect(hostRequests.length).toBeGreaterThan(before); + for (const request of hostRequests.slice(before)) { + expect(request.headers.get("authorization"), request.path).toBeNull(); + expect(request.headers.get("x-vercel-ai-gateway-team"), request.path).toBeNull(); + } + }, TIMEOUT * 2); +});