From ab0cac0b0194580ed8262ab6f8480061d804767c Mon Sep 17 00:00:00 2001 From: minhn4 Date: Sat, 11 Jul 2026 08:51:27 +0700 Subject: [PATCH] feat(gateway): prefer the hub's auth.json when present The CoDev Hub moved its auth file to ~/.codev-hub/auth.json with no fallback to the old shared ~/.codev/auth.json, breaking the shared- session contract. Resolve the auth dir per call: when the hub's auth.json exists, read AND write there (a silent refresh may rotate the refresh_token, so persisting it elsewhere would strand the hub on a dead session); otherwise fall back to ~/.codev as before. The force-login sentinel follows the same resolution. Also fixes a test bug: Bun's os.homedir() ignores runtime HOME changes, so the suite's HOME override silently read and wrote the real ~/.codev/auth.json. Home is now resolved per call via OPENCODE_TEST_HOME (core Global convention) and the tests set that instead. Co-Authored-By: Claude Fable 5 --- packages/codev-gateway/src/auth-file.ts | 37 +++++++++---- packages/codev-gateway/src/sso.ts | 3 +- packages/codev-gateway/test/auth-file.test.ts | 55 ++++++++++++++++--- 3 files changed, 76 insertions(+), 19 deletions(-) diff --git a/packages/codev-gateway/src/auth-file.ts b/packages/codev-gateway/src/auth-file.ts index d733c790eb..c1be6c4e1b 100644 --- a/packages/codev-gateway/src/auth-file.ts +++ b/packages/codev-gateway/src/auth-file.ts @@ -1,5 +1,5 @@ import { randomBytes } from "node:crypto" -import { chmodSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs" +import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs" import { homedir } from "node:os" import { dirname, join } from "node:path" @@ -27,12 +27,15 @@ export interface CodevConfig { gatewayUrl: string } -// ~/.codev/auth.json is SHARED with the codev hub: identical path and shape -// (hub src/lib/auth.ts AuthFileContents), so a hub installed later reuses the -// session this flow created and vice versa. Three independent blocks — SSO -// tokens, gateway API key, CoDev runtime config — each updated in isolation so -// saving one never clobbers the others. skillhub_cookie is hub-only; the -// read-merge-write pattern preserves it untouched. +// The auth file is SHARED with the codev hub: identical shape (hub +// src/lib/auth.ts AuthFileContents). The hub owns ~/.codev-hub/auth.json; when +// that file exists we read AND write it — writes must follow reads because a +// silent refresh may rotate the refresh_token, and persisting the rotated +// token anywhere else would strand the hub on a dead session. Without a hub +// file we fall back to our own ~/.codev/auth.json. Three independent blocks — +// SSO tokens, gateway API key, CoDev runtime config — each updated in +// isolation so saving one never clobbers the others. skillhub_cookie is +// hub-only; the read-merge-write pattern preserves it untouched. interface AuthFileContents { access_token?: string id_token?: string @@ -48,15 +51,29 @@ interface AuthFileContents { skillhub_cookie?: string } +// Keyed off the hub's auth.json rather than the ~/.codev-hub/ dir (mirroring +// the hub's own fresh-install check): unrelated code creates the directory, +// so only the file signals a hub session worth adopting. Home is resolved per +// call via OPENCODE_TEST_HOME (same convention as core's Global) because +// Bun's homedir() ignores runtime HOME changes, which tests rely on. +function authDir() { + const home = process.env.OPENCODE_TEST_HOME ?? homedir() + const hub = join(home, ".codev-hub") + if (existsSync(join(hub, "auth.json"))) return hub + return join(home, ".codev") +} + export function authFilePath() { - return join(homedir(), ".codev", "auth.json") + return join(authDir(), "auth.json") } // Written by the hub's `codevhub logout` (revoking tokens doesn't end the // IdP's browser session); honored here so a post-logout login re-prompts for -// credentials instead of silently riding the session cookie. +// credentials instead of silently riding the session cookie. Resolved against +// the same dir as auth.json so we honor the hub's sentinel when riding its +// session. export function forceLoginPath() { - return join(homedir(), ".codev", "force-login") + return join(authDir(), "force-login") } export function readAuthFile(): AuthFileContents | null { diff --git a/packages/codev-gateway/src/sso.ts b/packages/codev-gateway/src/sso.ts index 755b815bed..fb8a3b7cc7 100644 --- a/packages/codev-gateway/src/sso.ts +++ b/packages/codev-gateway/src/sso.ts @@ -41,7 +41,8 @@ export interface LoginOptions { /** * OAuth2 Authorization Code flow with PKCE against the CoDev SSO wrapper, * ported from the hub (src/lib/auth.ts) so both tools share one session in - * ~/.codev/auth.json: + * the resolved auth file (the hub's ~/.codev-hub/auth.json when present, else + * our own ~/.codev/auth.json): * 1. Reuse cached tokens if still valid * 2. Try a silent refresh if a refresh_token is on disk * 3. Otherwise: loopback HTTP server, /authorize with state + nonce + PKCE, diff --git a/packages/codev-gateway/test/auth-file.test.ts b/packages/codev-gateway/test/auth-file.test.ts index 954001b1b8..4e932d3ead 100644 --- a/packages/codev-gateway/test/auth-file.test.ts +++ b/packages/codev-gateway/test/auth-file.test.ts @@ -4,20 +4,19 @@ import { tmpdir } from "node:os" import { join } from "node:path" let tempDir: string -let originalHome: string | undefined -let originalProfile: string | undefined +let originalTestHome: string | undefined +// OPENCODE_TEST_HOME rather than HOME: Bun's homedir() ignores runtime HOME +// changes, so overriding HOME here would silently hit the real ~/.codev. beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), "codev-gateway-auth-")) - originalHome = process.env.HOME - originalProfile = process.env.USERPROFILE - process.env.HOME = tempDir - process.env.USERPROFILE = tempDir + originalTestHome = process.env.OPENCODE_TEST_HOME + process.env.OPENCODE_TEST_HOME = tempDir }) afterEach(() => { - process.env.HOME = originalHome - process.env.USERPROFILE = originalProfile + if (originalTestHome === undefined) delete process.env.OPENCODE_TEST_HOME + if (originalTestHome !== undefined) process.env.OPENCODE_TEST_HOME = originalTestHome rmSync(tempDir, { recursive: true, force: true }) }) @@ -51,6 +50,46 @@ describe("auth-file", () => { expect(statSync(authFilePath()).mode & 0o777).toBe(0o600) }) + test("prefers ~/.codev-hub only when its auth.json exists", async () => { + const { authFilePath, forceLoginPath } = await import("../src/auth-file") + const { mkdirSync, writeFileSync } = await import("node:fs") + expect(authFilePath()).toBe(join(tempDir, ".codev", "auth.json")) + + // A bare ~/.codev-hub dir (created by unrelated hub code) is not a session. + mkdirSync(join(tempDir, ".codev-hub"), { recursive: true }) + expect(authFilePath()).toBe(join(tempDir, ".codev", "auth.json")) + expect(forceLoginPath()).toBe(join(tempDir, ".codev", "force-login")) + + writeFileSync(join(tempDir, ".codev-hub", "auth.json"), "{}") + expect(authFilePath()).toBe(join(tempDir, ".codev-hub", "auth.json")) + expect(forceLoginPath()).toBe(join(tempDir, ".codev-hub", "force-login")) + }) + + test("reads a hub session and writes back into the hub file", async () => { + const { loadAuth, saveApiKey } = await import("../src/auth-file") + const { existsSync, mkdirSync, writeFileSync } = await import("node:fs") + mkdirSync(join(tempDir, ".codev-hub"), { recursive: true }) + writeFileSync( + join(tempDir, ".codev-hub", "auth.json"), + JSON.stringify({ + access_token: "hub-at", + id_token: "hub-it", + expires_at: Date.now() + 60_000, + user: { sub: "s", email: "hub@x", displayName: "Hub" }, + skillhub_cookie: "skill-hub-session=abc", + }), + ) + + expect(loadAuth()?.user.email).toBe("hub@x") + + saveApiKey({ apiKey: "sk-hub" }) + const raw = JSON.parse(readFileSync(join(tempDir, ".codev-hub", "auth.json"), "utf-8")) + expect(raw.api_key).toBe("sk-hub") + expect(raw.access_token).toBe("hub-at") + expect(raw.skillhub_cookie).toBe("skill-hub-session=abc") + expect(existsSync(join(tempDir, ".codev", "auth.json"))).toBe(false) + }) + test("loadAuth rejects expired or partial sessions", async () => { const { loadAuth, saveAuth } = await import("../src/auth-file") expect(loadAuth()).toBeNull()