From bc147dca6b86a28e53f35af6b7f8232be75084fc Mon Sep 17 00:00:00 2001 From: LJY <7yuny1@gmail.com> Date: Sun, 5 Apr 2026 14:51:44 +0800 Subject: [PATCH] feat: sync codex oauth into opencode auth and runtime --- src/auto.zig | 18 + src/cli.zig | 17 + src/main.zig | 31 ++ src/opencode_sync.zig | 687 +++++++++++++++++++++++++++++++++++++ src/tests/bdd_helpers.zig | 6 +- src/tests/e2e_cli_test.zig | 88 +++++ 6 files changed, 845 insertions(+), 2 deletions(-) create mode 100644 src/opencode_sync.zig diff --git a/src/auto.zig b/src/auto.zig index 29fed68..d727536 100644 --- a/src/auto.zig +++ b/src/auto.zig @@ -8,6 +8,7 @@ const c_time = @cImport({ }); const cli = @import("cli.zig"); const io_util = @import("io_util.zig"); +const opencode_sync = @import("opencode_sync.zig"); const registry = @import("registry.zig"); const sessions = @import("sessions.zig"); const usage_api = @import("usage_api.zig"); @@ -1309,6 +1310,20 @@ pub fn maybeAutoSwitchWithUsageFetcher( return maybeAutoSwitchWithUsageFetcherAndRefreshState(allocator, codex_home, reg, null, usage_fetcher); } +fn tryRefreshRunningOpencodeAfterSwitch( + allocator: std.mem.Allocator, + codex_home: []const u8, + reg: *const registry.Registry, +) void { + opencode_sync.sync(allocator, codex_home, reg) catch |err| { + std.log.warn("opencode file sync skipped after auto-switch: {s}", .{@errorName(err)}); + return; + }; + opencode_sync.refreshRunningServers(allocator, codex_home, reg) catch |err| { + std.log.warn("opencode runtime refresh skipped after auto-switch: {s}", .{@errorName(err)}); + }; +} + pub fn maybeAutoSwitchForDaemonWithUsageFetcher( allocator: std.mem.Allocator, codex_home: []const u8, @@ -1395,6 +1410,7 @@ pub fn maybeAutoSwitchForDaemonWithUsageFetcher( const previous_active_key = reg.accounts.items[active_idx].account_key; const next_active_key = reg.accounts.items[candidate_idx].account_key; try registry.activateAccountByKey(allocator, codex_home, reg, next_active_key); + tryRefreshRunningOpencodeAfterSwitch(allocator, codex_home, reg); try refresh_state.candidate_index.handleActiveSwitch( allocator, reg, @@ -1433,6 +1449,7 @@ pub fn maybeAutoSwitchForDaemonWithUsageFetcher( const previous_active_key = reg.accounts.items[active_idx].account_key; const next_active_key = reg.accounts.items[candidate_idx].account_key; try registry.activateAccountByKey(allocator, codex_home, reg, next_active_key); + tryRefreshRunningOpencodeAfterSwitch(allocator, codex_home, reg); try refresh_state.candidate_index.handleActiveSwitch( allocator, reg, @@ -1487,6 +1504,7 @@ fn maybeAutoSwitchWithUsageFetcherAndRefreshState( } try registry.activateAccountByKey(allocator, codex_home, reg, reg.accounts.items[candidate_idx].account_key); + tryRefreshRunningOpencodeAfterSwitch(allocator, codex_home, reg); return .{ .refreshed_candidates = refreshed_candidates, .state_changed = true, .switched = true }; } diff --git a/src/cli.zig b/src/cli.zig index 3bbdcaf..5360805 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -1001,6 +1001,23 @@ pub fn printRemoveSummary(labels: []const []const u8) !void { try out.flush(); } +pub fn printSwitchSuccess(email: []const u8, alias: []const u8, account_name: ?[]const u8) !void { + var stdout: io_util.Stdout = undefined; + stdout.init(); + const out = stdout.out(); + try out.print("Switched active account to {s}", .{email}); + if (alias.len != 0) { + try out.print(" (alias: {s})", .{alias}); + } + if (account_name) |name| { + if (name.len != 0) { + try out.print(" [{s}]", .{name}); + } + } + try out.writeAll(".\n"); + try out.flush(); +} + fn writeCodexLoginLaunchFailureHint(err_name: []const u8, use_color: bool) !void { var buffer: [512]u8 = undefined; var writer = std.fs.File.stderr().writer(&buffer); diff --git a/src/main.zig b/src/main.zig index dd8ed8c..c5d8a87 100644 --- a/src/main.zig +++ b/src/main.zig @@ -6,6 +6,7 @@ const registry = @import("registry.zig"); const auth = @import("auth.zig"); const auto = @import("auto.zig"); const format = @import("format.zig"); +const opencode_sync = @import("opencode_sync.zig"); const skip_service_reconcile_env = "CODEX_AUTH_SKIP_SERVICE_RECONCILE"; const account_name_refresh_only_env = "CODEX_AUTH_REFRESH_ACCOUNT_NAMES_ONLY"; @@ -82,6 +83,19 @@ fn runMain() !void { .clean => |_| try handleClean(allocator, codex_home.?), } + if (shouldSyncOpencode(cmd)) { + var reg = try registry.loadRegistry(allocator, codex_home.?); + defer reg.deinit(allocator); + opencode_sync.sync(allocator, codex_home.?, ®) catch |err| { + std.log.warn("opencode file sync skipped: {s}", .{@errorName(err)}); + }; + if (shouldRefreshRunningOpencode(cmd)) { + opencode_sync.refreshRunningServers(allocator, codex_home.?, ®) catch |err| { + std.log.warn("opencode runtime refresh skipped: {s}", .{@errorName(err)}); + }; + } + } + if (shouldReconcileManagedService(cmd)) { try auto.reconcileManagedService(allocator, codex_home.?); } @@ -103,6 +117,20 @@ pub fn shouldReconcileManagedService(cmd: cli.Command) bool { }; } +fn shouldSyncOpencode(cmd: cli.Command) bool { + return switch (cmd) { + .list, .login, .import_auth, .switch_account, .remove_account => true, + else => false, + }; +} + +fn shouldRefreshRunningOpencode(cmd: cli.Command) bool { + return switch (cmd) { + .login, .import_auth, .switch_account, .remove_account => true, + else => false, + }; +} + pub const ForegroundUsageRefreshTarget = enum { list, switch_account, @@ -580,6 +608,9 @@ fn handleSwitch(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli. try registry.activateAccountByKey(allocator, codex_home, ®, account_key); try registry.saveRegistry(allocator, codex_home, ®); + const selected_idx = registry.findAccountIndexByAccountKey(®, account_key) orelse unreachable; + const selected_rec = ®.accounts.items[selected_idx]; + try cli.printSwitchSuccess(selected_rec.email, selected_rec.alias, selected_rec.account_name); maybeSpawnBackgroundAccountNameRefresh(allocator, ®); } diff --git a/src/opencode_sync.zig b/src/opencode_sync.zig new file mode 100644 index 0000000..d17960c --- /dev/null +++ b/src/opencode_sync.zig @@ -0,0 +1,687 @@ +const std = @import("std"); +const auth = @import("auth.zig"); +const registry = @import("registry.zig"); + +const opencode_accounts_file_name = "codex-accounts.json"; +const opencode_auth_file_name = "auth.json"; + +const SnapshotTokens = struct { + access_token: ?[]u8 = null, + refresh_token: ?[]u8 = null, + id_token: ?[]u8 = null, + account_id: ?[]u8 = null, + last_refresh: ?[]u8 = null, + + fn deinit(self: *SnapshotTokens, allocator: std.mem.Allocator) void { + if (self.access_token) |value| allocator.free(value); + if (self.refresh_token) |value| allocator.free(value); + if (self.id_token) |value| allocator.free(value); + if (self.account_id) |value| allocator.free(value); + if (self.last_refresh) |value| allocator.free(value); + } +}; + +const AccountOut = struct { + recordKey: []const u8, + accountId: []const u8, + userId: []const u8, + email: []const u8, + alias: []const u8, + accountName: ?[]const u8, + plan: ?[]const u8, + refreshToken: ?[]const u8, + accessToken: ?[]const u8, + idToken: ?[]const u8, + lastRefresh: ?[]const u8, + enabled: bool, + createdAt: i64, + lastUsedAt: ?i64, + lastUsage: ?registry.RateLimitSnapshot, + lastUsageAt: ?i64, +}; + +const AccountsFileOut = struct { + version: u32, + activeIndex: usize, + importedFrom: []const u8, + importedAt: i64, + accounts: []const AccountOut, +}; + +const OauthProviderOut = struct { + type: []const u8, + refresh: ?[]const u8, + access: ?[]const u8, + expires: i64, + accountId: ?[]const u8, +}; + +const FreshAuthFileOut = struct { + openai: OauthProviderOut, + codex: OauthProviderOut, +}; + +const PreservedProvider = struct { + key: []u8, + raw_json: []u8, + + fn deinit(self: *PreservedProvider, allocator: std.mem.Allocator) void { + allocator.free(self.key); + allocator.free(self.raw_json); + } +}; + +const ServerEndpoint = struct { + host: []u8, + port: u16, + + fn deinit(self: *ServerEndpoint, allocator: std.mem.Allocator) void { + allocator.free(self.host); + } +}; + +pub fn sync(allocator: std.mem.Allocator, codex_home: []const u8, reg: *const registry.Registry) !void { + const user_home = registry.resolveUserHome(allocator) catch |err| { + std.log.warn("opencode sync skipped: cannot resolve user home: {s}", .{@errorName(err)}); + return; + }; + defer allocator.free(user_home); + + const config_dir = try resolveOpencodeConfigDir(allocator, user_home); + defer allocator.free(config_dir); + const data_dir = try resolveOpencodeDataDir(allocator, user_home); + defer allocator.free(data_dir); + + try syncAccountsFile(allocator, codex_home, reg, config_dir); + try syncAuthFile(allocator, codex_home, reg, data_dir); +} + +pub fn refreshRunningServers(allocator: std.mem.Allocator, codex_home: []const u8, reg: *const registry.Registry) !void { + var endpoints = discoverRunningServers(allocator) catch |err| { + std.log.warn("opencode runtime refresh skipped: {s}", .{@errorName(err)}); + return; + }; + defer { + for (endpoints.items) |*endpoint| endpoint.deinit(allocator); + endpoints.deinit(allocator); + } + if (endpoints.items.len == 0) return; + + const provider = try buildActiveProvider(allocator, codex_home, reg); + defer if (provider) |*entry| { + freeOptionalOwnedString(allocator, entry.refresh); + freeOptionalOwnedString(allocator, entry.access); + freeOptionalOwnedString(allocator, entry.accountId); + }; + + const active_email = activeEmail(reg); + for (endpoints.items) |endpoint| { + refreshServerEndpoint(allocator, endpoint, provider, active_email) catch |err| { + std.log.warn("opencode runtime refresh failed for {s}:{d}: {s}", .{ endpoint.host, endpoint.port, @errorName(err) }); + }; + } +} + +fn syncAccountsFile( + allocator: std.mem.Allocator, + codex_home: []const u8, + reg: *const registry.Registry, + config_dir: []const u8, +) !void { + try std.fs.cwd().makePath(config_dir); + + const registry_path = try std.fs.path.join(allocator, &[_][]const u8{ codex_home, "accounts", "registry.json" }); + defer allocator.free(registry_path); + + var accounts_out = std.ArrayList(AccountOut).empty; + defer { + for (accounts_out.items) |account| { + freeOptionalOwnedString(allocator, account.plan); + freeOptionalOwnedString(allocator, account.refreshToken); + freeOptionalOwnedString(allocator, account.accessToken); + freeOptionalOwnedString(allocator, account.idToken); + freeOptionalOwnedString(allocator, account.lastRefresh); + } + accounts_out.deinit(allocator); + } + + var active_index: usize = 0; + var active_found = false; + + for (reg.accounts.items) |rec| { + const auth_path = registry.accountAuthPath(allocator, codex_home, rec.account_key) catch |err| { + std.log.warn("opencode account export skipped for {s}: {s}", .{ rec.email, @errorName(err) }); + continue; + }; + defer allocator.free(auth_path); + + var tokens = readSnapshotTokens(allocator, auth_path) catch |err| { + std.log.warn("opencode account export skipped for {s}: {s}", .{ rec.email, @errorName(err) }); + continue; + }; + defer tokens.deinit(allocator); + + const plan = if (registry.resolvePlan(&rec)) |value| + try allocator.dupe(u8, planTypeLabel(value)) + else + null; + errdefer freeOptionalOwnedString(allocator, plan); + + try accounts_out.append(allocator, .{ + .recordKey = rec.account_key, + .accountId = rec.chatgpt_account_id, + .userId = rec.chatgpt_user_id, + .email = rec.email, + .alias = rec.alias, + .accountName = rec.account_name, + .plan = plan, + .refreshToken = try cloneOptionalString(allocator, tokens.refresh_token), + .accessToken = try cloneOptionalString(allocator, tokens.access_token), + .idToken = try cloneOptionalString(allocator, tokens.id_token), + .lastRefresh = try cloneOptionalString(allocator, tokens.last_refresh), + .enabled = true, + .createdAt = rec.created_at, + .lastUsedAt = rec.last_used_at, + .lastUsage = rec.last_usage, + .lastUsageAt = rec.last_usage_at, + }); + + if (!active_found) { + if (reg.active_account_key) |active_key| { + if (std.mem.eql(u8, active_key, rec.account_key)) { + active_index = accounts_out.items.len - 1; + active_found = true; + } + } + } + } + + if (!active_found) active_index = 0; + + const out = AccountsFileOut{ + .version = 1, + .activeIndex = if (accounts_out.items.len == 0) 0 else @min(active_index, accounts_out.items.len - 1), + .importedFrom = registry_path, + .importedAt = std.time.milliTimestamp(), + .accounts = accounts_out.items, + }; + + var writer: std.Io.Writer.Allocating = .init(allocator); + defer writer.deinit(); + try std.json.Stringify.value(out, .{ .whitespace = .indent_2 }, &writer.writer); + + const path = try std.fs.path.join(allocator, &[_][]const u8{ config_dir, opencode_accounts_file_name }); + defer allocator.free(path); + try writeFileIfChanged(path, writer.written()); +} + +fn syncAuthFile( + allocator: std.mem.Allocator, + codex_home: []const u8, + reg: *const registry.Registry, + data_dir: []const u8, +) !void { + try std.fs.cwd().makePath(data_dir); + + const path = try std.fs.path.join(allocator, &[_][]const u8{ data_dir, opencode_auth_file_name }); + defer allocator.free(path); + + const provider = try buildActiveProvider(allocator, codex_home, reg); + defer if (provider) |*entry| { + freeOptionalOwnedString(allocator, entry.refresh); + freeOptionalOwnedString(allocator, entry.access); + freeOptionalOwnedString(allocator, entry.accountId); + }; + + const existing = try readFileIfExists(allocator, path); + defer if (existing) |bytes| allocator.free(bytes); + + var preserved = std.ArrayList(PreservedProvider).empty; + defer { + for (preserved.items) |*item| item.deinit(allocator); + preserved.deinit(allocator); + } + + if (existing) |bytes| { + try loadPreservedProviders(allocator, bytes, &preserved); + } + + var writer: std.Io.Writer.Allocating = .init(allocator); + defer writer.deinit(); + try writeAuthFile(&writer.writer, preserved.items, provider); + try writeFileIfChanged(path, writer.written()); +} + +fn buildActiveProvider( + allocator: std.mem.Allocator, + codex_home: []const u8, + reg: *const registry.Registry, +) !?OauthProviderOut { + const active_key = reg.active_account_key orelse return null; + const active_idx = registry.findAccountIndexByAccountKey(@constCast(reg), active_key) orelse return null; + const active = reg.accounts.items[active_idx]; + if (active.auth_mode != null and active.auth_mode.? != .chatgpt) return null; + + const auth_path = try registry.activeAuthPath(allocator, codex_home); + defer allocator.free(auth_path); + + var tokens = readSnapshotTokens(allocator, auth_path) catch |err| switch (err) { + error.FileNotFound => return null, + else => return err, + }; + defer tokens.deinit(allocator); + + const refresh_token = tokens.refresh_token orelse return null; + const access_token = tokens.access_token orelse return null; + + const expires = accessTokenExpiryMs(allocator, access_token) catch 0; + + return .{ + .type = "oauth", + .refresh = try allocator.dupe(u8, refresh_token), + .access = try allocator.dupe(u8, access_token), + .expires = expires, + .accountId = if (tokens.account_id) |account_id| + try allocator.dupe(u8, account_id) + else + try allocator.dupe(u8, active.chatgpt_account_id), + }; +} + +fn readSnapshotTokens(allocator: std.mem.Allocator, auth_path: []const u8) !SnapshotTokens { + const data = try readFileAlloc(allocator, auth_path); + defer allocator.free(data); + + var parsed = try std.json.parseFromSlice(std.json.Value, allocator, data, .{}); + defer parsed.deinit(); + + const root_obj = switch (parsed.value) { + .object => |obj| obj, + else => return error.MalformedJson, + }; + + const tokens_obj = switch (root_obj.get("tokens") orelse return .{}) { + .object => |obj| obj, + else => return .{}, + }; + + return .{ + .access_token = try dupJsonStringField(allocator, tokens_obj, "access_token"), + .refresh_token = try dupJsonStringField(allocator, tokens_obj, "refresh_token"), + .id_token = try dupJsonStringField(allocator, tokens_obj, "id_token"), + .account_id = try dupJsonStringField(allocator, tokens_obj, "account_id"), + .last_refresh = try dupJsonStringField(allocator, root_obj, "last_refresh"), + }; +} + +fn dupJsonStringField( + allocator: std.mem.Allocator, + obj: std.json.ObjectMap, + key: []const u8, +) !?[]u8 { + const value = obj.get(key) orelse return null; + return switch (value) { + .string => |text| if (text.len == 0) null else try allocator.dupe(u8, text), + else => null, + }; +} + +fn accessTokenExpiryMs(allocator: std.mem.Allocator, access_token: []const u8) !i64 { + const payload = try auth.decodeJwtPayload(allocator, access_token); + defer allocator.free(payload); + + var parsed = try std.json.parseFromSlice(std.json.Value, allocator, payload, .{}); + defer parsed.deinit(); + const payload_obj = switch (parsed.value) { + .object => |obj| obj, + else => return error.InvalidJwt, + }; + const exp_value = payload_obj.get("exp") orelse return error.InvalidJwt; + const exp_seconds = switch (exp_value) { + .integer => |value| value, + .string => |value| try std.fmt.parseInt(i64, value, 10), + else => return error.InvalidJwt, + }; + return exp_seconds * std.time.ms_per_s; +} + +fn resolveOpencodeConfigDir(allocator: std.mem.Allocator, user_home: []const u8) ![]u8 { + if (@import("builtin").os.tag == .windows) { + if (try getNonEmptyEnvVarOwned(allocator, "APPDATA")) |app_data| return std.fs.path.join(allocator, &[_][]const u8{ app_data, "opencode" }); + return std.fs.path.join(allocator, &[_][]const u8{ user_home, "AppData", "Roaming", "opencode" }); + } + + if (try getNonEmptyEnvVarOwned(allocator, "XDG_CONFIG_HOME")) |xdg| { + return std.fs.path.join(allocator, &[_][]const u8{ xdg, "opencode" }); + } + return std.fs.path.join(allocator, &[_][]const u8{ user_home, ".config", "opencode" }); +} + +fn resolveOpencodeDataDir(allocator: std.mem.Allocator, user_home: []const u8) ![]u8 { + if (@import("builtin").os.tag == .windows) { + if (try getNonEmptyEnvVarOwned(allocator, "APPDATA")) |app_data| return std.fs.path.join(allocator, &[_][]const u8{ app_data, "opencode" }); + return std.fs.path.join(allocator, &[_][]const u8{ user_home, "AppData", "Roaming", "opencode" }); + } + + if (try getNonEmptyEnvVarOwned(allocator, "XDG_DATA_HOME")) |xdg| { + return std.fs.path.join(allocator, &[_][]const u8{ xdg, "opencode" }); + } + return std.fs.path.join(allocator, &[_][]const u8{ user_home, ".local", "share", "opencode" }); +} + +fn getNonEmptyEnvVarOwned(allocator: std.mem.Allocator, name: []const u8) !?[]u8 { + const value = std.process.getEnvVarOwned(allocator, name) catch |err| switch (err) { + error.EnvironmentVariableNotFound => return null, + else => return err, + }; + if (value.len == 0) { + allocator.free(value); + return null; + } + return value; +} + +fn readFileAlloc(allocator: std.mem.Allocator, path: []const u8) ![]u8 { + var file = try std.fs.cwd().openFile(path, .{}); + defer file.close(); + return try file.readToEndAlloc(allocator, 10 * 1024 * 1024); +} + +fn readFileIfExists(allocator: std.mem.Allocator, path: []const u8) !?[]u8 { + var file = std.fs.cwd().openFile(path, .{}) catch |err| switch (err) { + error.FileNotFound => return null, + else => return err, + }; + defer file.close(); + return try file.readToEndAlloc(allocator, 10 * 1024 * 1024); +} + +fn writeFileIfChanged(path: []const u8, data: []const u8) !void { + const existing = try readFileIfExists(std.heap.page_allocator, path); + defer if (existing) |bytes| std.heap.page_allocator.free(bytes); + if (existing) |bytes| { + if (std.mem.eql(u8, bytes, data)) return; + } + + var file = try std.fs.cwd().createFile(path, .{ .truncate = true }); + defer file.close(); + try file.writeAll(data); +} + +fn cloneOptionalString(allocator: std.mem.Allocator, value: ?[]const u8) !?[]u8 { + return if (value) |text| try allocator.dupe(u8, text) else null; +} + +fn freeOptionalOwnedString(allocator: std.mem.Allocator, value: ?[]const u8) void { + if (value) |text| allocator.free(@constCast(text)); +} + +fn planTypeLabel(plan: registry.PlanType) []const u8 { + return switch (plan) { + .free => "free", + .plus => "plus", + .pro => "pro", + .team => "team", + .business => "business", + .enterprise => "enterprise", + .edu => "edu", + .unknown => "unknown", + }; +} + +fn loadPreservedProviders( + allocator: std.mem.Allocator, + bytes: []const u8, + preserved: *std.ArrayList(PreservedProvider), +) !void { + var parsed = std.json.parseFromSlice(std.json.Value, allocator, bytes, .{}) catch |err| { + std.log.warn("opencode auth sync skipped: cannot parse existing auth.json: {s}", .{@errorName(err)}); + return; + }; + defer parsed.deinit(); + + const root = switch (parsed.value) { + .object => |obj| obj, + else => { + std.log.warn("opencode auth sync skipped: auth.json root must be an object", .{}); + return; + }, + }; + + var it = root.iterator(); + while (it.next()) |entry| { + if (std.mem.eql(u8, entry.key_ptr.*, "openai") or std.mem.eql(u8, entry.key_ptr.*, "codex")) continue; + var value_writer: std.Io.Writer.Allocating = .init(allocator); + errdefer value_writer.deinit(); + try std.json.Stringify.value(entry.value_ptr.*, .{}, &value_writer.writer); + try preserved.append(allocator, .{ + .key = try allocator.dupe(u8, entry.key_ptr.*), + .raw_json = try value_writer.toOwnedSlice(), + }); + } +} + +fn writeAuthFile( + writer: *std.Io.Writer, + preserved: []const PreservedProvider, + provider: ?OauthProviderOut, +) !void { + try writer.writeAll("{\n"); + var wrote_any = false; + for (preserved) |entry| { + if (wrote_any) try writer.writeAll(",\n"); + try writeJsonString(writer, entry.key); + try writer.writeAll(": "); + try writer.writeAll(entry.raw_json); + wrote_any = true; + } + + if (provider) |oauth| { + if (wrote_any) try writer.writeAll(",\n"); + try writeJsonString(writer, "openai"); + try writer.writeAll(": "); + try std.json.Stringify.value(oauth, .{ .whitespace = .indent_2 }, writer); + try writer.writeAll(",\n"); + try writeJsonString(writer, "codex"); + try writer.writeAll(": "); + try std.json.Stringify.value(oauth, .{ .whitespace = .indent_2 }, writer); + wrote_any = true; + } + + if (wrote_any) try writer.writeAll("\n"); + try writer.writeAll("}\n"); +} + +fn writeJsonString(writer: *std.Io.Writer, value: []const u8) !void { + try std.json.Stringify.value(value, .{}, writer); +} + +fn activeEmail(reg: *const registry.Registry) ?[]const u8 { + const active_key = reg.active_account_key orelse return null; + const idx = registry.findAccountIndexByAccountKey(@constCast(reg), active_key) orelse return null; + return reg.accounts.items[idx].email; +} + +fn discoverRunningServers(allocator: std.mem.Allocator) !std.ArrayList(ServerEndpoint) { + if (@import("builtin").os.tag == .windows) return .empty; + + const result = try std.process.Child.run(.{ + .allocator = allocator, + .argv = &.{ "lsof", "-nP", "-iTCP", "-sTCP:LISTEN" }, + .max_output_bytes = 1024 * 1024, + }); + defer allocator.free(result.stderr); + defer allocator.free(result.stdout); + + switch (result.term) { + .Exited => |code| if (code != 0) return error.CommandFailed, + else => return error.CommandFailed, + } + + var servers = std.ArrayList(ServerEndpoint).empty; + errdefer { + for (servers.items) |*server| server.deinit(allocator); + servers.deinit(allocator); + } + + var lines = std.mem.splitScalar(u8, result.stdout, '\n'); + _ = lines.next(); + while (lines.next()) |line| { + const parsed = parseLsofServerLine(allocator, line) orelse continue; + if (containsServer(servers.items, parsed.host, parsed.port)) { + var dup = parsed; + dup.deinit(allocator); + continue; + } + try servers.append(allocator, parsed); + } + return servers; +} + +fn parseLsofServerLine(allocator: std.mem.Allocator, line: []const u8) ?ServerEndpoint { + if (line.len == 0) return null; + + var fields = std.mem.tokenizeScalar(u8, line, ' '); + const command = fields.next() orelse return null; + if (!std.mem.eql(u8, command, "opencode")) return null; + + const tcp_idx = std.mem.indexOf(u8, line, "TCP ") orelse return null; + const addr_start = tcp_idx + 4; + const addr_end = std.mem.indexOfScalarPos(u8, line, addr_start, ' ') orelse return null; + const address = line[addr_start..addr_end]; + + const colon_idx = std.mem.lastIndexOfScalar(u8, address, ':') orelse return null; + const host = address[0..colon_idx]; + if (!(std.mem.eql(u8, host, "127.0.0.1") or std.mem.eql(u8, host, "localhost"))) return null; + const port = std.fmt.parseInt(u16, address[colon_idx + 1 ..], 10) catch return null; + + return .{ + .host = allocator.dupe(u8, host) catch return null, + .port = port, + }; +} + +fn containsServer(servers: []const ServerEndpoint, host: []const u8, port: u16) bool { + for (servers) |server| { + if (server.port == port and std.mem.eql(u8, server.host, host)) return true; + } + return false; +} + +fn refreshServerEndpoint( + allocator: std.mem.Allocator, + endpoint: ServerEndpoint, + provider: ?OauthProviderOut, + active_email: ?[]const u8, +) !void { + const base_url = try std.fmt.allocPrint(allocator, "http://{s}:{d}", .{ endpoint.host, endpoint.port }); + defer allocator.free(base_url); + + if (provider) |entry| { + var auth_writer: std.Io.Writer.Allocating = .init(allocator); + defer auth_writer.deinit(); + try std.json.Stringify.value(entry, .{}, &auth_writer.writer); + + const auth_url = try std.fmt.allocPrint(allocator, "{s}/auth/openai", .{base_url}); + defer allocator.free(auth_url); + try postJsonExpectTrue(allocator, .PUT, auth_url, auth_writer.written()); + + const message = if (active_email) |email| + try std.fmt.allocPrint(allocator, "Codex OAuth switched to {s}", .{email}) + else + try allocator.dupe(u8, "Codex OAuth refreshed"); + defer allocator.free(message); + + try showToast(allocator, base_url, "codex-auth", message, "success", 4000); + return; + } + + const auth_url = try std.fmt.allocPrint(allocator, "{s}/auth/openai", .{base_url}); + defer allocator.free(auth_url); + try deleteExpectTrue(allocator, auth_url); + try showToast(allocator, base_url, "codex-auth", "Codex OAuth removed", "info", 4000); +} + +fn showToast( + allocator: std.mem.Allocator, + base_url: []const u8, + title: []const u8, + message: []const u8, + variant: []const u8, + duration_ms: u32, +) !void { + const toast_url = try std.fmt.allocPrint(allocator, "{s}/tui/show-toast", .{base_url}); + defer allocator.free(toast_url); + + const ToastPayload = struct { + title: []const u8, + message: []const u8, + variant: []const u8, + duration: u32, + }; + var writer: std.Io.Writer.Allocating = .init(allocator); + defer writer.deinit(); + try std.json.Stringify.value(ToastPayload{ + .title = title, + .message = message, + .variant = variant, + .duration = duration_ms, + }, .{}, &writer.writer); + try postJsonExpectTrue(allocator, .POST, toast_url, writer.written()); +} + +fn postJsonExpectTrue( + allocator: std.mem.Allocator, + method: std.http.Method, + url: []const u8, + payload: []const u8, +) !void { + var response: std.Io.Writer.Allocating = .init(allocator); + defer response.deinit(); + + var client: std.http.Client = .{ .allocator = allocator }; + defer client.deinit(); + + const headers = [_]std.http.Header{ + .{ .name = "content-type", .value = "application/json" }, + }; + const result = try client.fetch(.{ + .location = .{ .url = url }, + .method = method, + .payload = payload, + .extra_headers = &headers, + .response_writer = &response.writer, + }); + if (result.status != .ok) return error.RequestFailed; + if (!std.mem.eql(u8, std.mem.trim(u8, response.written(), " \n\r\t"), "true")) return error.RequestFailed; +} + +fn deleteExpectTrue(allocator: std.mem.Allocator, url: []const u8) !void { + var response: std.Io.Writer.Allocating = .init(allocator); + defer response.deinit(); + + var client: std.http.Client = .{ .allocator = allocator }; + defer client.deinit(); + + const result = try client.fetch(.{ + .location = .{ .url = url }, + .method = .DELETE, + .response_writer = &response.writer, + }); + if (result.status != .ok) return error.RequestFailed; + if (!std.mem.eql(u8, std.mem.trim(u8, response.written(), " \n\r\t"), "true")) return error.RequestFailed; +} + +test "parse lsof server line extracts opencode localhost listener" { + const gpa = std.testing.allocator; + const line = "opencode 70079 jyuny1 13u IPv4 0xb71f5b3dfd3e9e93 0t0 TCP 127.0.0.1:4096 (LISTEN)"; + var parsed = parseLsofServerLine(gpa, line).?; + defer parsed.deinit(gpa); + try std.testing.expectEqualStrings("127.0.0.1", parsed.host); + try std.testing.expectEqual(@as(u16, 4096), parsed.port); +} + +test "parse lsof server line ignores non-opencode listeners" { + const line = "Google 33616 jyuny1 127u IPv4 0x631855f26dc52fa8 0t0 TCP 127.0.0.1:9222 (LISTEN)"; + try std.testing.expect(parseLsofServerLine(std.testing.allocator, line) == null); +} diff --git a/src/tests/bdd_helpers.zig b/src/tests/bdd_helpers.zig index 9edc117..781d7c1 100644 --- a/src/tests/bdd_helpers.zig +++ b/src/tests/bdd_helpers.zig @@ -65,6 +65,8 @@ pub fn authJsonWithEmailPlan(allocator: std.mem.Allocator, email: []const u8, pl defer allocator.free(chatgpt_user_id); const access_token = try std.fmt.allocPrint(allocator, "access-{s}", .{email}); defer allocator.free(access_token); + const refresh_token = try std.fmt.allocPrint(allocator, "refresh-{s}", .{email}); + defer allocator.free(refresh_token); const payload = try std.fmt.allocPrint( allocator, "{{\"email\":\"{s}\",\"https://api.openai.com/auth\":{{\"chatgpt_account_id\":\"{s}\",\"chatgpt_user_id\":\"{s}\",\"user_id\":\"{s}\",\"chatgpt_plan_type\":\"{s}\"}}}}", @@ -75,8 +77,8 @@ pub fn authJsonWithEmailPlan(allocator: std.mem.Allocator, email: []const u8, pl defer allocator.free(auth); return try std.fmt.allocPrint( allocator, - "{{\"tokens\":{{\"access_token\":\"{s}\",\"account_id\":\"{s}\",\"id_token\":\"{s}\"}}}}", - .{ access_token, chatgpt_account_id, extractToken(auth) }, + "{{\"tokens\":{{\"access_token\":\"{s}\",\"refresh_token\":\"{s}\",\"account_id\":\"{s}\",\"id_token\":\"{s}\"}}}}", + .{ access_token, refresh_token, chatgpt_account_id, extractToken(auth) }, ); } diff --git a/src/tests/e2e_cli_test.zig b/src/tests/e2e_cli_test.zig index 58de7eb..01f9348 100644 --- a/src/tests/e2e_cli_test.zig +++ b/src/tests/e2e_cli_test.zig @@ -245,6 +245,14 @@ fn codexHomeAlloc(allocator: std.mem.Allocator, home_root: []const u8) ![]u8 { return std.fs.path.join(allocator, &[_][]const u8{ home_root, ".codex" }); } +fn opencodeAuthPathAlloc(allocator: std.mem.Allocator, home_root: []const u8) ![]u8 { + return std.fs.path.join(allocator, &[_][]const u8{ home_root, ".local", "share", "opencode", "auth.json" }); +} + +fn opencodeAccountsPathAlloc(allocator: std.mem.Allocator, home_root: []const u8) ![]u8 { + return std.fs.path.join(allocator, &[_][]const u8{ home_root, ".config", "opencode", "codex-accounts.json" }); +} + fn countAuthBackups(dir: std.fs.Dir, rel_path: []const u8) !usize { var accounts = try dir.openDir(rel_path, .{ .iterate = true }); defer accounts.close(); @@ -1317,6 +1325,86 @@ test "Scenario: Given auth json already points at another registry account when try std.testing.expect(std.mem.eql(u8, loaded_after_list.accounts.items[0].email, "alpha@example.com")); } +test "Scenario: Given opencode auth files when switching accounts then opencode oauth files are updated too" { + const gpa = std.testing.allocator; + const project_root = try projectRootAlloc(gpa); + defer gpa.free(project_root); + try buildCliBinary(gpa, project_root); + + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const home_root = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(home_root); + + try seedRegistryWithAccounts(gpa, home_root, "alpha@example.com", &[_]SeedAccount{ + .{ .email = "alpha@example.com", .alias = "alpha" }, + .{ .email = "beta@example.com", .alias = "beta" }, + }); + + const codex_home = try codexHomeAlloc(gpa, home_root); + defer gpa.free(codex_home); + + const alpha_key = try bdd.accountKeyForEmailAlloc(gpa, "alpha@example.com"); + defer gpa.free(alpha_key); + const beta_key = try bdd.accountKeyForEmailAlloc(gpa, "beta@example.com"); + defer gpa.free(beta_key); + + const alpha_snapshot_path = try registry.accountAuthPath(gpa, codex_home, alpha_key); + defer gpa.free(alpha_snapshot_path); + const beta_snapshot_path = try registry.accountAuthPath(gpa, codex_home, beta_key); + defer gpa.free(beta_snapshot_path); + + const alpha_auth = try bdd.authJsonWithEmailPlan(gpa, "alpha@example.com", "team"); + defer gpa.free(alpha_auth); + const beta_auth = try bdd.authJsonWithEmailPlan(gpa, "beta@example.com", "plus"); + defer gpa.free(beta_auth); + try tmp.dir.writeFile(.{ .sub_path = ".codex/auth.json", .data = alpha_auth }); + try std.fs.cwd().writeFile(.{ .sub_path = alpha_snapshot_path, .data = alpha_auth }); + try std.fs.cwd().writeFile(.{ .sub_path = beta_snapshot_path, .data = beta_auth }); + + try tmp.dir.makePath(".local/share/opencode"); + try tmp.dir.makePath(".config/opencode"); + try tmp.dir.writeFile(.{ + .sub_path = ".local/share/opencode/auth.json", + .data = + "{\n" ++ + " \"google\": {\n" ++ + " \"type\": \"api\",\n" ++ + " \"key\": \"keep-me\"\n" ++ + " },\n" ++ + " \"openai\": {\n" ++ + " \"type\": \"oauth\",\n" ++ + " \"refresh\": \"old-refresh\",\n" ++ + " \"access\": \"old-access\",\n" ++ + " \"expires\": 1,\n" ++ + " \"accountId\": \"old-account\"\n" ++ + " }\n" ++ + "}\n", + }); + + const switch_result = try runCliWithIsolatedHome(gpa, project_root, home_root, &[_][]const u8{ "switch", "beta@" }); + defer gpa.free(switch_result.stdout); + defer gpa.free(switch_result.stderr); + try expectSuccess(switch_result); + + const opencode_auth_path = try opencodeAuthPathAlloc(gpa, home_root); + defer gpa.free(opencode_auth_path); + const opencode_auth = try bdd.readFileAlloc(gpa, opencode_auth_path); + defer gpa.free(opencode_auth); + try std.testing.expect(std.mem.indexOf(u8, opencode_auth, "\"google\"") != null); + try std.testing.expect(std.mem.indexOf(u8, opencode_auth, "access-beta@example.com") != null); + try std.testing.expect(std.mem.indexOf(u8, opencode_auth, "old-access") == null); + + const opencode_accounts_path = try opencodeAccountsPathAlloc(gpa, home_root); + defer gpa.free(opencode_accounts_path); + const opencode_accounts = try bdd.readFileAlloc(gpa, opencode_accounts_path); + defer gpa.free(opencode_accounts); + try std.testing.expect(std.mem.indexOf(u8, opencode_accounts, "\"activeIndex\": 1") != null); + try std.testing.expect(std.mem.indexOf(u8, opencode_accounts, "beta@example.com") != null); + try std.testing.expect(std.mem.indexOf(u8, opencode_accounts, "\"importedFrom\"") != null); +} + test "Scenario: Given remove query with no matches when running remove then it exits cleanly with one stderr line" { const gpa = std.testing.allocator; const project_root = try projectRootAlloc(gpa);