diff --git a/src/agents/pi-embedded-runner.ts b/src/agents/pi-embedded-runner.ts index d27ce08ef..618bfe85a 100644 --- a/src/agents/pi-embedded-runner.ts +++ b/src/agents/pi-embedded-runner.ts @@ -25,6 +25,7 @@ import { import type { ThinkLevel, VerboseLevel } from "../auto-reply/thinking.js"; import { formatToolAggregate } from "../auto-reply/tool-meta.js"; import type { ClawdbotConfig } from "../config/config.js"; +import { resolveOAuthPath } from "../config/paths.js"; import { getMachineDisplayName } from "../infra/machine-name.js"; import { createSubsystemLogger } from "../logging.js"; import { splitMediaFromOutput } from "../media/parse.js"; @@ -32,7 +33,7 @@ import { type enqueueCommand, enqueueCommandInLane, } from "../process/command-queue.js"; -import { CONFIG_DIR, resolveUserPath } from "../utils.js"; +import { resolveUserPath } from "../utils.js"; import { resolveClawdbotAgentDir } from "./agent-paths.js"; import type { BashElevatedDefaults } from "./bash-tools.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js"; @@ -106,7 +107,6 @@ type EmbeddedRunWaiter = { const EMBEDDED_RUN_WAITERS = new Map>(); const OAUTH_FILENAME = "oauth.json"; -const DEFAULT_OAUTH_DIR = path.join(CONFIG_DIR, "credentials"); let oauthStorageConfigured = false; type OAuthStorage = Record; @@ -140,9 +140,7 @@ export function buildEmbeddedSandboxInfo( } function resolveClawdbotOAuthPath(): string { - const overrideDir = - process.env.CLAWDBOT_OAUTH_DIR?.trim() || DEFAULT_OAUTH_DIR; - return path.join(resolveUserPath(overrideDir), OAUTH_FILENAME); + return resolveOAuthPath(); } function loadOAuthStorageAt(pathname: string): OAuthStorage | null { diff --git a/src/commands/onboard-auth.test.ts b/src/commands/onboard-auth.test.ts new file mode 100644 index 000000000..4187d1961 --- /dev/null +++ b/src/commands/onboard-auth.test.ts @@ -0,0 +1,52 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import type { OAuthCredentials } from "@mariozechner/pi-ai"; +import { afterEach, describe, expect, it } from "vitest"; + +import { resolveOAuthPath } from "../config/paths.js"; +import { writeOAuthCredentials } from "./onboard-auth.js"; + +describe("writeOAuthCredentials", () => { + const previousStateDir = process.env.CLAWDBOT_STATE_DIR; + let tempStateDir: string | null = null; + + afterEach(async () => { + if (tempStateDir) { + await fs.rm(tempStateDir, { recursive: true, force: true }); + tempStateDir = null; + } + if (previousStateDir === undefined) { + delete process.env.CLAWDBOT_STATE_DIR; + } else { + process.env.CLAWDBOT_STATE_DIR = previousStateDir; + } + delete process.env.CLAWDBOT_OAUTH_DIR; + }); + + it("writes oauth.json under CLAWDBOT_STATE_DIR/credentials", async () => { + tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-oauth-")); + process.env.CLAWDBOT_STATE_DIR = tempStateDir; + + const creds = { + refresh: "refresh-token", + access: "access-token", + expires: Date.now() + 60_000, + } satisfies OAuthCredentials; + + await writeOAuthCredentials("anthropic", creds); + + const oauthPath = resolveOAuthPath(); + expect(oauthPath).toBe( + path.join(tempStateDir, "credentials", "oauth.json"), + ); + + const raw = await fs.readFile(oauthPath, "utf8"); + const parsed = JSON.parse(raw) as Record; + expect(parsed.anthropic).toMatchObject({ + refresh: "refresh-token", + access: "access-token", + }); + }); +}); diff --git a/src/commands/onboard-auth.ts b/src/commands/onboard-auth.ts index 724b1d642..16b076a3d 100644 --- a/src/commands/onboard-auth.ts +++ b/src/commands/onboard-auth.ts @@ -6,15 +6,15 @@ import { discoverAuthStorage } from "@mariozechner/pi-coding-agent"; import { resolveClawdbotAgentDir } from "../agents/agent-paths.js"; import type { ClawdbotConfig } from "../config/config.js"; -import { CONFIG_DIR } from "../utils.js"; +import { resolveOAuthPath } from "../config/paths.js"; export async function writeOAuthCredentials( provider: OAuthProvider, creds: OAuthCredentials, ): Promise { - const dir = path.join(CONFIG_DIR, "credentials"); + const filePath = resolveOAuthPath(); + const dir = path.dirname(filePath); await fs.mkdir(dir, { recursive: true, mode: 0o700 }); - const filePath = path.join(dir, "oauth.json"); let storage: Record = {}; try { const raw = await fs.readFile(filePath, "utf8"); diff --git a/src/config/paths.test.ts b/src/config/paths.test.ts new file mode 100644 index 000000000..fd40ce3d6 --- /dev/null +++ b/src/config/paths.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; + +import { resolveOAuthDir, resolveOAuthPath } from "./paths.js"; + +describe("oauth paths", () => { + it("prefers CLAWDBOT_OAUTH_DIR over CLAWDBOT_STATE_DIR", () => { + const env = { + CLAWDBOT_OAUTH_DIR: "/custom/oauth", + CLAWDBOT_STATE_DIR: "/custom/state", + } as NodeJS.ProcessEnv; + + expect(resolveOAuthDir(env, "/custom/state")).toBe("/custom/oauth"); + expect(resolveOAuthPath(env, "/custom/state")).toBe( + "/custom/oauth/oauth.json", + ); + }); + + it("derives oauth path from CLAWDBOT_STATE_DIR when unset", () => { + const env = { + CLAWDBOT_STATE_DIR: "/custom/state", + } as NodeJS.ProcessEnv; + + expect(resolveOAuthDir(env, "/custom/state")).toBe( + "/custom/state/credentials", + ); + expect(resolveOAuthPath(env, "/custom/state")).toBe( + "/custom/state/credentials/oauth.json", + ); + }); +}); diff --git a/src/config/paths.ts b/src/config/paths.ts index e8c496664..81d092d9e 100644 --- a/src/config/paths.ts +++ b/src/config/paths.ts @@ -1,6 +1,6 @@ import os from "node:os"; import path from "node:path"; - +import { resolveUserPath } from "../utils.js"; import type { ClawdbotConfig } from "./types.js"; /** @@ -52,6 +52,32 @@ export const CONFIG_PATH_CLAWDBOT = resolveConfigPath(); export const DEFAULT_GATEWAY_PORT = 18789; +const OAUTH_FILENAME = "oauth.json"; + +/** + * OAuth credentials storage directory. + * + * Precedence: + * - `CLAWDBOT_OAUTH_DIR` (explicit override) + * - `CLAWDBOT_STATE_DIR/credentials` (canonical server/default) + * - `~/.clawdbot/credentials` (legacy default) + */ +export function resolveOAuthDir( + env: NodeJS.ProcessEnv = process.env, + stateDir: string = resolveStateDir(env, os.homedir), +): string { + const override = env.CLAWDBOT_OAUTH_DIR?.trim(); + if (override) return resolveUserPath(override); + return path.join(stateDir, "credentials"); +} + +export function resolveOAuthPath( + env: NodeJS.ProcessEnv = process.env, + stateDir: string = resolveStateDir(env, os.homedir), +): string { + return path.join(resolveOAuthDir(env, stateDir), OAUTH_FILENAME); +} + export function resolveGatewayPort( cfg?: ClawdbotConfig, env: NodeJS.ProcessEnv = process.env,