From 17ceca86d698c104df48149ba85f8dfab3ea622c Mon Sep 17 00:00:00 2001 From: Pavan Kumar Gondhi Date: Mon, 11 May 2026 12:52:32 +0530 Subject: [PATCH] Redact persisted secret-shaped payloads [AI] (#79006) * fix: redact persisted secret-shaped payloads * docs: add changelog entry for PR merge --- CHANGELOG.md | 1 + ...th-profiles.ensureauthprofilestore.test.ts | 17 +- src/agents/auth-profiles/external-cli-sync.ts | 18 +- .../auth-profiles/external-oauth.test.ts | 41 +- .../oauth.adopt-identity.test.ts | 38 +- .../oauth.mirror-refresh.test.ts | 65 +- ...auth.openai-codex-refresh-fallback.test.ts | 80 +- src/agents/auth-profiles/persisted.ts | 736 ++++++++- src/agents/auth-profiles/profiles.test.ts | 1357 +++++++++++++++-- src/agents/auth-profiles/store.ts | 15 +- src/agents/auth-profiles/types.ts | 7 + src/commands/agents.add.test.ts | 63 + src/commands/agents.commands.add.ts | 17 +- src/commands/status-all/diagnosis.test.ts | 82 +- src/commands/status-all/diagnosis.ts | 11 +- src/config/io.audit.test.ts | 30 + src/config/io.audit.ts | 6 +- src/config/sessions/transcript-append.ts | 3 +- src/config/sessions/transcript.test.ts | 34 + src/daemon/diagnostics.test.ts | 35 + src/daemon/diagnostics.ts | 12 +- src/daemon/launchd.test.ts | 1 + src/daemon/launchd.ts | 9 +- src/daemon/runtime-hints.test.ts | 2 +- src/daemon/runtime-hints.ts | 2 +- .../runtime-hints.windows-paths.test.ts | 2 +- src/logging/diagnostic-support-redaction.ts | 4 +- src/logging/logger-redaction-behavior.test.ts | 53 + src/logging/logger.ts | 21 +- src/logging/redact.test.ts | 92 ++ src/logging/redact.ts | 129 +- src/trajectory/runtime.test.ts | 4 + src/trajectory/runtime.ts | 6 +- 33 files changed, 2775 insertions(+), 218 deletions(-) create mode 100644 src/daemon/diagnostics.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index aa3b5cb65c4..16024c9a3a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Redact persisted secret-shaped payloads [AI]. (#79006) Thanks @pgondhi987. - OpenAI Codex: surface browser OAuth and device-code login failures instead of treating failed logins as empty successful auth results. Refs #80363. - CLI agents: carry runtime-only current-turn sender/reply context into CLI model prompts while keeping prompt-build hook input and transcript text clean. - fix(matrix): gate name-based allowlist resolution [AI]. (#79007) Thanks @pgondhi987. diff --git a/src/agents/auth-profiles.ensureauthprofilestore.test.ts b/src/agents/auth-profiles.ensureauthprofilestore.test.ts index d2243c38f86..5951e3a90b4 100644 --- a/src/agents/auth-profiles.ensureauthprofilestore.test.ts +++ b/src/agents/auth-profiles.ensureauthprofilestore.test.ts @@ -816,14 +816,23 @@ describe("ensureAuthProfileStore", () => { const persisted = JSON.parse( fs.readFileSync(path.join(agentDir, "auth-profiles.json"), "utf8"), ) as { - profiles: Record; + profiles: Record>; }; - expectRecordFields(persisted.profiles["openai-codex:default"], { + const persistedProfile = persisted.profiles["openai-codex:default"]; + expect(persistedProfile).toMatchObject({ type: "oauth", provider: "openai-codex", - access: "access-token", - refresh: "refresh-token", + oauthRef: { + source: "openclaw-credentials", + provider: "openai-codex", + id: expect.any(String), + }, }); + expect(persistedProfile).not.toHaveProperty("access"); + expect(persistedProfile).not.toHaveProperty("refresh"); + expect(persistedProfile).not.toHaveProperty("idToken"); + expect(JSON.stringify(persisted)).not.toContain("access-token"); + expect(JSON.stringify(persisted)).not.toContain("refresh-token"); } finally { clearRuntimeAuthProfileStoreSnapshots(); restoreEnvValue("OPENCLAW_STATE_DIR", previousStateDir); diff --git a/src/agents/auth-profiles/external-cli-sync.ts b/src/agents/auth-profiles/external-cli-sync.ts index 0cdcadf467f..b8539bda6f0 100644 --- a/src/agents/auth-profiles/external-cli-sync.ts +++ b/src/agents/auth-profiles/external-cli-sync.ts @@ -145,6 +145,12 @@ function resolveExternalCliSyncProvider(params: { return provider; } +function hasInlineOAuthTokenMaterial(credential: OAuthCredential): boolean { + return [credential.access, credential.refresh, credential.idToken].some( + (value) => typeof value === "string" && value.trim().length > 0, + ); +} + export function readExternalCliBootstrapCredential(params: { profileId: string; credential: OAuthCredential; @@ -153,11 +159,7 @@ export function readExternalCliBootstrapCredential(params: { if (!provider) { return null; } - // bootstrapOnly providers must not replace an existing local credential - // during runtime refresh. The oauth-manager only calls this hook when a - // local credential is already present, so returning null here keeps the - // locally stored refresh token canonical. - if (provider.bootstrapOnly) { + if (provider.bootstrapOnly && hasInlineOAuthTokenMaterial(params.credential)) { return null; } return provider.readCredentials(); @@ -248,7 +250,11 @@ export function resolveExternalCliAuthProfiles( }); continue; } - if (providerConfig.bootstrapOnly && existingOAuth) { + if ( + providerConfig.bootstrapOnly && + existingOAuth && + hasInlineOAuthTokenMaterial(existingOAuth) + ) { log.debug("kept local oauth over external cli bootstrap-only provider", { profileId: providerConfig.profileId, provider: providerConfig.provider, diff --git a/src/agents/auth-profiles/external-oauth.test.ts b/src/agents/auth-profiles/external-oauth.test.ts index 2a772361b33..93d1b140424 100644 --- a/src/agents/auth-profiles/external-oauth.test.ts +++ b/src/agents/auth-profiles/external-oauth.test.ts @@ -5,6 +5,7 @@ import { overlayExternalOAuthProfiles, shouldPersistExternalOAuthProfile, } from "./external-auth.js"; +import { readManagedExternalCliCredential } from "./external-cli-sync.js"; import type { AuthProfileStore, OAuthCredential } from "./types.js"; const resolveExternalAuthProfilesWithPluginsMock = vi.fn< @@ -158,7 +159,7 @@ describe("auth external oauth helpers", () => { expect(shouldPersist).toBe(true); }); - it("does not use Codex CLI OAuth as a runtime overlay source", () => { + it("keeps Codex CLI OAuth from replacing stored inline token material", () => { readCodexCliCredentialsCachedMock.mockReturnValue( createCredential({ access: "fresh-cli-access-token", @@ -185,6 +186,44 @@ describe("auth external oauth helpers", () => { expect(profile.accountId).toBe("acct-cli"); }); + it("uses Codex CLI OAuth when the stored Codex profile has no inline token material", () => { + const cliCredential = createCredential({ + access: "fresh-cli-access-token", + refresh: "fresh-cli-refresh-token", + expires: createUsableOAuthExpiry(), + accountId: "acct-cli", + }); + const tokenlessCredential = { + type: "oauth", + provider: "openai-codex", + expires: Date.now() - 60_000, + accountId: "acct-cli", + } as OAuthCredential; + readCodexCliCredentialsCachedMock.mockReturnValue(cliCredential); + + const overlaid = overlayExternalOAuthProfiles( + createStore({ + "openai-codex:default": tokenlessCredential, + }), + ); + + expect(overlaid.profiles["openai-codex:default"]).toMatchObject({ + access: "fresh-cli-access-token", + refresh: "fresh-cli-refresh-token", + accountId: "acct-cli", + }); + expect( + readManagedExternalCliCredential({ + profileId: "openai-codex:default", + credential: tokenlessCredential, + }), + ).toMatchObject({ + access: "fresh-cli-access-token", + refresh: "fresh-cli-refresh-token", + accountId: "acct-cli", + }); + }); + it("keeps healthy local oauth even when external cli has a fresher token", () => { readCodexCliCredentialsCachedMock.mockReturnValue( createCredential({ diff --git a/src/agents/auth-profiles/oauth.adopt-identity.test.ts b/src/agents/auth-profiles/oauth.adopt-identity.test.ts index 9c07f2974fa..eea4c629820 100644 --- a/src/agents/auth-profiles/oauth.adopt-identity.test.ts +++ b/src/agents/auth-profiles/oauth.adopt-identity.test.ts @@ -29,18 +29,18 @@ const { formatProviderAuthProfileApiKeyWithPluginMock, } = getOAuthProviderRuntimeMocks(); -function expectOAuthProfileFields( - store: AuthProfileStore, - profileId: string, - params: { access: string; accountId: string }, -) { - const credential = store.profiles[profileId]; - expect(credential?.type).toBe("oauth"); - if (credential?.type !== "oauth") { - throw new Error(`Expected OAuth credential for ${profileId}`); - } - expect(credential.access).toBe(params.access); - expect(credential.accountId).toBe(params.accountId); +function expectPersistedOpenAICodexProfileWithoutInlineTokens( + credential: AuthProfileStore["profiles"][string], + metadata: Record = {}, +): void { + expect(credential).toMatchObject({ + type: "oauth", + provider: "openai-codex", + ...metadata, + }); + expect(credential).not.toHaveProperty("access"); + expect(credential).not.toHaveProperty("refresh"); + expect(credential).not.toHaveProperty("idToken"); } // Cross-account-leak defense-in-depth: each adopt site in oauth.ts calls the @@ -138,10 +138,11 @@ describe("OAuth credential adoption is identity-gated", () => { const subRaw = JSON.parse( await fs.readFile(path.join(subAgentDir, "auth-profiles.json"), "utf8"), ) as AuthProfileStore; - expectOAuthProfileFields(subRaw, profileId, { - access: "sub-own-access", + expectPersistedOpenAICodexProfileWithoutInlineTokens(subRaw.profiles[profileId], { accountId: "acct-sub", + expires: subExpiry, }); + expect(JSON.stringify(subRaw)).not.toContain("sub-own-access"); }); it("inside-the-lock main adoption refuses across accountId mismatch and proceeds to own refresh", async () => { @@ -210,10 +211,11 @@ describe("OAuth credential adoption is identity-gated", () => { const mainRaw = JSON.parse( await fs.readFile(path.join(mainAgentDir, "auth-profiles.json"), "utf8"), ) as AuthProfileStore; - expectOAuthProfileFields(mainRaw, profileId, { - access: "main-foreign-access", + expectPersistedOpenAICodexProfileWithoutInlineTokens(mainRaw.profiles[profileId], { accountId: "acct-other", + expires: freshExpiry, }); + expect(JSON.stringify(mainRaw)).not.toContain("main-foreign-access"); }); it("catch-block main-inherit refuses across accountId mismatch and surfaces the original error", async () => { @@ -286,9 +288,9 @@ describe("OAuth credential adoption is identity-gated", () => { const subRaw = JSON.parse( await fs.readFile(path.join(subAgentDir, "auth-profiles.json"), "utf8"), ) as AuthProfileStore; - expectOAuthProfileFields(subRaw, profileId, { - access: "sub-stale", + expectPersistedOpenAICodexProfileWithoutInlineTokens(subRaw.profiles[profileId], { accountId: "acct-sub", }); + expect(JSON.stringify(subRaw)).not.toContain("sub-stale"); }); }); diff --git a/src/agents/auth-profiles/oauth.mirror-refresh.test.ts b/src/agents/auth-profiles/oauth.mirror-refresh.test.ts index 04357615e68..35f01597cc1 100644 --- a/src/agents/auth-profiles/oauth.mirror-refresh.test.ts +++ b/src/agents/auth-profiles/oauth.mirror-refresh.test.ts @@ -28,6 +28,20 @@ const { formatProviderAuthProfileApiKeyWithPluginMock, } = getOAuthProviderRuntimeMocks(); +function expectPersistedOpenAICodexProfileWithoutInlineTokens( + credential: AuthProfileStore["profiles"][string], + metadata: Record = {}, +): void { + expect(credential).toMatchObject({ + type: "oauth", + provider: "openai-codex", + ...metadata, + }); + expect(credential).not.toHaveProperty("access"); + expect(credential).not.toHaveProperty("refresh"); + expect(credential).not.toHaveProperty("idToken"); +} + function requireOAuthCredential(store: AuthProfileStore, profileId: string): OAuthCredential { const profile = store.profiles[profileId]; if (!profile || profile.type !== "oauth") { @@ -36,7 +50,7 @@ function requireOAuthCredential(store: AuthProfileStore, profileId: string): OAu return profile; } -vi.mock("@earendil-works/pi-ai/oauth", () => ({ +vi.mock("@mariozechner/pi-ai/oauth", () => ({ getOAuthProviders: () => [{ id: "anthropic" }, { id: "openai-codex" }], getOAuthApiKey: vi.fn(async (provider: string, credentials: Record) => { const credential = credentials[provider]; @@ -85,7 +99,7 @@ describe("resolveApiKeyForProfile OAuth refresh mirror-to-main (#26322)", () => await removeOAuthTestTempRoot(tempRoot); }); - it("mirrors refreshed credentials into the main store so peers skip refresh", async () => { + it("mirrors refreshed Codex OAuth metadata into the main store without inline tokens", async () => { const profileId = "openai-codex:default"; const provider = "openai-codex"; const accountId = "acct-shared"; @@ -116,15 +130,17 @@ describe("resolveApiKeyForProfile OAuth refresh mirror-to-main (#26322)", () => expect(result?.apiKey).toBe("sub-refreshed-access"); - // Main store should now carry the refreshed credential, so a peer agent - // starting fresh will adopt rather than race. + // Main store should now carry refreshed metadata, so a peer agent + // starting fresh can resolve the runtime credential without token races. const mainRaw = JSON.parse( await fs.readFile(path.join(mainAgentDir, "auth-profiles.json"), "utf8"), ) as AuthProfileStore; - const mainCredential = requireOAuthCredential(mainRaw, profileId); - expect(mainCredential.access).toBe("sub-refreshed-access"); - expect(mainCredential.refresh).toBe("sub-refreshed-refresh"); - expect(mainCredential.expires).toBe(freshExpiry); + expectPersistedOpenAICodexProfileWithoutInlineTokens(mainRaw.profiles[profileId], { + expires: freshExpiry, + accountId, + }); + expect(JSON.stringify(mainRaw)).not.toContain("sub-refreshed-access"); + expect(JSON.stringify(mainRaw)).not.toContain("sub-refreshed-refresh"); }); it("does not mirror when refresh was performed from the main agent itself", async () => { @@ -161,10 +177,11 @@ describe("resolveApiKeyForProfile OAuth refresh mirror-to-main (#26322)", () => const mainRaw = JSON.parse( await fs.readFile(path.join(mainAgentDir, "auth-profiles.json"), "utf8"), ) as AuthProfileStore; - const mainCredential = requireOAuthCredential(mainRaw, profileId); - expect(mainCredential.access).toBe("main-refreshed-access"); - expect(mainCredential.refresh).toBe("main-refreshed-refresh"); - expect(mainCredential.expires).toBe(freshExpiry); + expectPersistedOpenAICodexProfileWithoutInlineTokens(mainRaw.profiles[profileId], { + expires: freshExpiry, + }); + expect(JSON.stringify(mainRaw)).not.toContain("main-refreshed-access"); + expect(JSON.stringify(mainRaw)).not.toContain("main-refreshed-refresh"); expect(refreshProviderOAuthCredentialWithPluginMock).toHaveBeenCalledTimes(1); }); @@ -332,17 +349,22 @@ describe("resolveApiKeyForProfile OAuth refresh mirror-to-main (#26322)", () => const subRaw = JSON.parse( await fs.readFile(path.join(subAgentDir, "auth-profiles.json"), "utf8"), ) as AuthProfileStore; - const subCredential = requireOAuthCredential(subRaw, profileId); - expect(subCredential.access).toBe("local-stale-access"); - expect(subCredential.refresh).toBe("local-stale-refresh"); + expectPersistedOpenAICodexProfileWithoutInlineTokens(subRaw.profiles[profileId], { + expires: now - 120_000, + accountId, + }); + expect(JSON.stringify(subRaw)).not.toContain("local-stale-access"); + expect(JSON.stringify(subRaw)).not.toContain("local-stale-refresh"); const mainRaw = JSON.parse( await fs.readFile(path.join(mainAgentDir, "auth-profiles.json"), "utf8"), ) as AuthProfileStore; - const mainCredential = requireOAuthCredential(mainRaw, profileId); - expect(mainCredential.access).toBe("main-owner-refreshed-access"); - expect(mainCredential.refresh).toBe("main-owner-refreshed-refresh"); - expect(mainCredential.expires).toBe(freshExpiry); + expectPersistedOpenAICodexProfileWithoutInlineTokens(mainRaw.profiles[profileId], { + expires: freshExpiry, + accountId, + }); + expect(JSON.stringify(mainRaw)).not.toContain("main-owner-refreshed-access"); + expect(JSON.stringify(mainRaw)).not.toContain("main-owner-refreshed-refresh"); }); it("inherits main-agent credentials via the catch-block fallback when refresh throws after main becomes fresh", async () => { @@ -410,7 +432,10 @@ describe("resolveApiKeyForProfile OAuth refresh mirror-to-main (#26322)", () => const subRaw = JSON.parse( await fs.readFile(path.join(subAgentDir, "auth-profiles.json"), "utf8"), ) as AuthProfileStore; - expect(requireOAuthCredential(subRaw, profileId).access).toBe("cached-access-token"); + expectPersistedOpenAICodexProfileWithoutInlineTokens(subRaw.profiles[profileId], { + accountId: "acct-shared", + }); + expect(JSON.stringify(subRaw)).not.toContain("cached-access-token"); }); it("mirrors refreshed credentials produced by the plugin-refresh path", async () => { diff --git a/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts b/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts index 600b01e9113..c244caf8253 100644 --- a/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts +++ b/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts @@ -85,6 +85,20 @@ function mockRotatedOpenAICodexRefresh() { }); } +function expectPersistedOpenAICodexProfileWithoutInlineTokens( + credential: AuthProfileStore["profiles"][string], + metadata: Record = {}, +): void { + expect(credential).toMatchObject({ + type: "oauth", + provider: "openai-codex", + ...metadata, + }); + expect(credential).not.toHaveProperty("access"); + expect(credential).not.toHaveProperty("refresh"); + expect(credential).not.toHaveProperty("idToken"); +} + function resolveOpenAICodexProfile(params: { profileId: string; agentDir: string }) { return resolveApiKeyForProfile({ store: ensureAuthProfileStore(params.agentDir), @@ -212,7 +226,7 @@ describe("resolveApiKeyForProfile openai-codex refresh fallback", () => { expect(refreshProviderOAuthCredentialWithPluginMock).toHaveBeenCalledTimes(1); }); - it("persists plugin-refreshed openai-codex credentials before returning", async () => { + it("persists plugin-refreshed openai-codex metadata before returning", async () => { const profileId = "openai-codex:default"; saveAuthProfileStore( createExpiredOauthStore({ @@ -233,11 +247,11 @@ describe("resolveApiKeyForProfile openai-codex refresh fallback", () => { }); const persisted = await readPersistedStore(agentDir); - const profile = requireOAuthProfile(persisted, profileId); - expect(profile.provider).toBe("openai-codex"); - expect(profile.access).toBe("rotated-access-token"); - expect(profile.refresh).toBe("rotated-refresh-token"); - expect(profile.accountId).toBe("acct-rotated"); + expectPersistedOpenAICodexProfileWithoutInlineTokens(persisted.profiles[profileId], { + accountId: "acct-rotated", + }); + expect(JSON.stringify(persisted)).not.toContain("rotated-access-token"); + expect(JSON.stringify(persisted)).not.toContain("rotated-refresh-token"); }); it("refreshes imported Codex credentials into the canonical auth store without writing back to .codex", async () => { @@ -286,12 +300,17 @@ describe("resolveApiKeyForProfile openai-codex refresh fallback", () => { email: undefined, }); const persisted = await readPersistedStore(agentDir); - const profile = requireOAuthProfile(persisted, profileId); - expect(profile.provider).toBe("openai-codex"); - expect(profile.access).toBe("rotated-cli-access-token"); - expect(profile.refresh).toBe("rotated-cli-refresh-token"); - expect(profile.accountId).toBe("acct-rotated"); - expect(profile.access).not.toBe("expired-access-token"); + expectPersistedOpenAICodexProfileWithoutInlineTokens(persisted.profiles[profileId], { + accountId: "acct-rotated", + }); + expect(JSON.stringify(persisted)).not.toContain("rotated-cli-access-token"); + expect(JSON.stringify(persisted)).not.toContain("rotated-cli-refresh-token"); + expect(persisted.profiles[profileId]).not.toEqual( + expect.objectContaining({ + provider: "openai-codex", + access: "expired-access-token", + }), + ); }); it("ignores mismatched fresh Codex CLI credentials when canonical local auth is bound to another account", async () => { @@ -344,13 +363,18 @@ describe("resolveApiKeyForProfile openai-codex refresh fallback", () => { }); const persisted = await readPersistedStore(agentDir); - const profile = requireOAuthProfile(persisted, profileId); - expect(profile.access).toBe("fresh-local-access-token"); - expect(profile.refresh).toBe("fresh-local-refresh-token"); - expect(profile.accountId).toBe("acct-local"); - expect(profile.access).not.toBe("fresh-cli-access-token"); - expect(profile.refresh).not.toBe("fresh-cli-refresh-token"); - expect(profile.accountId).not.toBe("acct-external"); + expectPersistedOpenAICodexProfileWithoutInlineTokens(persisted.profiles[profileId], { + accountId: "acct-local", + }); + expect(JSON.stringify(persisted)).not.toContain("fresh-local-access-token"); + expect(JSON.stringify(persisted)).not.toContain("fresh-local-refresh-token"); + expect(persisted.profiles[profileId]).not.toEqual( + expect.objectContaining({ + access: "fresh-cli-access-token", + refresh: "fresh-cli-refresh-token", + accountId: "acct-external", + }), + ); }); it("keeps the canonical refresh token when imported Codex CLI state is expired", async () => { @@ -406,10 +430,14 @@ describe("resolveApiKeyForProfile openai-codex refresh fallback", () => { }); const persisted = await readPersistedStore(agentDir); - const profile = requireOAuthProfile(persisted, profileId); - expect(profile.access).toBe("fresh-access-token"); - expect(profile.refresh).toBe("fresh-refresh-token"); - expect(profile.refresh).not.toBe("fresh-cli-refresh-token"); + expectPersistedOpenAICodexProfileWithoutInlineTokens(persisted.profiles[profileId]); + expect(JSON.stringify(persisted)).not.toContain("fresh-access-token"); + expect(JSON.stringify(persisted)).not.toContain("fresh-refresh-token"); + expect(persisted.profiles[profileId]).not.toEqual( + expect.objectContaining({ + refresh: "fresh-cli-refresh-token", + }), + ); }); it("adopts fresher stored credentials after refresh_token_reused", async () => { @@ -514,9 +542,9 @@ describe("resolveApiKeyForProfile openai-codex refresh fallback", () => { expect(getOAuthApiKeyMock).toHaveBeenCalledTimes(2); const persisted = await readPersistedStore(agentDir); - const profile = requireOAuthProfile(persisted, profileId); - expect(profile.access).toBe("retried-access-token"); - expect(profile.refresh).toBe("retried-refresh-token"); + expectPersistedOpenAICodexProfileWithoutInlineTokens(persisted.profiles[profileId]); + expect(JSON.stringify(persisted)).not.toContain("retried-access-token"); + expect(JSON.stringify(persisted)).not.toContain("retried-refresh-token"); }); it("keeps throwing for non-codex providers on the same refresh error", async () => { diff --git a/src/agents/auth-profiles/persisted.ts b/src/agents/auth-profiles/persisted.ts index 1276a5aa725..260c04ce04e 100644 --- a/src/agents/auth-profiles/persisted.ts +++ b/src/agents/auth-profiles/persisted.ts @@ -1,6 +1,11 @@ -import { resolveOAuthPath } from "../../config/paths.js"; +import { execFileSync } from "node:child_process"; +import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { resolveOAuthDir, resolveOAuthPath, resolveStateDir } from "../../config/paths.js"; import { coerceSecretRef } from "../../config/types.secrets.js"; -import { loadJsonFile } from "../../infra/json-file.js"; +import { loadJsonFile, saveJsonFile } from "../../infra/json-file.js"; import { normalizeProviderId } from "../provider-id.js"; import { AUTH_STORE_VERSION, log } from "./constants.js"; import { @@ -22,6 +27,7 @@ import type { AuthProfileSecretsStore, AuthProfileStore, OAuthCredential, + OAuthCredentialRef, OAuthCredentials, ProfileUsageStats, } from "./types.js"; @@ -32,6 +38,40 @@ type CredentialRejectReason = "non_object" | "invalid_type" | "missing_provider" type RejectedCredentialEntry = { key: string; reason: CredentialRejectReason }; const AUTH_PROFILE_TYPES = new Set(["api_key", "oauth", "token"]); +const REDACTED_OAUTH_TOKEN_PROVIDER_IDS = new Set(["openai-codex"]); +const OAUTH_PROFILE_SECRET_REF_SOURCE = "openclaw-credentials" as const; +const OAUTH_PROFILE_SECRET_DIRNAME = "auth-profiles"; +const OAUTH_PROFILE_SECRET_VERSION = 1; +const OAUTH_PROFILE_SECRET_ALGORITHM = "aes-256-gcm" as const; +const OAUTH_PROFILE_SECRET_KEY_ENV = "OPENCLAW_AUTH_PROFILE_SECRET_KEY"; +const OAUTH_PROFILE_SECRET_KEYCHAIN_SERVICE = "OpenClaw Auth Profile Secrets"; +const OAUTH_PROFILE_SECRET_KEYCHAIN_ACCOUNT = "oauth-profile-master-key"; +const OAUTH_PROFILE_SECRET_KEY_FILE_NAME = "auth-profile-secret-key"; + +type OAuthProfileSecretMaterial = { + access?: string; + refresh?: string; + idToken?: string; +}; + +type OAuthProfileEncryptedSecretPayload = { + algorithm: typeof OAUTH_PROFILE_SECRET_ALGORITHM; + iv: string; + tag: string; + ciphertext: string; +}; + +type OAuthProfileSecretPayload = OAuthProfileSecretMaterial & { + version: typeof OAUTH_PROFILE_SECRET_VERSION; + profileId: string; + provider: string; + encrypted?: OAuthProfileEncryptedSecretPayload; +}; + +type LoadPersistedAuthProfileStoreOptions = { + rewriteInlineOAuthSecrets?: boolean; + repairOAuthSecretPayloads?: boolean; +}; function normalizeSecretBackedField(params: { entry: Record; @@ -62,6 +102,424 @@ function normalizeRawCredentialEntry(raw: Record): Partial; } +function shouldPersistOAuthWithoutInlineSecrets( + credential: AuthProfileCredential, +): credential is OAuthCredential { + return ( + credential.type === "oauth" && + REDACTED_OAUTH_TOKEN_PROVIDER_IDS.has(normalizeProviderId(credential.provider)) + ); +} + +function resolveOAuthProfileSecretId(params: { agentDir?: string; profileId: string }): string { + return createHash("sha256") + .update(`${resolveAuthStorePath(params.agentDir)}\0${params.profileId}`) + .digest("hex") + .slice(0, 32); +} + +function resolveOAuthProfileSecretPath(ref: OAuthCredentialRef): string { + return path.join(resolveOAuthDir(), OAUTH_PROFILE_SECRET_DIRNAME, `${ref.id}.json`); +} + +function isOAuthProfileSecretRef(value: unknown): value is OAuthCredentialRef { + if (!value || typeof value !== "object") { + return false; + } + const record = value as Partial; + return ( + record.source === OAUTH_PROFILE_SECRET_REF_SOURCE && + record.provider === "openai-codex" && + typeof record.id === "string" && + /^[a-f0-9]{32}$/.test(record.id) + ); +} + +function resolveOAuthProfileSecretRef(params: { + agentDir?: string; + profileId: string; +}): OAuthCredentialRef { + return { + source: OAUTH_PROFILE_SECRET_REF_SOURCE, + provider: "openai-codex", + id: resolveOAuthProfileSecretId(params), + }; +} + +function hasInlineOAuthTokenMaterial(credential: OAuthCredential): boolean { + return [credential.access, credential.refresh, credential.idToken].some( + (value) => typeof value === "string" && value.trim().length > 0, + ); +} + +function normalizeOAuthProfileSecretMaterial( + credential: Partial>, +): OAuthProfileSecretMaterial | null { + const material: OAuthProfileSecretMaterial = { + ...(typeof credential.access === "string" && credential.access.trim() + ? { access: credential.access } + : {}), + ...(typeof credential.refresh === "string" && credential.refresh.trim() + ? { refresh: credential.refresh } + : {}), + ...(typeof credential.idToken === "string" && credential.idToken.trim() + ? { idToken: credential.idToken } + : {}), + }; + return Object.keys(material).length > 0 ? material : null; +} + +function buildOAuthProfileSecretAad(params: { + ref: OAuthCredentialRef; + profileId: string; + provider: string; +}): Buffer { + return Buffer.from(`${params.ref.id}\0${params.profileId}\0${params.provider}`, "utf8"); +} + +function readMacOAuthProfileSecretKey(): string | undefined { + if (process.platform !== "darwin") { + return undefined; + } + try { + return execFileSync( + "security", + [ + "find-generic-password", + "-s", + OAUTH_PROFILE_SECRET_KEYCHAIN_SERVICE, + "-a", + OAUTH_PROFILE_SECRET_KEYCHAIN_ACCOUNT, + "-w", + ], + { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] }, + ).trim(); + } catch { + return undefined; + } +} + +function createMacOAuthProfileSecretKey(): string | undefined { + if (process.platform !== "darwin") { + return undefined; + } + const generated = randomBytes(32).toString("base64url"); + try { + execFileSync( + "security", + [ + "add-generic-password", + "-U", + "-s", + OAUTH_PROFILE_SECRET_KEYCHAIN_SERVICE, + "-a", + OAUTH_PROFILE_SECRET_KEYCHAIN_ACCOUNT, + "-w", + generated, + ], + { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] }, + ); + return generated; + } catch (err) { + log.warn("failed to create oauth profile secret keychain entry", { err }); + return undefined; + } +} + +function isPathInsideOrEqual(parentDir: string, candidatePath: string): boolean { + const relative = path.relative(path.resolve(parentDir), path.resolve(candidatePath)); + return ( + relative === "" || (!!relative && !relative.startsWith("..") && !path.isAbsolute(relative)) + ); +} + +function uniquePaths(paths: Array): string[] { + return Array.from(new Set(paths.filter((entry): entry is string => Boolean(entry)))); +} + +function resolveFallbackOAuthProfileSecretKeyFileCandidates(): string[] { + if (process.platform === "win32") { + const home = process.env.USERPROFILE?.trim() || os.homedir(); + const root = + process.env.APPDATA?.trim() || (home ? path.join(home, "AppData", "Roaming") : undefined); + return uniquePaths([ + root ? path.join(root, "OpenClaw", OAUTH_PROFILE_SECRET_KEY_FILE_NAME) : undefined, + home + ? path.join(home, ".openclaw-auth-profile-secrets", OAUTH_PROFILE_SECRET_KEY_FILE_NAME) + : undefined, + ]); + } + + if (process.platform === "darwin") { + const home = process.env.HOME?.trim() || os.homedir(); + return uniquePaths([ + home + ? path.join( + home, + "Library", + "Application Support", + "OpenClaw", + OAUTH_PROFILE_SECRET_KEY_FILE_NAME, + ) + : undefined, + home + ? path.join(home, ".openclaw-auth-profile-secrets", OAUTH_PROFILE_SECRET_KEY_FILE_NAME) + : undefined, + ]); + } + + const home = process.env.HOME?.trim() || os.homedir(); + const root = + process.env.XDG_CONFIG_HOME?.trim() || (home ? path.join(home, ".config") : undefined); + return uniquePaths([ + root ? path.join(root, "openclaw", OAUTH_PROFILE_SECRET_KEY_FILE_NAME) : undefined, + home + ? path.join(home, ".openclaw-auth-profile-secrets", OAUTH_PROFILE_SECRET_KEY_FILE_NAME) + : undefined, + ]); +} + +function resolveFallbackOAuthProfileSecretKeyFilePath(): string | undefined { + const stateDir = resolveStateDir(); + return resolveFallbackOAuthProfileSecretKeyFileCandidates().find( + (candidate) => !isPathInsideOrEqual(stateDir, candidate), + ); +} + +function readFallbackOAuthProfileSecretKeyFile(): string | undefined { + const keyPath = resolveFallbackOAuthProfileSecretKeyFilePath(); + if (!keyPath) { + return undefined; + } + return readFallbackOAuthProfileSecretKeyFileAtPath(keyPath); +} + +function readFallbackOAuthProfileSecretKeyFileAtPath(keyPath: string): string | undefined { + try { + const value = fs.readFileSync(keyPath, "utf8").trim(); + return value || undefined; + } catch { + return undefined; + } +} + +function createFallbackOAuthProfileSecretKeyFile(): string | undefined { + const keyPath = resolveFallbackOAuthProfileSecretKeyFilePath(); + if (!keyPath) { + return undefined; + } + const generated = randomBytes(32).toString("base64url"); + let fd: number | undefined; + try { + fs.mkdirSync(path.dirname(keyPath), { recursive: true, mode: 0o700 }); + fd = fs.openSync(keyPath, "wx", 0o600); + fs.writeFileSync(fd, `${generated}\n`, "utf8"); + try { + fs.chmodSync(keyPath, 0o600); + } catch { + // Best effort only; some platforms ignore POSIX modes. + } + return generated; + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code === "EEXIST") { + return readFallbackOAuthProfileSecretKeyFileAtPath(keyPath); + } + log.warn("failed to create oauth profile secret key file", { err }); + return undefined; + } finally { + if (fd !== undefined) { + try { + fs.closeSync(fd); + } catch { + // Best effort only. + } + } + } +} + +function shouldUseMacKeychainForOAuthProfileSecrets(): boolean { + return process.platform === "darwin" && process.env.VITEST !== "true"; +} + +function resolveOAuthProfileSecretKeySeed(options?: { create?: boolean }): string | undefined { + const externalKey = process.env[OAUTH_PROFILE_SECRET_KEY_ENV]?.trim(); + if (externalKey) { + return externalKey; + } + if (process.env.NODE_ENV === "test" && process.env.VITEST === "true") { + return "openclaw-test-oauth-profile-secret-key"; + } + if (shouldUseMacKeychainForOAuthProfileSecrets()) { + const keychainKey = + readMacOAuthProfileSecretKey() ?? + (options?.create === true ? createMacOAuthProfileSecretKey() : undefined); + if (keychainKey) { + return keychainKey; + } + } + const fileKey = + readFallbackOAuthProfileSecretKeyFile() ?? + (options?.create === true ? createFallbackOAuthProfileSecretKeyFile() : undefined); + if (fileKey) { + return fileKey; + } + return undefined; +} + +function buildOAuthProfileSecretKey(options?: { create?: boolean }): Buffer | null { + const externalKey = resolveOAuthProfileSecretKeySeed(options); + if (!externalKey) { + return null; + } + return createHash("sha256").update(`openclaw:auth-profile-oauth:${externalKey}`).digest(); +} + +function encryptOAuthProfileSecretMaterial(params: { + ref: OAuthCredentialRef; + profileId: string; + provider: string; + material: OAuthProfileSecretMaterial; +}): OAuthProfileEncryptedSecretPayload { + const key = buildOAuthProfileSecretKey({ create: true }); + if (!key) { + throw new Error("OAuth profile secret key source is required to persist OAuth profile secrets"); + } + const iv = randomBytes(12); + const cipher = createCipheriv(OAUTH_PROFILE_SECRET_ALGORITHM, key, iv); + cipher.setAAD( + buildOAuthProfileSecretAad({ + ref: params.ref, + profileId: params.profileId, + provider: params.provider, + }), + ); + const ciphertext = Buffer.concat([ + cipher.update(JSON.stringify(params.material), "utf8"), + cipher.final(), + ]); + return { + algorithm: OAUTH_PROFILE_SECRET_ALGORITHM, + iv: iv.toString("base64url"), + tag: cipher.getAuthTag().toString("base64url"), + ciphertext: ciphertext.toString("base64url"), + }; +} + +function decryptOAuthProfileSecretMaterial(params: { + ref: OAuthCredentialRef; + profileId: string; + provider: string; + encrypted: OAuthProfileEncryptedSecretPayload; +}): OAuthProfileSecretMaterial | null { + if (params.encrypted.algorithm !== OAUTH_PROFILE_SECRET_ALGORITHM) { + return null; + } + const key = buildOAuthProfileSecretKey(); + if (!key) { + return null; + } + try { + const decipher = createDecipheriv( + OAUTH_PROFILE_SECRET_ALGORITHM, + key, + Buffer.from(params.encrypted.iv, "base64url"), + ); + decipher.setAAD( + buildOAuthProfileSecretAad({ + ref: params.ref, + profileId: params.profileId, + provider: params.provider, + }), + ); + decipher.setAuthTag(Buffer.from(params.encrypted.tag, "base64url")); + const plaintext = Buffer.concat([ + decipher.update(Buffer.from(params.encrypted.ciphertext, "base64url")), + decipher.final(), + ]).toString("utf8"); + const raw = JSON.parse(plaintext) as unknown; + if (!raw || typeof raw !== "object") { + return null; + } + return normalizeOAuthProfileSecretMaterial(raw as OAuthProfileSecretMaterial); + } catch { + return null; + } +} + +function writeOAuthProfileSecretMaterial(params: { + ref: OAuthCredentialRef; + profileId: string; + provider: string; + material: OAuthProfileSecretMaterial; +}): void { + const secretPath = resolveOAuthProfileSecretPath(params.ref); + fs.mkdirSync(path.dirname(secretPath), { recursive: true, mode: 0o700 }); + const payload: OAuthProfileSecretPayload = { + version: OAUTH_PROFILE_SECRET_VERSION, + profileId: params.profileId, + provider: params.provider, + encrypted: encryptOAuthProfileSecretMaterial(params), + }; + saveJsonFile(secretPath, payload); + try { + fs.chmodSync(secretPath, 0o600); + } catch { + // Best effort only; some platforms ignore POSIX modes. + } +} + +function persistOAuthProfileSecrets(params: { + agentDir?: string; + profileId: string; + credential: OAuthCredential; +}): OAuthCredentialRef | undefined { + const expectedRef = resolveOAuthProfileSecretRef({ + agentDir: params.agentDir, + profileId: params.profileId, + }); + const existingRef = isOAuthProfileSecretRef(params.credential.oauthRef) + ? params.credential.oauthRef + : undefined; + const targetRef = existingRef?.id === expectedRef.id ? existingRef : expectedRef; + if (!hasInlineOAuthTokenMaterial(params.credential)) { + return existingRef?.id === expectedRef.id ? existingRef : undefined; + } + const material = normalizeOAuthProfileSecretMaterial(params.credential); + if (!material) { + return existingRef?.id === expectedRef.id ? existingRef : undefined; + } + writeOAuthProfileSecretMaterial({ + ref: targetRef, + profileId: params.profileId, + provider: params.credential.provider, + material, + }); + return targetRef; +} + +function omitInlineOAuthSecrets(params: { + agentDir?: string; + profileId: string; + credential: OAuthCredential; +}): AuthProfileCredential { + const oauthRef = persistOAuthProfileSecrets(params); + if (!oauthRef) { + return params.credential; + } + const sanitized = { ...params.credential } as Record; + delete sanitized.access; + delete sanitized.refresh; + delete sanitized.idToken; + sanitized.oauthRef = oauthRef; + return sanitized as AuthProfileCredential; +} + +function hasInlinePersistableOAuthSecrets(credential: AuthProfileCredential): boolean { + return ( + shouldPersistOAuthWithoutInlineSecrets(credential) && hasInlineOAuthTokenMaterial(credential) + ); +} + function parseCredentialEntry( raw: unknown, fallbackProvider?: string, @@ -500,6 +958,7 @@ export function buildPersistedAuthProfileSecretsStore( profileId: string; credential: AuthProfileCredential; }) => boolean, + options?: { agentDir?: string }, ): AuthProfileSecretsStore { const profiles = Object.fromEntries( Object.entries(store.profiles).flatMap(([profileId, credential]) => { @@ -516,6 +975,18 @@ export function buildPersistedAuthProfileSecretsStore( delete sanitized.token; return [[profileId, sanitized]]; } + if (shouldPersistOAuthWithoutInlineSecrets(credential)) { + return [ + [ + profileId, + omitInlineOAuthSecrets({ + agentDir: options?.agentDir, + profileId, + credential, + }), + ], + ]; + } return [[profileId, credential]]; }), ) as AuthProfileSecretsStore["profiles"]; @@ -589,17 +1060,274 @@ export function mergeOAuthFileIntoStore(store: AuthProfileStore): boolean { return mutated; } -export function loadPersistedAuthProfileStore(agentDir?: string): AuthProfileStore | null { +function coerceOAuthProfileEncryptedSecretPayload( + raw: unknown, +): OAuthProfileEncryptedSecretPayload | null { + if (!raw || typeof raw !== "object") { + return null; + } + const record = raw as Partial; + return record.algorithm === OAUTH_PROFILE_SECRET_ALGORITHM && + typeof record.iv === "string" && + typeof record.tag === "string" && + typeof record.ciphertext === "string" + ? { + algorithm: record.algorithm, + iv: record.iv, + tag: record.tag, + ciphertext: record.ciphertext, + } + : null; +} + +function hasEncryptedOAuthProfileSecretPayload(raw: unknown): boolean { + return ( + !!raw && + typeof raw === "object" && + coerceOAuthProfileEncryptedSecretPayload( + (raw as Partial).encrypted, + ) !== null + ); +} + +function coerceOAuthProfileSecretPayload(params: { + raw: unknown; + ref: OAuthCredentialRef; + profileId: string; + provider: string; +}): OAuthProfileSecretMaterial | null { + const { raw, ref, profileId, provider } = params; + if (!raw || typeof raw !== "object") { + return null; + } + const record = raw as Partial; + if ( + record.version !== OAUTH_PROFILE_SECRET_VERSION || + record.profileId !== profileId || + record.provider !== provider + ) { + return null; + } + const encrypted = coerceOAuthProfileEncryptedSecretPayload(record.encrypted); + if (encrypted) { + return decryptOAuthProfileSecretMaterial({ + ref, + profileId, + provider, + encrypted, + }); + } + return normalizeOAuthProfileSecretMaterial(record); +} + +function resolvePersistedOAuthSecrets( + credential: OAuthCredential, + profileId: string, + options?: { repairOAuthSecretPayloads?: boolean }, +): OAuthCredential { + if (!isOAuthProfileSecretRef(credential.oauthRef)) { + return credential; + } + const secretPath = resolveOAuthProfileSecretPath(credential.oauthRef); + const raw = loadJsonFile(secretPath); + const secret = coerceOAuthProfileSecretPayload({ + raw, + ref: credential.oauthRef, + profileId, + provider: credential.provider, + }); + if (!secret) { + return credential; + } + if (options?.repairOAuthSecretPayloads === true && !hasEncryptedOAuthProfileSecretPayload(raw)) { + writeOAuthProfileSecretMaterial({ + ref: credential.oauthRef, + profileId, + provider: credential.provider, + material: secret, + }); + } + return { + ...credential, + ...(secret.access ? { access: secret.access } : {}), + ...(secret.refresh ? { refresh: secret.refresh } : {}), + ...(secret.idToken ? { idToken: secret.idToken } : {}), + } as OAuthCredential; +} + +function resolvePersistedOAuthProfileSecrets( + store: AuthProfileStore, + options?: { repairOAuthSecretPayloads?: boolean }, +): AuthProfileStore { + const profiles = Object.fromEntries( + Object.entries(store.profiles).map(([profileId, credential]) => [ + profileId, + credential.type === "oauth" + ? resolvePersistedOAuthSecrets(credential, profileId, options) + : credential, + ]), + ) as AuthProfileStore["profiles"]; + return { + ...store, + profiles, + }; +} + +function collectPersistedOAuthProfileSecretIds( + store: AuthProfileStore | AuthProfileSecretsStore, +): Set { + const ids = new Set(); + for (const credential of Object.values(store.profiles)) { + if (credential.type === "oauth" && isOAuthProfileSecretRef(credential.oauthRef)) { + ids.add(credential.oauthRef.id); + } + } + return ids; +} + +export function removeDetachedOAuthProfileSecrets(params: { + previousRaw: unknown; + nextStore: AuthProfileSecretsStore; +}): void { + const previousStore = coercePersistedAuthProfileStore(params.previousRaw); + if (!previousStore) { + return; + } + const previousIds = collectPersistedOAuthProfileSecretIds(previousStore); + if (previousIds.size === 0) { + return; + } + const nextIds = collectPersistedOAuthProfileSecretIds(params.nextStore); + for (const id of previousIds) { + if (nextIds.has(id)) { + continue; + } + fs.rmSync( + resolveOAuthProfileSecretPath({ + source: OAUTH_PROFILE_SECRET_REF_SOURCE, + provider: "openai-codex", + id, + }), + { force: true }, + ); + } +} + +function buildPersistedAuthProfileFilePayload(params: { + store: AuthProfileStore; + raw: unknown; + agentDir?: string; +}): AuthProfileSecretsStore & Partial { + const payload = buildPersistedAuthProfileSecretsStore(params.store, undefined, { + agentDir: params.agentDir, + }) as AuthProfileSecretsStore & Partial; + const state = coerceAuthProfileState(params.raw); + return { + ...payload, + ...(state.order ? { order: state.order } : {}), + ...(state.lastGood ? { lastGood: state.lastGood } : {}), + ...(state.usageStats ? { usageStats: state.usageStats } : {}), + }; +} + +function resolveAuthStoreLockPathSync(authPath: string): string { + const resolved = path.resolve(authPath); + const dir = path.dirname(resolved); + fs.mkdirSync(dir, { recursive: true }); + try { + return `${path.join(fs.realpathSync(dir), path.basename(resolved))}.lock`; + } catch { + return `${resolved}.lock`; + } +} + +function withAuthStoreRewriteLockSync(authPath: string, fn: () => void): boolean { + const lockPath = resolveAuthStoreLockPathSync(authPath); + let fd: number | undefined; + try { + fd = fs.openSync(lockPath, "wx", 0o600); + fs.writeFileSync( + fd, + `${JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }, null, 2)}\n`, + "utf8", + ); + fn(); + return true; + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code === "EEXIST") { + return false; + } + throw err; + } finally { + if (fd !== undefined) { + try { + fs.closeSync(fd); + } catch { + // Best effort only. + } + try { + fs.rmSync(lockPath, { force: true }); + } catch { + // Best effort only. + } + } + } +} + +function rewritePersistedInlineOAuthSecrets(params: { authPath: string; agentDir?: string }): void { + withAuthStoreRewriteLockSync(params.authPath, () => { + const raw = loadJsonFile(params.authPath); + const store = coercePersistedAuthProfileStore(raw); + if (!store) { + return; + } + const merged = { + ...store, + ...mergeAuthProfileState( + coerceAuthProfileState(raw), + loadPersistedAuthProfileState(params.agentDir), + ), + }; + if (!Object.values(merged.profiles).some(hasInlinePersistableOAuthSecrets)) { + return; + } + saveJsonFile( + params.authPath, + buildPersistedAuthProfileFilePayload({ store: merged, raw, agentDir: params.agentDir }), + ); + }); +} + +export function loadPersistedAuthProfileStore( + agentDir?: string, + options?: LoadPersistedAuthProfileStoreOptions, +): AuthProfileStore | null { const authPath = resolveAuthStorePath(agentDir); const raw = loadJsonFile(authPath); const store = coercePersistedAuthProfileStore(raw); if (!store) { return null; } - return { + const merged = { ...store, ...mergeAuthProfileState(coerceAuthProfileState(raw), loadPersistedAuthProfileState(agentDir)), }; + const canRepairPersistedSecrets = + options?.rewriteInlineOAuthSecrets === true && process.env.OPENCLAW_AUTH_STORE_READONLY !== "1"; + if ( + canRepairPersistedSecrets && + Object.values(merged.profiles).some(hasInlinePersistableOAuthSecrets) + ) { + try { + rewritePersistedInlineOAuthSecrets({ authPath, agentDir }); + } catch (err) { + log.warn("failed to rewrite inline oauth auth profile secrets", { err, authPath }); + } + } + return resolvePersistedOAuthProfileSecrets(merged, { + repairOAuthSecretPayloads: + options?.repairOAuthSecretPayloads === true || canRepairPersistedSecrets, + }); } export function loadLegacyAuthProfileStore(agentDir?: string): LegacyAuthStore | null { diff --git a/src/agents/auth-profiles/profiles.test.ts b/src/agents/auth-profiles/profiles.test.ts index 40e17f1ccd5..fd80ed6dfd2 100644 --- a/src/agents/auth-profiles/profiles.test.ts +++ b/src/agents/auth-profiles/profiles.test.ts @@ -1,103 +1,1284 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { markAuthProfileSuccess } from "./profiles.js"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { resolveOAuthDir } from "../../config/paths.js"; +import { AUTH_STORE_VERSION } from "./constants.js"; +import { resolveAuthStorePath } from "./paths.js"; +import { promoteAuthProfileInOrder } from "./profiles.js"; +import { + clearRuntimeAuthProfileStoreSnapshots, + findPersistedAuthProfileCredential, + loadAuthProfileStoreForRuntime, + loadAuthProfileStoreWithoutExternalProfiles, + saveAuthProfileStore, +} from "./store.js"; import type { AuthProfileStore } from "./types.js"; -const storeMocks = vi.hoisted(() => ({ - saveAuthProfileStore: vi.fn(), - updateAuthProfileStoreWithLock: vi.fn().mockResolvedValue(null), -})); - -vi.mock("./store.js", () => ({ - ensureAuthProfileStoreForLocalUpdate: vi.fn(() => ({ version: 1, profiles: {} })), - saveAuthProfileStore: storeMocks.saveAuthProfileStore, - updateAuthProfileStoreWithLock: storeMocks.updateAuthProfileStoreWithLock, -})); - -beforeEach(() => { - vi.clearAllMocks(); - storeMocks.updateAuthProfileStoreWithLock.mockResolvedValue(null); -}); - -function makeStore(usageStats: AuthProfileStore["usageStats"]): AuthProfileStore { - return { - version: 1, - profiles: { - "anthropic:default": { - type: "api_key", - provider: "anthropic", - key: "sk-test", - }, - }, - usageStats, +function readPersistedTree(rootDir: string): string { + const chunks: string[] = []; + const visit = (dir: string): void => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + visit(entryPath); + continue; + } + if (entry.isFile()) { + chunks.push(fs.readFileSync(entryPath, "utf8")); + } + } }; + visit(rootDir); + return chunks.join("\n"); } -describe("markAuthProfileSuccess", () => { - it("updates last-good and usage stats through the fallback save path when lock update misses", async () => { - const store = makeStore({ - "anthropic:default": { - errorCount: 3, - cooldownUntil: Date.now() + 60_000, - cooldownReason: "rate_limit", - }, - }); +function findFilesNamed(rootDir: string, basename: string): string[] { + const matches: string[] = []; + const visit = (dir: string): void => { + if (!fs.existsSync(dir)) { + return; + } + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + visit(entryPath); + continue; + } + if (entry.isFile() && entry.name === basename) { + matches.push(entryPath); + } + } + }; + visit(rootDir); + return matches; +} - storeMocks.updateAuthProfileStoreWithLock.mockResolvedValue(null); +function isPathInsideOrEqual(parentDir: string, candidatePath: string): boolean { + const relative = path.relative(path.resolve(parentDir), path.resolve(candidatePath)); + return ( + relative === "" || (!!relative && !relative.startsWith("..") && !path.isAbsolute(relative)) + ); +} - const beforeUsed = Date.now(); - await markAuthProfileSuccess({ - store, - provider: "anthropic", - profileId: "anthropic:default", - agentDir: "/tmp/openclaw-auth-profiles-success", - }); - expect(storeMocks.saveAuthProfileStore).toHaveBeenCalledWith( - store, - "/tmp/openclaw-auth-profiles-success", - ); - expect(store.lastGood).toEqual({ anthropic: "anthropic:default" }); - expect(store.usageStats?.["anthropic:default"]).toMatchObject({ - errorCount: 0, - cooldownUntil: undefined, - cooldownReason: undefined, - }); - expect(store.usageStats?.["anthropic:default"]?.lastUsed).toBeGreaterThanOrEqual(beforeUsed); +function readPersistedOAuthRefId(agentDir: string, profileId: string): string { + const persisted = JSON.parse(fs.readFileSync(resolveAuthStorePath(agentDir), "utf8")) as { + profiles: Record; + }; + const refId = persisted.profiles[profileId]?.oauthRef?.id; + expect(refId).toEqual(expect.any(String)); + return String(refId); +} + +function resolvePersistedOAuthSecretPath(refId: string): string { + return path.join(resolveOAuthDir(), "auth-profiles", `${refId}.json`); +} + +function resolveAuthStoreLockPath(authPath: string): string { + return `${path.join(fs.realpathSync(path.dirname(authPath)), path.basename(authPath))}.lock`; +} + +describe("promoteAuthProfileInOrder", () => { + it("omits inline openai-codex oauth secrets from persisted auth profile files", () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-profile-metadata-")); + const agentDir = path.join(stateDir, "agents", "main", "agent"); + const previousStateDir = process.env.OPENCLAW_STATE_DIR; + process.env.OPENCLAW_STATE_DIR = stateDir; + try { + fs.mkdirSync(agentDir, { recursive: true }); + const profileId = "openai-codex:default"; + const expires = Date.now() + 60 * 60 * 1000; + saveAuthProfileStore( + { + version: AUTH_STORE_VERSION, + profiles: { + [profileId]: { + type: "oauth", + provider: "openai-codex", + access: "local-access-token", + refresh: "local-refresh-token", + idToken: "local-id-token", + expires, + email: "dev@example.test", + accountId: "acct-local", + chatgptPlanType: "plus", + }, + }, + }, + agentDir, + { filterExternalAuthProfiles: false }, + ); + + const persisted = JSON.parse(fs.readFileSync(resolveAuthStorePath(agentDir), "utf8")) as { + profiles: Record>; + }; + const credential = persisted.profiles[profileId]; + + expect(credential).toMatchObject({ + type: "oauth", + provider: "openai-codex", + expires, + email: "dev@example.test", + accountId: "acct-local", + chatgptPlanType: "plus", + oauthRef: { + source: "openclaw-credentials", + provider: "openai-codex", + id: expect.any(String), + }, + }); + expect(credential).not.toHaveProperty("access"); + expect(credential).not.toHaveProperty("refresh"); + expect(credential).not.toHaveProperty("idToken"); + expect(JSON.stringify(persisted)).not.toContain("local-access-token"); + expect(JSON.stringify(persisted)).not.toContain("local-refresh-token"); + expect(JSON.stringify(persisted)).not.toContain("local-id-token"); + const persistedStateTree = readPersistedTree(stateDir); + expect(persistedStateTree).not.toContain("local-access-token"); + expect(persistedStateTree).not.toContain("local-refresh-token"); + expect(persistedStateTree).not.toContain("local-id-token"); + + clearRuntimeAuthProfileStoreSnapshots(); + expect( + loadAuthProfileStoreWithoutExternalProfiles(agentDir).profiles[profileId], + ).toMatchObject({ + type: "oauth", + provider: "openai-codex", + access: "local-access-token", + refresh: "local-refresh-token", + idToken: "local-id-token", + }); + } finally { + if (previousStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previousStateDir; + } + fs.rmSync(stateDir, { recursive: true, force: true }); + } }); - it("adopts locked store last-good and usage stats without saving locally when lock update succeeds", async () => { - const store = makeStore({ - "anthropic:default": { - errorCount: 3, - cooldownUntil: Date.now() + 60_000, - }, - }); - const lockedStore = makeStore(undefined); + it("requires the external oauth profile secret key to recover persisted token material", () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-profile-keyed-")); + const agentDir = path.join(stateDir, "agents", "main", "agent"); + const previousStateDir = process.env.OPENCLAW_STATE_DIR; + const previousSecretKey = process.env.OPENCLAW_AUTH_PROFILE_SECRET_KEY; + process.env.OPENCLAW_STATE_DIR = stateDir; + process.env.OPENCLAW_AUTH_PROFILE_SECRET_KEY = "correct-profile-secret-key"; + try { + fs.mkdirSync(agentDir, { recursive: true }); + const profileId = "openai-codex:default"; + const expires = Date.now() + 60 * 60 * 1000; + saveAuthProfileStore( + { + version: AUTH_STORE_VERSION, + profiles: { + [profileId]: { + type: "oauth", + provider: "openai-codex", + access: "keyed-access-token", + refresh: "keyed-refresh-token", + expires, + }, + }, + }, + agentDir, + { filterExternalAuthProfiles: false }, + ); - storeMocks.updateAuthProfileStoreWithLock.mockImplementationOnce(async ({ updater }) => { - updater(lockedStore); - return lockedStore; - }); + const persistedStateTree = readPersistedTree(stateDir); + expect(persistedStateTree).not.toContain("keyed-access-token"); + expect(persistedStateTree).not.toContain("keyed-refresh-token"); - const beforeUsed = Date.now(); - await markAuthProfileSuccess({ - store, - provider: "anthropic", - profileId: "anthropic:default", - agentDir: "/tmp/openclaw-auth-profiles-success", - }); - const afterUsed = Date.now(); + process.env.OPENCLAW_AUTH_PROFILE_SECRET_KEY = "wrong-profile-secret-key"; + clearRuntimeAuthProfileStoreSnapshots(); + expect( + loadAuthProfileStoreWithoutExternalProfiles(agentDir).profiles[profileId], + ).not.toMatchObject({ + access: "keyed-access-token", + refresh: "keyed-refresh-token", + }); - expect(storeMocks.saveAuthProfileStore).not.toHaveBeenCalled(); - expect(store.lastGood).toEqual({ anthropic: "anthropic:default" }); - expect(store.usageStats).toEqual(lockedStore.usageStats); - expect(store.usageStats?.["anthropic:default"]).toMatchObject({ - errorCount: 0, - cooldownUntil: undefined, + process.env.OPENCLAW_AUTH_PROFILE_SECRET_KEY = "correct-profile-secret-key"; + clearRuntimeAuthProfileStoreSnapshots(); + expect( + loadAuthProfileStoreWithoutExternalProfiles(agentDir).profiles[profileId], + ).toMatchObject({ + type: "oauth", + provider: "openai-codex", + access: "keyed-access-token", + refresh: "keyed-refresh-token", + }); + } finally { + if (previousStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previousStateDir; + } + if (previousSecretKey === undefined) { + delete process.env.OPENCLAW_AUTH_PROFILE_SECRET_KEY; + } else { + process.env.OPENCLAW_AUTH_PROFILE_SECRET_KEY = previousSecretKey; + } + fs.rmSync(stateDir, { recursive: true, force: true }); + } + }); + + it("does not create fallback oauth key files under the Vitest NODE_ENV test harness", () => { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-profile-test-key-")); + const stateDir = path.join(rootDir, "state"); + const agentDir = path.join(stateDir, "agents", "main", "agent"); + const homeDir = path.join(rootDir, "home"); + const configDir = path.join(rootDir, "config"); + const previousStateDir = process.env.OPENCLAW_STATE_DIR; + const previousSecretKey = process.env.OPENCLAW_AUTH_PROFILE_SECRET_KEY; + const previousNodeEnv = process.env.NODE_ENV; + const previousVitest = process.env.VITEST; + const previousHome = process.env.HOME; + const previousXdgConfigHome = process.env.XDG_CONFIG_HOME; + const previousAppData = process.env.APPDATA; + const previousUserProfile = process.env.USERPROFILE; + process.env.OPENCLAW_STATE_DIR = stateDir; + process.env.NODE_ENV = "test"; + process.env.VITEST = "true"; + process.env.HOME = homeDir; + process.env.XDG_CONFIG_HOME = configDir; + process.env.APPDATA = configDir; + process.env.USERPROFILE = homeDir; + delete process.env.OPENCLAW_AUTH_PROFILE_SECRET_KEY; + try { + fs.mkdirSync(agentDir, { recursive: true }); + const profileId = "openai-codex:default"; + saveAuthProfileStore( + { + version: AUTH_STORE_VERSION, + profiles: { + [profileId]: { + type: "oauth", + provider: "openai-codex", + access: "test-env-access-token", + refresh: "test-env-refresh-token", + expires: Date.now() + 60 * 60 * 1000, + }, + }, + }, + agentDir, + { filterExternalAuthProfiles: false }, + ); + + expect(findFilesNamed(rootDir, "auth-profile-secret-key")).toEqual([]); + clearRuntimeAuthProfileStoreSnapshots(); + expect( + loadAuthProfileStoreWithoutExternalProfiles(agentDir).profiles[profileId], + ).toMatchObject({ + type: "oauth", + provider: "openai-codex", + access: "test-env-access-token", + refresh: "test-env-refresh-token", + }); + } finally { + if (previousStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previousStateDir; + } + if (previousSecretKey === undefined) { + delete process.env.OPENCLAW_AUTH_PROFILE_SECRET_KEY; + } else { + process.env.OPENCLAW_AUTH_PROFILE_SECRET_KEY = previousSecretKey; + } + if (previousNodeEnv === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = previousNodeEnv; + } + if (previousVitest === undefined) { + delete process.env.VITEST; + } else { + process.env.VITEST = previousVitest; + } + if (previousHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = previousHome; + } + if (previousXdgConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = previousXdgConfigHome; + } + if (previousAppData === undefined) { + delete process.env.APPDATA; + } else { + process.env.APPDATA = previousAppData; + } + if (previousUserProfile === undefined) { + delete process.env.USERPROFILE; + } else { + process.env.USERPROFILE = previousUserProfile; + } + fs.rmSync(rootDir, { recursive: true, force: true }); + } + }); + + it("does not use the hardcoded oauth key for NODE_ENV test outside the harness", () => { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-profile-node-env-test-")); + const stateDir = path.join(rootDir, "state"); + const agentDir = path.join(stateDir, "agents", "main", "agent"); + const homeDir = path.join(rootDir, "home"); + const configDir = path.join(rootDir, "config"); + const previousStateDir = process.env.OPENCLAW_STATE_DIR; + const previousSecretKey = process.env.OPENCLAW_AUTH_PROFILE_SECRET_KEY; + const previousNodeEnv = process.env.NODE_ENV; + const previousVitest = process.env.VITEST; + const previousHome = process.env.HOME; + const previousXdgConfigHome = process.env.XDG_CONFIG_HOME; + const previousAppData = process.env.APPDATA; + const previousUserProfile = process.env.USERPROFILE; + process.env.OPENCLAW_STATE_DIR = stateDir; + process.env.NODE_ENV = "test"; + delete process.env.VITEST; + process.env.HOME = homeDir; + process.env.XDG_CONFIG_HOME = configDir; + process.env.APPDATA = configDir; + process.env.USERPROFILE = homeDir; + delete process.env.OPENCLAW_AUTH_PROFILE_SECRET_KEY; + try { + fs.mkdirSync(agentDir, { recursive: true }); + const profileId = "openai-codex:default"; + saveAuthProfileStore( + { + version: AUTH_STORE_VERSION, + profiles: { + [profileId]: { + type: "oauth", + provider: "openai-codex", + access: "node-env-test-access-token", + refresh: "node-env-test-refresh-token", + expires: Date.now() + 60 * 60 * 1000, + }, + }, + }, + agentDir, + { filterExternalAuthProfiles: false }, + ); + + expect(findFilesNamed(rootDir, "auth-profile-secret-key")).toHaveLength(1); + clearRuntimeAuthProfileStoreSnapshots(); + delete process.env.NODE_ENV; + expect( + loadAuthProfileStoreWithoutExternalProfiles(agentDir).profiles[profileId], + ).toMatchObject({ + type: "oauth", + provider: "openai-codex", + access: "node-env-test-access-token", + refresh: "node-env-test-refresh-token", + }); + } finally { + if (previousStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previousStateDir; + } + if (previousSecretKey === undefined) { + delete process.env.OPENCLAW_AUTH_PROFILE_SECRET_KEY; + } else { + process.env.OPENCLAW_AUTH_PROFILE_SECRET_KEY = previousSecretKey; + } + if (previousNodeEnv === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = previousNodeEnv; + } + if (previousVitest === undefined) { + delete process.env.VITEST; + } else { + process.env.VITEST = previousVitest; + } + if (previousHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = previousHome; + } + if (previousXdgConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = previousXdgConfigHome; + } + if (previousAppData === undefined) { + delete process.env.APPDATA; + } else { + process.env.APPDATA = previousAppData; + } + if (previousUserProfile === undefined) { + delete process.env.USERPROFILE; + } else { + process.env.USERPROFILE = previousUserProfile; + } + fs.rmSync(rootDir, { recursive: true, force: true }); + } + }); + + it("persists production oauth profiles on non-macOS without an env secret key", () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-profile-prod-")); + const agentDir = path.join(stateDir, "agents", "main", "agent"); + const homeDir = path.join(path.dirname(stateDir), "home"); + const configDir = path.join(path.dirname(stateDir), "external-config"); + const previousStateDir = process.env.OPENCLAW_STATE_DIR; + const previousSecretKey = process.env.OPENCLAW_AUTH_PROFILE_SECRET_KEY; + const previousNodeEnv = process.env.NODE_ENV; + const previousHome = process.env.HOME; + const previousXdgConfigHome = process.env.XDG_CONFIG_HOME; + const previousAppData = process.env.APPDATA; + const previousUserProfile = process.env.USERPROFILE; + process.env.OPENCLAW_STATE_DIR = stateDir; + process.env.NODE_ENV = "production"; + process.env.HOME = homeDir; + process.env.XDG_CONFIG_HOME = configDir; + process.env.APPDATA = configDir; + process.env.USERPROFILE = homeDir; + delete process.env.OPENCLAW_AUTH_PROFILE_SECRET_KEY; + try { + fs.mkdirSync(agentDir, { recursive: true }); + const profileId = "openai-codex:default"; + saveAuthProfileStore( + { + version: AUTH_STORE_VERSION, + profiles: { + [profileId]: { + type: "oauth", + provider: "openai-codex", + access: "production-access-token", + refresh: "production-refresh-token", + expires: Date.now() + 60 * 60 * 1000, + }, + }, + }, + agentDir, + { filterExternalAuthProfiles: false }, + ); + + const persistedStateTree = readPersistedTree(stateDir); + expect(persistedStateTree).not.toContain("production-access-token"); + expect(persistedStateTree).not.toContain("production-refresh-token"); + + clearRuntimeAuthProfileStoreSnapshots(); + expect( + loadAuthProfileStoreWithoutExternalProfiles(agentDir).profiles[profileId], + ).toMatchObject({ + type: "oauth", + provider: "openai-codex", + access: "production-access-token", + refresh: "production-refresh-token", + }); + } finally { + if (previousStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previousStateDir; + } + if (previousSecretKey === undefined) { + delete process.env.OPENCLAW_AUTH_PROFILE_SECRET_KEY; + } else { + process.env.OPENCLAW_AUTH_PROFILE_SECRET_KEY = previousSecretKey; + } + if (previousNodeEnv === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = previousNodeEnv; + } + if (previousHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = previousHome; + } + if (previousXdgConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = previousXdgConfigHome; + } + if (previousAppData === undefined) { + delete process.env.APPDATA; + } else { + process.env.APPDATA = previousAppData; + } + if (previousUserProfile === undefined) { + delete process.env.USERPROFILE; + } else { + process.env.USERPROFILE = previousUserProfile; + } + fs.rmSync(stateDir, { recursive: true, force: true }); + fs.rmSync(homeDir, { recursive: true, force: true }); + fs.rmSync(configDir, { recursive: true, force: true }); + } + }); + + it("keeps fallback oauth key material outside an overlapping state tree", () => { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-profile-overlap-")); + const configDir = path.join(rootDir, "config"); + const stateDir = path.join(configDir, "openclaw"); + const homeDir = path.join(rootDir, "home"); + const agentDir = path.join(stateDir, "agents", "main", "agent"); + const previousStateDir = process.env.OPENCLAW_STATE_DIR; + const previousSecretKey = process.env.OPENCLAW_AUTH_PROFILE_SECRET_KEY; + const previousNodeEnv = process.env.NODE_ENV; + const previousHome = process.env.HOME; + const previousXdgConfigHome = process.env.XDG_CONFIG_HOME; + const previousAppData = process.env.APPDATA; + const previousUserProfile = process.env.USERPROFILE; + process.env.OPENCLAW_STATE_DIR = stateDir; + process.env.NODE_ENV = "production"; + process.env.HOME = homeDir; + process.env.XDG_CONFIG_HOME = configDir; + process.env.APPDATA = configDir; + process.env.USERPROFILE = homeDir; + delete process.env.OPENCLAW_AUTH_PROFILE_SECRET_KEY; + try { + fs.mkdirSync(agentDir, { recursive: true }); + const profileId = "openai-codex:default"; + saveAuthProfileStore( + { + version: AUTH_STORE_VERSION, + profiles: { + [profileId]: { + type: "oauth", + provider: "openai-codex", + access: "overlap-access-token", + refresh: "overlap-refresh-token", + expires: Date.now() + 60 * 60 * 1000, + }, + }, + }, + agentDir, + { filterExternalAuthProfiles: false }, + ); + + const keyPaths = findFilesNamed(rootDir, "auth-profile-secret-key"); + expect(keyPaths.length).toBeGreaterThan(0); + expect(keyPaths.every((keyPath) => !isPathInsideOrEqual(stateDir, keyPath))).toBe(true); + const keyValues = keyPaths.map((keyPath) => fs.readFileSync(keyPath, "utf8").trim()); + const persistedStateTree = readPersistedTree(stateDir); + expect(persistedStateTree).not.toContain("overlap-access-token"); + expect(persistedStateTree).not.toContain("overlap-refresh-token"); + for (const keyValue of keyValues) { + expect(persistedStateTree).not.toContain(keyValue); + } + } finally { + if (previousStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previousStateDir; + } + if (previousSecretKey === undefined) { + delete process.env.OPENCLAW_AUTH_PROFILE_SECRET_KEY; + } else { + process.env.OPENCLAW_AUTH_PROFILE_SECRET_KEY = previousSecretKey; + } + if (previousNodeEnv === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = previousNodeEnv; + } + if (previousHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = previousHome; + } + if (previousXdgConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = previousXdgConfigHome; + } + if (previousAppData === undefined) { + delete process.env.APPDATA; + } else { + process.env.APPDATA = previousAppData; + } + if (previousUserProfile === undefined) { + delete process.env.USERPROFILE; + } else { + process.env.USERPROFILE = previousUserProfile; + } + fs.rmSync(rootDir, { recursive: true, force: true }); + } + }); + + it("adopts an atomically-created fallback oauth key when another writer wins creation", () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-profile-key-race-")); + const agentDir = path.join(stateDir, "agents", "main", "agent"); + const homeDir = path.join(path.dirname(stateDir), "home"); + const configDir = path.join(path.dirname(stateDir), "external-config"); + const previousStateDir = process.env.OPENCLAW_STATE_DIR; + const previousSecretKey = process.env.OPENCLAW_AUTH_PROFILE_SECRET_KEY; + const previousNodeEnv = process.env.NODE_ENV; + const previousHome = process.env.HOME; + const previousXdgConfigHome = process.env.XDG_CONFIG_HOME; + const previousAppData = process.env.APPDATA; + const previousUserProfile = process.env.USERPROFILE; + const originalOpenSync = fs.openSync.bind(fs); + const originalWriteSync = fs.writeSync.bind(fs); + const originalCloseSync = fs.closeSync.bind(fs); + let injectedRace = false; + const openSpy = vi.spyOn(fs, "openSync").mockImplementation((file, flags, mode) => { + if ( + !injectedRace && + flags === "wx" && + typeof file === "string" && + path.basename(file) === "auth-profile-secret-key" + ) { + injectedRace = true; + fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 }); + const fd = originalOpenSync(file, "w", mode); + try { + originalWriteSync(fd, "raced-fallback-key\n", undefined, "utf8"); + } finally { + originalCloseSync(fd); + } + const err = new Error("file exists") as NodeJS.ErrnoException; + err.code = "EEXIST"; + throw err; + } + return originalOpenSync(file, flags, mode); }); - const lastUsed = store.usageStats?.["anthropic:default"]?.lastUsed; - expect(typeof lastUsed).toBe("number"); - expect(Number.isFinite(lastUsed)).toBe(true); - expect(lastUsed).toBeGreaterThanOrEqual(beforeUsed); - expect(lastUsed).toBeLessThanOrEqual(afterUsed); + process.env.OPENCLAW_STATE_DIR = stateDir; + process.env.NODE_ENV = "production"; + process.env.HOME = homeDir; + process.env.XDG_CONFIG_HOME = configDir; + process.env.APPDATA = configDir; + process.env.USERPROFILE = homeDir; + delete process.env.OPENCLAW_AUTH_PROFILE_SECRET_KEY; + try { + fs.mkdirSync(agentDir, { recursive: true }); + const profileId = "openai-codex:default"; + saveAuthProfileStore( + { + version: AUTH_STORE_VERSION, + profiles: { + [profileId]: { + type: "oauth", + provider: "openai-codex", + access: "race-access-token", + refresh: "race-refresh-token", + expires: Date.now() + 60 * 60 * 1000, + }, + }, + }, + agentDir, + { filterExternalAuthProfiles: false }, + ); + + expect(injectedRace).toBe(true); + clearRuntimeAuthProfileStoreSnapshots(); + expect( + loadAuthProfileStoreWithoutExternalProfiles(agentDir).profiles[profileId], + ).toMatchObject({ + type: "oauth", + provider: "openai-codex", + access: "race-access-token", + refresh: "race-refresh-token", + }); + } finally { + openSpy.mockRestore(); + if (previousStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previousStateDir; + } + if (previousSecretKey === undefined) { + delete process.env.OPENCLAW_AUTH_PROFILE_SECRET_KEY; + } else { + process.env.OPENCLAW_AUTH_PROFILE_SECRET_KEY = previousSecretKey; + } + if (previousNodeEnv === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = previousNodeEnv; + } + if (previousHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = previousHome; + } + if (previousXdgConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = previousXdgConfigHome; + } + if (previousAppData === undefined) { + delete process.env.APPDATA; + } else { + process.env.APPDATA = previousAppData; + } + if (previousUserProfile === undefined) { + delete process.env.USERPROFILE; + } else { + process.env.USERPROFILE = previousUserProfile; + } + fs.rmSync(stateDir, { recursive: true, force: true }); + fs.rmSync(homeDir, { recursive: true, force: true }); + fs.rmSync(configDir, { recursive: true, force: true }); + } + }); + + it("preserves access-only openai-codex oauth credentials when persisting refs", () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-profile-access-only-")); + const agentDir = path.join(stateDir, "agents", "main", "agent"); + const previousStateDir = process.env.OPENCLAW_STATE_DIR; + process.env.OPENCLAW_STATE_DIR = stateDir; + try { + fs.mkdirSync(agentDir, { recursive: true }); + const profileId = "openai-codex:default"; + const expires = Date.now() + 60 * 60 * 1000; + saveAuthProfileStore( + { + version: AUTH_STORE_VERSION, + profiles: { + [profileId]: { + type: "oauth", + provider: "openai-codex", + access: "access-only-token", + expires, + } as AuthProfileStore["profiles"][string], + }, + }, + agentDir, + { filterExternalAuthProfiles: false }, + ); + + const persisted = JSON.parse(fs.readFileSync(resolveAuthStorePath(agentDir), "utf8")) as { + profiles: Record>; + }; + const credential = persisted.profiles[profileId]; + expect(credential).toMatchObject({ + type: "oauth", + provider: "openai-codex", + expires, + oauthRef: { + source: "openclaw-credentials", + provider: "openai-codex", + id: expect.any(String), + }, + }); + expect(credential).not.toHaveProperty("access"); + expect(credential).not.toHaveProperty("refresh"); + expect(JSON.stringify(persisted)).not.toContain("access-only-token"); + + clearRuntimeAuthProfileStoreSnapshots(); + expect( + loadAuthProfileStoreWithoutExternalProfiles(agentDir).profiles[profileId], + ).toMatchObject({ + type: "oauth", + provider: "openai-codex", + access: "access-only-token", + }); + } finally { + if (previousStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previousStateDir; + } + fs.rmSync(stateDir, { recursive: true, force: true }); + } + }); + + it("removes detached openai-codex oauth secrets when profiles are deleted", () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-profile-delete-")); + const agentDir = path.join(stateDir, "agents", "main", "agent"); + const previousStateDir = process.env.OPENCLAW_STATE_DIR; + process.env.OPENCLAW_STATE_DIR = stateDir; + try { + fs.mkdirSync(agentDir, { recursive: true }); + const profileId = "openai-codex:default"; + saveAuthProfileStore( + { + version: AUTH_STORE_VERSION, + profiles: { + [profileId]: { + type: "oauth", + provider: "openai-codex", + access: "delete-access-token", + refresh: "delete-refresh-token", + expires: Date.now() + 60 * 60 * 1000, + }, + }, + }, + agentDir, + { filterExternalAuthProfiles: false }, + ); + + const persisted = JSON.parse(fs.readFileSync(resolveAuthStorePath(agentDir), "utf8")) as { + profiles: Record; + }; + const refId = persisted.profiles[profileId]?.oauthRef?.id; + expect(refId).toEqual(expect.any(String)); + const secretPath = resolvePersistedOAuthSecretPath(String(refId)); + const secretFile = fs.readFileSync(secretPath, "utf8"); + expect(secretFile).not.toContain("delete-access-token"); + expect(secretFile).not.toContain("delete-refresh-token"); + + saveAuthProfileStore( + { + version: AUTH_STORE_VERSION, + profiles: {}, + }, + agentDir, + { filterExternalAuthProfiles: false }, + ); + + expect(fs.existsSync(secretPath)).toBe(false); + expect(fs.readFileSync(resolveAuthStorePath(agentDir), "utf8")).not.toContain(profileId); + } finally { + if (previousStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previousStateDir; + } + fs.rmSync(stateDir, { recursive: true, force: true }); + } + }); + + it("regenerates openai-codex oauth refs for copied profile save targets", () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-profile-copy-ref-")); + const mainAgentDir = path.join(stateDir, "agents", "main", "agent"); + const copiedAgentDir = path.join(stateDir, "agents", "copied", "agent"); + const previousStateDir = process.env.OPENCLAW_STATE_DIR; + process.env.OPENCLAW_STATE_DIR = stateDir; + try { + fs.mkdirSync(mainAgentDir, { recursive: true }); + fs.mkdirSync(copiedAgentDir, { recursive: true }); + const originalProfileId = "openai-codex:default"; + const copiedProfileId = "openai-codex:copied"; + saveAuthProfileStore( + { + version: AUTH_STORE_VERSION, + profiles: { + [originalProfileId]: { + type: "oauth", + provider: "openai-codex", + access: "copy-access-token", + refresh: "copy-refresh-token", + expires: Date.now() + 60 * 60 * 1000, + copyToAgents: true, + }, + }, + }, + mainAgentDir, + { filterExternalAuthProfiles: false }, + ); + + const originalRefId = readPersistedOAuthRefId(mainAgentDir, originalProfileId); + const originalCredential = + loadAuthProfileStoreWithoutExternalProfiles(mainAgentDir).profiles[originalProfileId]; + expect(originalCredential?.type).toBe("oauth"); + if (!originalCredential || originalCredential.type !== "oauth") { + throw new Error("expected original oauth credential"); + } + saveAuthProfileStore( + { + version: AUTH_STORE_VERSION, + profiles: { + [copiedProfileId]: originalCredential, + }, + }, + copiedAgentDir, + { filterExternalAuthProfiles: false }, + ); + + const copiedRefId = readPersistedOAuthRefId(copiedAgentDir, copiedProfileId); + expect(copiedRefId).not.toBe(originalRefId); + const originalSecretPath = resolvePersistedOAuthSecretPath(originalRefId); + const copiedSecretPath = resolvePersistedOAuthSecretPath(copiedRefId); + const copiedSecretFile = fs.readFileSync(copiedSecretPath, "utf8"); + expect(copiedSecretFile).not.toContain("copy-access-token"); + expect(copiedSecretFile).not.toContain("copy-refresh-token"); + + saveAuthProfileStore( + { + version: AUTH_STORE_VERSION, + profiles: {}, + }, + mainAgentDir, + { filterExternalAuthProfiles: false }, + ); + + expect(fs.existsSync(originalSecretPath)).toBe(false); + expect(fs.existsSync(copiedSecretPath)).toBe(true); + clearRuntimeAuthProfileStoreSnapshots(); + expect( + loadAuthProfileStoreWithoutExternalProfiles(copiedAgentDir).profiles[copiedProfileId], + ).toMatchObject({ + type: "oauth", + provider: "openai-codex", + access: "copy-access-token", + refresh: "copy-refresh-token", + }); + } finally { + if (previousStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previousStateDir; + } + fs.rmSync(stateDir, { recursive: true, force: true }); + } + }); + + it("does not rewrite inline openai-codex oauth secrets from read-only lookup paths", () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-profile-readonly-")); + const agentDir = path.join(stateDir, "agents", "main", "agent"); + const previousStateDir = process.env.OPENCLAW_STATE_DIR; + const previousReadOnly = process.env.OPENCLAW_AUTH_STORE_READONLY; + process.env.OPENCLAW_STATE_DIR = stateDir; + try { + fs.mkdirSync(agentDir, { recursive: true }); + const profileId = "openai-codex:default"; + const expires = Date.now() + 60 * 60 * 1000; + fs.writeFileSync( + resolveAuthStorePath(agentDir), + `${JSON.stringify( + { + version: AUTH_STORE_VERSION, + profiles: { + [profileId]: { + type: "oauth", + provider: "openai-codex", + access: "readonly-access-token", + refresh: "readonly-refresh-token", + expires, + }, + }, + }, + null, + 2, + )}\n`, + ); + const before = fs.readFileSync(resolveAuthStorePath(agentDir), "utf8"); + + expect(findPersistedAuthProfileCredential({ agentDir, profileId })).toMatchObject({ + type: "oauth", + provider: "openai-codex", + access: "readonly-access-token", + refresh: "readonly-refresh-token", + }); + expect(fs.readFileSync(resolveAuthStorePath(agentDir), "utf8")).toBe(before); + + process.env.OPENCLAW_AUTH_STORE_READONLY = "1"; + clearRuntimeAuthProfileStoreSnapshots(); + expect( + loadAuthProfileStoreForRuntime(agentDir, { externalCli: { mode: "none" } }).profiles[ + profileId + ], + ).toMatchObject({ + type: "oauth", + provider: "openai-codex", + access: "readonly-access-token", + refresh: "readonly-refresh-token", + }); + expect(fs.readFileSync(resolveAuthStorePath(agentDir), "utf8")).toBe(before); + } finally { + if (previousStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previousStateDir; + } + if (previousReadOnly === undefined) { + delete process.env.OPENCLAW_AUTH_STORE_READONLY; + } else { + process.env.OPENCLAW_AUTH_STORE_READONLY = previousReadOnly; + } + fs.rmSync(stateDir, { recursive: true, force: true }); + } + }); + + it("does not repair legacy openai-codex oauth sidecars from read-only lookup paths", () => { + const stateDir = fs.mkdtempSync( + path.join(os.tmpdir(), "openclaw-auth-profile-readonly-sidecar-"), + ); + const agentDir = path.join(stateDir, "agents", "main", "agent"); + const previousStateDir = process.env.OPENCLAW_STATE_DIR; + const previousSecretKey = process.env.OPENCLAW_AUTH_PROFILE_SECRET_KEY; + const previousReadOnly = process.env.OPENCLAW_AUTH_STORE_READONLY; + process.env.OPENCLAW_STATE_DIR = stateDir; + process.env.OPENCLAW_AUTH_PROFILE_SECRET_KEY = "readonly-sidecar-secret-key"; + try { + fs.mkdirSync(agentDir, { recursive: true }); + const profileId = "openai-codex:default"; + saveAuthProfileStore( + { + version: AUTH_STORE_VERSION, + profiles: { + [profileId]: { + type: "oauth", + provider: "openai-codex", + access: "sidecar-access-token", + refresh: "sidecar-refresh-token", + expires: Date.now() + 60 * 60 * 1000, + }, + }, + }, + agentDir, + { filterExternalAuthProfiles: false }, + ); + const secretPath = resolvePersistedOAuthSecretPath( + readPersistedOAuthRefId(agentDir, profileId), + ); + const legacySidecar = `${JSON.stringify( + { + version: 1, + profileId, + provider: "openai-codex", + access: "legacy-sidecar-access", + refresh: "legacy-sidecar-refresh", + }, + null, + 2, + )}\n`; + fs.writeFileSync(secretPath, legacySidecar, "utf8"); + + process.env.OPENCLAW_AUTH_STORE_READONLY = "1"; + clearRuntimeAuthProfileStoreSnapshots(); + expect( + loadAuthProfileStoreForRuntime(agentDir, { + readOnly: true, + externalCli: { mode: "none" }, + }).profiles[profileId], + ).toMatchObject({ + type: "oauth", + provider: "openai-codex", + access: "legacy-sidecar-access", + refresh: "legacy-sidecar-refresh", + }); + expect(fs.readFileSync(secretPath, "utf8")).toBe(legacySidecar); + } finally { + if (previousStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previousStateDir; + } + if (previousSecretKey === undefined) { + delete process.env.OPENCLAW_AUTH_PROFILE_SECRET_KEY; + } else { + process.env.OPENCLAW_AUTH_PROFILE_SECRET_KEY = previousSecretKey; + } + if (previousReadOnly === undefined) { + delete process.env.OPENCLAW_AUTH_STORE_READONLY; + } else { + process.env.OPENCLAW_AUTH_STORE_READONLY = previousReadOnly; + } + fs.rmSync(stateDir, { recursive: true, force: true }); + } + }); + + it("rewrites existing inline openai-codex oauth secrets during runtime load", () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-profile-rewrite-")); + const agentDir = path.join(stateDir, "agents", "main", "agent"); + const previousStateDir = process.env.OPENCLAW_STATE_DIR; + process.env.OPENCLAW_STATE_DIR = stateDir; + try { + fs.mkdirSync(agentDir, { recursive: true }); + const profileId = "openai-codex:default"; + const expires = Date.now() + 60 * 60 * 1000; + fs.writeFileSync( + resolveAuthStorePath(agentDir), + `${JSON.stringify( + { + version: AUTH_STORE_VERSION, + profiles: { + [profileId]: { + type: "oauth", + provider: "openai-codex", + access: "existing-access-token", + refresh: "existing-refresh-token", + idToken: "existing-id-token", + expires, + accountId: "acct-existing", + }, + }, + order: { + "openai-codex": [profileId], + }, + }, + null, + 2, + )}\n`, + ); + + expect( + loadAuthProfileStoreForRuntime(agentDir, { externalCli: { mode: "none" } }).profiles[ + profileId + ], + ).toMatchObject({ + type: "oauth", + provider: "openai-codex", + access: "existing-access-token", + refresh: "existing-refresh-token", + idToken: "existing-id-token", + }); + + const persisted = JSON.parse(fs.readFileSync(resolveAuthStorePath(agentDir), "utf8")) as { + profiles: Record>; + order?: Record; + }; + const credential = persisted.profiles[profileId]; + expect(credential).toMatchObject({ + type: "oauth", + provider: "openai-codex", + expires, + accountId: "acct-existing", + oauthRef: { + source: "openclaw-credentials", + provider: "openai-codex", + id: expect.any(String), + }, + }); + expect(persisted.order?.["openai-codex"]).toEqual([profileId]); + expect(credential).not.toHaveProperty("access"); + expect(credential).not.toHaveProperty("refresh"); + expect(credential).not.toHaveProperty("idToken"); + const persistedStateTree = readPersistedTree(stateDir); + expect(persistedStateTree).not.toContain("existing-access-token"); + expect(persistedStateTree).not.toContain("existing-refresh-token"); + expect(persistedStateTree).not.toContain("existing-id-token"); + + clearRuntimeAuthProfileStoreSnapshots(); + expect( + loadAuthProfileStoreWithoutExternalProfiles(agentDir).profiles[profileId], + ).toMatchObject({ + type: "oauth", + provider: "openai-codex", + access: "existing-access-token", + refresh: "existing-refresh-token", + idToken: "existing-id-token", + }); + } finally { + if (previousStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previousStateDir; + } + fs.rmSync(stateDir, { recursive: true, force: true }); + } + }); + + it("does not rewrite inline openai-codex oauth secrets while the auth store lock is held", () => { + const stateDir = fs.mkdtempSync( + path.join(os.tmpdir(), "openclaw-auth-profile-locked-rewrite-"), + ); + const agentDir = path.join(stateDir, "agents", "main", "agent"); + const previousStateDir = process.env.OPENCLAW_STATE_DIR; + process.env.OPENCLAW_STATE_DIR = stateDir; + let lockFd: number | undefined; + try { + fs.mkdirSync(agentDir, { recursive: true }); + const profileId = "openai-codex:default"; + const authPath = resolveAuthStorePath(agentDir); + const expires = Date.now() + 60 * 60 * 1000; + fs.writeFileSync( + authPath, + `${JSON.stringify( + { + version: AUTH_STORE_VERSION, + profiles: { + [profileId]: { + type: "oauth", + provider: "openai-codex", + access: "locked-access-token", + refresh: "locked-refresh-token", + expires, + }, + }, + }, + null, + 2, + )}\n`, + ); + const before = fs.readFileSync(authPath, "utf8"); + const lockPath = resolveAuthStoreLockPath(authPath); + lockFd = fs.openSync(lockPath, "wx", 0o600); + fs.writeFileSync( + lockFd, + `${JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }, null, 2)}\n`, + "utf8", + ); + + expect( + loadAuthProfileStoreForRuntime(agentDir, { externalCli: { mode: "none" } }).profiles[ + profileId + ], + ).toMatchObject({ + type: "oauth", + provider: "openai-codex", + access: "locked-access-token", + refresh: "locked-refresh-token", + }); + + expect(fs.readFileSync(authPath, "utf8")).toBe(before); + } finally { + if (lockFd !== undefined) { + fs.closeSync(lockFd); + fs.rmSync(resolveAuthStoreLockPath(resolveAuthStorePath(agentDir)), { force: true }); + } + if (previousStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previousStateDir; + } + fs.rmSync(stateDir, { recursive: true, force: true }); + } + }); + + it("moves a relogin profile to the front of an existing per-agent provider order", async () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-order-promote-")); + const agentDir = path.join(stateDir, "agents", "main", "agent"); + const previousStateDir = process.env.OPENCLAW_STATE_DIR; + process.env.OPENCLAW_STATE_DIR = stateDir; + try { + fs.mkdirSync(agentDir, { recursive: true }); + const newProfileId = "openai-codex:bunsthedev@gmail.com"; + const staleProfileId = "openai-codex:val@viewdue.ai"; + saveAuthProfileStore( + { + version: AUTH_STORE_VERSION, + profiles: { + [newProfileId]: { + type: "oauth", + provider: "openai-codex", + access: "new-access", + refresh: "new-refresh", + expires: Date.now() + 60 * 60 * 1000, + }, + [staleProfileId]: { + type: "oauth", + provider: "openai-codex", + access: "stale-access", + refresh: "stale-refresh", + expires: Date.now() + 30 * 60 * 1000, + }, + }, + order: { + "openai-codex": [staleProfileId], + }, + }, + agentDir, + ); + + const updated = await promoteAuthProfileInOrder({ + agentDir, + provider: "openai-codex", + profileId: newProfileId, + }); + + expect(updated?.order?.["openai-codex"]).toEqual([newProfileId, staleProfileId]); + expect(loadAuthProfileStoreForRuntime(agentDir).order?.["openai-codex"]).toEqual([ + newProfileId, + staleProfileId, + ]); + } finally { + if (previousStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previousStateDir; + } + fs.rmSync(stateDir, { recursive: true, force: true }); + } }); }); diff --git a/src/agents/auth-profiles/store.ts b/src/agents/auth-profiles/store.ts index c07aa8b7334..ea88ee202ad 100644 --- a/src/agents/auth-profiles/store.ts +++ b/src/agents/auth-profiles/store.ts @@ -3,7 +3,7 @@ import path from "node:path"; import { isDeepStrictEqual } from "node:util"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { withFileLock } from "../../infra/file-lock.js"; -import { saveJsonFile } from "../../infra/json-file.js"; +import { loadJsonFile, saveJsonFile } from "../../infra/json-file.js"; import { cloneAuthProfileStore } from "./clone.js"; import { AUTH_STORE_LOCK_OPTIONS, @@ -31,6 +31,7 @@ import { loadPersistedAuthProfileStore, mergeAuthProfileStores, mergeOAuthFileIntoStore, + removeDetachedOAuthProfileSecrets, } from "./persisted.js"; import { clearRuntimeAuthProfileStoreSnapshots as clearRuntimeAuthProfileStoreSnapshotsImpl, @@ -478,7 +479,9 @@ export async function updateAuthProfileStoreWithLock(params: { } export function loadAuthProfileStore(): AuthProfileStore { - const asStore = loadPersistedAuthProfileStore(); + const asStore = loadPersistedAuthProfileStore(undefined, { + rewriteInlineOAuthSecrets: process.env.OPENCLAW_AUTH_STORE_READONLY !== "1", + }); if (asStore) { return overlayExternalAuthProfiles(asStore); } @@ -515,7 +518,9 @@ function loadAuthProfileStoreForAgent( return cached; } } - const asStore = loadPersistedAuthProfileStore(agentDir); + const asStore = loadPersistedAuthProfileStore(agentDir, { + rewriteInlineOAuthSecrets: !readOnly && process.env.OPENCLAW_AUTH_STORE_READONLY !== "1", + }); if (asStore) { const synced = maybeSyncPersistedExternalCliAuthProfiles({ store: asStore, @@ -745,8 +750,10 @@ export function saveAuthProfileStore( const authPath = resolveAuthStorePath(agentDir); const statePath = resolveAuthStatePath(agentDir); const localStore = buildLocalAuthProfileStoreForSave({ store, agentDir, options }); - const payload = buildPersistedAuthProfileSecretsStore(localStore); + const previousRaw = loadJsonFile(authPath); + const payload = buildPersistedAuthProfileSecretsStore(localStore, undefined, { agentDir }); saveJsonFile(authPath, payload); + removeDetachedOAuthProfileSecrets({ previousRaw, nextStore: payload }); savePersistedAuthProfileState(localStore, agentDir); writeCachedAuthProfileStore({ authPath, diff --git a/src/agents/auth-profiles/types.ts b/src/agents/auth-profiles/types.ts index a9a735f5748..99e17e8757b 100644 --- a/src/agents/auth-profiles/types.ts +++ b/src/agents/auth-profiles/types.ts @@ -16,6 +16,12 @@ export type OAuthCredentials = { idToken?: string; }; +export type OAuthCredentialRef = { + source: "openclaw-credentials"; + provider: "openai-codex"; + id: string; +}; + export type ApiKeyCredential = { type: "api_key"; provider: string; @@ -57,6 +63,7 @@ export type OAuthCredential = OAuthCredentials & { copyToAgents?: boolean; email?: string; displayName?: string; + oauthRef?: OAuthCredentialRef; }; export type AuthProfileCredential = ApiKeyCredential | TokenCredential | OAuthCredential; diff --git a/src/commands/agents.add.test.ts b/src/commands/agents.add.test.ts index 65b5c17f83b..0365a78ea21 100644 --- a/src/commands/agents.add.test.ts +++ b/src/commands/agents.add.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { AUTH_STORE_VERSION } from "../agents/auth-profiles/constants.js"; +import { saveAuthProfileStore } from "../agents/auth-profiles/store.js"; import { baseConfigSnapshot, createTestRuntime } from "./test-runtime-config-helpers.js"; const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn()); @@ -137,6 +138,68 @@ describe("agents add command", () => { } }); + it("copies portable Codex OAuth profiles without inline token material", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agents-add-oauth-copy-")); + const previousStateDir = process.env.OPENCLAW_STATE_DIR; + process.env.OPENCLAW_STATE_DIR = root; + try { + const sourceAgentDir = path.join(root, "main", "agent"); + const destAgentDir = path.join(root, "work", "agent"); + const destAuthPath = path.join(destAgentDir, "auth-profiles.json"); + await fs.mkdir(sourceAgentDir, { recursive: true }); + saveAuthProfileStore( + { + version: AUTH_STORE_VERSION, + profiles: { + "openai-codex:default": { + type: "oauth", + provider: "openai-codex", + access: "codex-copy-access-token", + refresh: "codex-copy-refresh-token", + expires: Date.now() + 60_000, + copyToAgents: true, + }, + }, + }, + sourceAgentDir, + ); + + const result = await __testing.copyPortableAuthProfiles({ + sourceAgentDir, + destAuthPath, + }); + + expect(result).toEqual({ copied: 1, skipped: 0 }); + const copiedRaw = await fs.readFile(destAuthPath, "utf8"); + expect(copiedRaw).not.toContain("codex-copy-access-token"); + expect(copiedRaw).not.toContain("codex-copy-refresh-token"); + const copied = JSON.parse(copiedRaw) as { + profiles: Record>; + }; + const credential = copied.profiles["openai-codex:default"]; + expect(credential).toMatchObject({ + type: "oauth", + provider: "openai-codex", + copyToAgents: true, + oauthRef: { + source: "openclaw-credentials", + provider: "openai-codex", + id: expect.any(String), + }, + }); + expect(credential).not.toHaveProperty("access"); + expect(credential).not.toHaveProperty("refresh"); + expect(credential).not.toHaveProperty("idToken"); + } finally { + if (previousStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previousStateDir; + } + await fs.rm(root, { recursive: true, force: true }); + } + }); + it("does not claim skipped OAuth profiles stay shared from a non-main source agent", () => { expect( __testing.formatSkippedOAuthProfilesMessage({ diff --git a/src/commands/agents.commands.add.ts b/src/commands/agents.commands.add.ts index 7b5eac50db6..e3f562d1e6f 100644 --- a/src/commands/agents.commands.add.ts +++ b/src/commands/agents.commands.add.ts @@ -10,7 +10,10 @@ import { ensureAuthProfileStore, } from "../agents/auth-profiles.js"; import { resolveAuthStorePath } from "../agents/auth-profiles/paths.js"; -import { loadPersistedAuthProfileStore } from "../agents/auth-profiles/persisted.js"; +import { + buildPersistedAuthProfileSecretsStore, + loadPersistedAuthProfileStore, +} from "../agents/auth-profiles/persisted.js"; import { formatCliCommand } from "../cli/command-format.js"; import { commitConfigWithPendingPluginInstalls } from "../cli/plugins-install-record-commit.js"; import { logConfigUpdated } from "../config/logging.js"; @@ -63,7 +66,12 @@ async function copyPortableAuthProfiles(params: { return { copied: 0, skipped: portable.skippedProfileIds.length }; } await fs.mkdir(path.dirname(params.destAuthPath), { recursive: true }); - saveJsonFile(params.destAuthPath, portable.store); + saveJsonFile( + params.destAuthPath, + buildPersistedAuthProfileSecretsStore(portable.store, undefined, { + agentDir: path.dirname(params.destAuthPath), + }), + ); return { copied: portable.copiedProfileIds.length, skipped: portable.skippedProfileIds.length, @@ -304,7 +312,10 @@ export async function agentsAddCommand( }); if (shouldCopy) { await fs.mkdir(path.dirname(destAuthPath), { recursive: true }); - saveJsonFile(destAuthPath, portable.store); + saveJsonFile( + destAuthPath, + buildPersistedAuthProfileSecretsStore(portable.store, undefined, { agentDir }), + ); const skippedText = portable.skippedProfileIds.length > 0 ? ` ${formatSkippedOAuthProfilesMessage({ diff --git a/src/commands/status-all/diagnosis.test.ts b/src/commands/status-all/diagnosis.test.ts index 0846f1df391..85216892dfe 100644 --- a/src/commands/status-all/diagnosis.test.ts +++ b/src/commands/status-all/diagnosis.test.ts @@ -1,16 +1,36 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ProgressReporter } from "../../cli/progress.js"; -vi.mock("../../daemon/restart-logs.js", () => ({ - resolveGatewayLogPaths: () => { +type GatewayLogPaths = { + logDir: string; + stdoutPath: string; + stderrPath: string; +}; + +const restartLogMocks = vi.hoisted(() => ({ + resolveGatewayLogPaths: vi.fn<() => GatewayLogPaths>(() => { throw new Error("skip log tail"); - }, - resolveGatewayRestartLogPath: () => "/tmp/gateway-restart.log", + }), + resolveGatewayRestartLogPath: vi.fn<() => string>(() => "/tmp/gateway-restart.log"), +})); + +const gatewayMocks = vi.hoisted(() => ({ + readFileTailLines: vi.fn<(filePath: string, maxLines: number) => Promise>( + async () => [], + ), + summarizeLogTail: vi.fn<(lines: string[], opts?: { maxLines?: number }) => string[]>( + (lines) => lines, + ), +})); + +vi.mock("../../daemon/restart-logs.js", () => ({ + resolveGatewayLogPaths: restartLogMocks.resolveGatewayLogPaths, + resolveGatewayRestartLogPath: restartLogMocks.resolveGatewayRestartLogPath, })); vi.mock("./gateway.js", () => ({ - readFileTailLines: vi.fn(async () => []), - summarizeLogTail: vi.fn(() => []), + readFileTailLines: gatewayMocks.readFileTailLines, + summarizeLogTail: gatewayMocks.summarizeLogTail, })); import { appendStatusAllDiagnosis } from "./diagnosis.js"; @@ -63,6 +83,15 @@ function createBaseParams( } describe("status-all diagnosis port checks", () => { + beforeEach(() => { + restartLogMocks.resolveGatewayLogPaths.mockImplementation(() => { + throw new Error("skip log tail"); + }); + restartLogMocks.resolveGatewayRestartLogPath.mockReturnValue("/tmp/gateway-restart.log"); + gatewayMocks.readFileTailLines.mockResolvedValue([]); + gatewayMocks.summarizeLogTail.mockImplementation((lines: string[]) => lines); + }); + it("labels OpenClaw Tailscale exposure separately from daemon state", async () => { const params = createBaseParams([]); params.tailscale.backendState = "Running"; @@ -145,4 +174,43 @@ describe("status-all diagnosis port checks", () => { expect(output).not.toContain("Channel issues skipped (gateway unreachable)"); expect(output).not.toContain("Gateway health:"); }); + + it("does not read or display stale stderr tails on Darwin", async () => { + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { value: "darwin" }); + try { + restartLogMocks.resolveGatewayLogPaths.mockReturnValue({ + logDir: "/tmp/openclaw/logs", + stdoutPath: "/tmp/openclaw/logs/gateway.log", + stderrPath: "/tmp/openclaw/logs/gateway.err.log", + }); + restartLogMocks.resolveGatewayRestartLogPath.mockReturnValue( + "/tmp/openclaw/logs/gateway-restart.log", + ); + gatewayMocks.readFileTailLines.mockImplementation(async (filePath: string) => { + if (filePath.endsWith("gateway.log")) { + return ["gateway stdout current"]; + } + if (filePath.endsWith("gateway.err.log")) { + return ["failed to bind gateway socket stale"]; + } + return []; + }); + const params = createBaseParams([]); + + await appendStatusAllDiagnosis(params); + + const output = params.lines.join("\n"); + expect(gatewayMocks.readFileTailLines).not.toHaveBeenCalledWith( + "/tmp/openclaw/logs/gateway.err.log", + 40, + ); + expect(output).toContain("# stdout: /tmp/openclaw/logs/gateway.log"); + expect(output).toContain("gateway stdout current"); + expect(output).not.toContain("# stderr:"); + expect(output).not.toContain("failed to bind gateway socket stale"); + } finally { + Object.defineProperty(process, "platform", { value: originalPlatform }); + } + }); }); diff --git a/src/commands/status-all/diagnosis.ts b/src/commands/status-all/diagnosis.ts index 12ff4bd9a4d..16492f3319c 100644 --- a/src/commands/status-all/diagnosis.ts +++ b/src/commands/status-all/diagnosis.ts @@ -226,17 +226,20 @@ export async function appendStatusAllDiagnosis(params: { if (logPaths) { params.progress.setLabel("Reading logs…"); const restartLogPath = resolveGatewayRestartLogPath(process.env); + const readStderr = process.platform !== "darwin"; const [stderrTail, stdoutTail, restartTail] = await Promise.all([ - readFileTailLines(logPaths.stderrPath, 40).catch(() => []), + readStderr ? readFileTailLines(logPaths.stderrPath, 40).catch(() => []) : [], readFileTailLines(logPaths.stdoutPath, 40).catch(() => []), readFileTailLines(restartLogPath, 30).catch(() => []), ]); if (stderrTail.length > 0 || stdoutTail.length > 0) { lines.push(""); lines.push(muted(`Gateway logs (tail, summarized): ${logPaths.logDir}`)); - lines.push(` ${muted(`# stderr: ${logPaths.stderrPath}`)}`); - for (const line of summarizeLogTail(stderrTail, { maxLines: 22 }).map(redactSecrets)) { - lines.push(` ${muted(line)}`); + if (readStderr) { + lines.push(` ${muted(`# stderr: ${logPaths.stderrPath}`)}`); + for (const line of summarizeLogTail(stderrTail, { maxLines: 22 }).map(redactSecrets)) { + lines.push(` ${muted(line)}`); + } } lines.push(` ${muted(`# stdout: ${logPaths.stdoutPath}`)}`); for (const line of summarizeLogTail(stdoutTail, { maxLines: 22 }).map(redactSecrets)) { diff --git a/src/config/io.audit.test.ts b/src/config/io.audit.test.ts index 7c554463a01..1ed993dd8d8 100644 --- a/src/config/io.audit.test.ts +++ b/src/config/io.audit.test.ts @@ -202,6 +202,36 @@ describe("config io audit helpers", () => { expect(written.nextHash).toBe("next-hash"); }); + it("redacts structured audit records before persistence", async () => { + const home = await suiteRootTracker.make("append-redacted"); + const record = finalizeConfigWriteAuditRecord({ + base: { + ...createAuditRecordBase(path.join(home, ".openclaw", "openclaw.json")), + suspicious: [ + "provider returned ya29.fake-access-token-with-enough-length", + "plugin returned AIzaSyD-very-real-looking-google-api-key-123", + ], + }, + result: "failed", + err: Object.assign(new Error("payload contained abcd-efgh-ijkl-mnop"), { code: "EFAIL" }), + }); + + await appendConfigAuditRecord({ + fs, + env: {} as NodeJS.ProcessEnv, + homedir: () => home, + record, + }); + + const raw = fs.readFileSync( + path.join(home, ".openclaw", "logs", "config-audit.jsonl"), + "utf-8", + ); + expect(raw).not.toContain("AIzaSyD-very-real-looking"); + expect(raw).not.toContain("ya29.fake-access-token"); + expect(raw).not.toContain("abcd-efgh-ijkl-mnop"); + }); + it("redacts argv values that follow known secret flag names", () => { const argv = [ "node", diff --git a/src/config/io.audit.ts b/src/config/io.audit.ts index fec3c9a9b39..3f27e321d6b 100644 --- a/src/config/io.audit.ts +++ b/src/config/io.audit.ts @@ -1,5 +1,5 @@ import path from "node:path"; -import { redactToolPayloadText } from "../logging/redact.js"; +import { redactSecrets, redactToolPayloadText } from "../logging/redact.js"; import { resolveStateDir } from "./paths.js"; const CONFIG_AUDIT_ARGV_CAP = 8; @@ -432,10 +432,10 @@ type ConfigAuditAppendParams = ConfigAuditAppendContext & function resolveConfigAuditAppendRecord(params: ConfigAuditAppendParams): ConfigAuditRecord { if ("record" in params) { - return params.record; + return redactSecrets(params.record); } const { fs: _fs, env: _env, homedir: _homedir, ...record } = params; - return record as ConfigAuditRecord; + return redactSecrets(record as ConfigAuditRecord); } export async function appendConfigAuditRecord(params: ConfigAuditAppendParams): Promise { diff --git a/src/config/sessions/transcript-append.ts b/src/config/sessions/transcript-append.ts index 39ff44f09d1..4abb19c7444 100644 --- a/src/config/sessions/transcript-append.ts +++ b/src/config/sessions/transcript-append.ts @@ -7,6 +7,7 @@ import { type SessionWriteLockAcquireTimeoutConfig, resolveSessionWriteLockAcquireTimeoutMs, } from "../../agents/session-write-lock.js"; +import { redactSecrets } from "../../logging/redact.js"; const TRANSCRIPT_APPEND_SCAN_CHUNK_BYTES = 64 * 1024; const SESSION_MANAGER_APPEND_MAX_BYTES = 8 * 1024 * 1024; @@ -290,7 +291,7 @@ async function appendSessionTranscriptMessageLocked(params: { id: messageId, ...(shouldRawAppend ? {} : { parentId: leafInfo.leafId ?? null }), timestamp: new Date(now).toISOString(), - message: params.message, + message: redactSecrets(params.message), }; await fs.appendFile(params.transcriptPath, `${JSON.stringify(entry)}\n`, "utf-8"); return { messageId }; diff --git a/src/config/sessions/transcript.test.ts b/src/config/sessions/transcript.test.ts index 61c958745d0..b00d834ab73 100644 --- a/src/config/sessions/transcript.test.ts +++ b/src/config/sessions/transcript.test.ts @@ -423,6 +423,40 @@ describe("appendAssistantMessageToSessionTranscript", () => { } }); + it("redacts structured message content before transcript persistence", async () => { + const sessionFile = resolveSessionTranscriptPathInDir( + "redacted-transcript-session", + fixture.sessionsDir(), + ); + + await appendSessionTranscriptMessage({ + transcriptPath: sessionFile, + message: { + role: "user", + content: [ + { + type: "text", + text: "standalone app password abcd-efgh-ijkl-mnop", + }, + { + type: "text", + text: "tokens ya29.fake-access-token-with-enough-length", + }, + ], + toolInput: { + apiKey: "AIzaSyD-very-real-looking-google-api-key-123", + refresh: "1//0fake-refresh-token-with-enough-length", + }, + }, + }); + + const raw = fs.readFileSync(sessionFile, "utf-8"); + expect(raw).not.toContain("ya29.fake-access-token"); + expect(raw).not.toContain("abcd-efgh-ijkl-mnop"); + expect(raw).not.toContain("AIzaSyD-very-real-looking"); + expect(raw).not.toContain("1//0fake-refresh-token"); + }); + it("migrates small linear transcripts before appending", async () => { const sessionFile = resolveSessionTranscriptPathInDir( "small-linear-session", diff --git a/src/daemon/diagnostics.test.ts b/src/daemon/diagnostics.test.ts new file mode 100644 index 00000000000..4a7091eb1bc --- /dev/null +++ b/src/daemon/diagnostics.test.ts @@ -0,0 +1,35 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { readLastGatewayErrorLine } from "./diagnostics.js"; +import { resolveGatewayLogPaths } from "./restart-logs.js"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +function makeTempStateDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-daemon-diagnostics-")); + tempDirs.push(dir); + return dir; +} + +describe("readLastGatewayErrorLine", () => { + it("ignores stale launchd stderr when stderr is suppressed", async () => { + const stateDir = makeTempStateDir(); + const env = { OPENCLAW_STATE_DIR: stateDir }; + const { logDir, stdoutPath, stderrPath } = resolveGatewayLogPaths(env); + fs.mkdirSync(logDir, { recursive: true }); + fs.writeFileSync(stderrPath, "failed to bind gateway socket stale\n", "utf8"); + fs.writeFileSync(stdoutPath, "gateway stdout current\n", "utf8"); + + await expect(readLastGatewayErrorLine(env, { platform: "darwin" })).resolves.toBe( + "gateway stdout current", + ); + }); +}); diff --git a/src/daemon/diagnostics.ts b/src/daemon/diagnostics.ts index 03d1bdd0688..f1480418cf3 100644 --- a/src/daemon/diagnostics.ts +++ b/src/daemon/diagnostics.ts @@ -24,9 +24,13 @@ async function readLastLogLine(filePath: string): Promise { } } -export async function readLastGatewayErrorLine(env: NodeJS.ProcessEnv): Promise { +export async function readLastGatewayErrorLine( + env: NodeJS.ProcessEnv, + options?: { platform?: NodeJS.Platform }, +): Promise { + const readStderr = (options?.platform ?? process.platform) !== "darwin"; const { stdoutPath, stderrPath } = resolveGatewayLogPaths(env); - const stderrRaw = await fs.readFile(stderrPath, "utf8").catch(() => ""); + const stderrRaw = readStderr ? await fs.readFile(stderrPath, "utf8").catch(() => "") : ""; const stdoutRaw = await fs.readFile(stdoutPath, "utf8").catch(() => ""); const lines = [...stderrRaw.split(/\r?\n/), ...stdoutRaw.split(/\r?\n/)].map((line) => line.trim(), @@ -40,5 +44,7 @@ export async function readLastGatewayErrorLine(env: NodeJS.ProcessEnv): Promise< return line; } } - return (await readLastLogLine(stderrPath)) ?? (await readLastLogLine(stdoutPath)); + return readStderr + ? ((await readLastLogLine(stderrPath)) ?? (await readLastLogLine(stdoutPath))) + : await readLastLogLine(stdoutPath); } diff --git a/src/daemon/launchd.test.ts b/src/daemon/launchd.test.ts index 6e65397804a..c26ddba2d97 100644 --- a/src/daemon/launchd.test.ts +++ b/src/daemon/launchd.test.ts @@ -614,6 +614,7 @@ describe("launchd install", () => { const plist = state.files.get(plistPath) ?? ""; expect(plist).toContain("StandardOutPath"); expect(plist).toContain("StandardErrorPath"); + expect(plist).toContain("/dev/null"); expect(plist).toContain("KeepAlive"); expect(plist).toContain("node"); const rewriteIndex = state.fileWrites.findIndex((write) => write.path === plistPath); diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts index 54c055f74fc..41fd4aeddbc 100644 --- a/src/daemon/launchd.ts +++ b/src/daemon/launchd.ts @@ -41,6 +41,7 @@ const LAUNCH_AGENT_PRIVATE_DIR_MODE = 0o700; const LAUNCH_AGENT_ENV_FILE_MODE = 0o600; const LAUNCH_AGENT_ENV_WRAPPER_MODE = 0o700; const LAUNCH_AGENT_ENV_DIR_NAME = "service-env"; +const LAUNCH_AGENT_STDERR_PATH = "/dev/null"; function assertValidLaunchAgentLabel(label: string): string { const trimmed = label.trim(); @@ -665,7 +666,7 @@ async function writeLaunchAgentPlist({ environment, description, }: Omit): Promise<{ plistPath: string; stdoutPath: string }> { - const { logDir, stdoutPath, stderrPath } = resolveGatewayLogPaths(env); + const { logDir, stdoutPath } = resolveGatewayLogPaths(env); await ensureSecureDirectory(logDir); const domain = resolveGuiDomain(); @@ -702,7 +703,7 @@ async function writeLaunchAgentPlist({ programArguments: prepared.programArguments, workingDirectory, stdoutPath, - stderrPath, + stderrPath: LAUNCH_AGENT_STDERR_PATH, environment: prepared.inlineEnvironment, }); await fs.writeFile(plistPath, plist, { encoding: "utf8", mode: LAUNCH_AGENT_PLIST_MODE }); @@ -774,7 +775,7 @@ async function rewriteLaunchAgentPlistForRestart({ return; } - const { logDir, stdoutPath, stderrPath } = resolveGatewayLogPaths(env); + const { logDir, stdoutPath } = resolveGatewayLogPaths(env); await ensureSecureDirectory(logDir); const serviceDescription = resolveGatewayServiceDescription({ @@ -793,7 +794,7 @@ async function rewriteLaunchAgentPlistForRestart({ programArguments: prepared.programArguments, workingDirectory: existing.workingDirectory, stdoutPath, - stderrPath, + stderrPath: LAUNCH_AGENT_STDERR_PATH, environment: prepared.inlineEnvironment, }); await fs.writeFile(plistPath, plist, { encoding: "utf8", mode: LAUNCH_AGENT_PLIST_MODE }); diff --git a/src/daemon/runtime-hints.test.ts b/src/daemon/runtime-hints.test.ts index 5fe27678ef6..77137721683 100644 --- a/src/daemon/runtime-hints.test.ts +++ b/src/daemon/runtime-hints.test.ts @@ -15,7 +15,7 @@ describe("buildPlatformRuntimeLogHints", () => { }), ).toEqual([ "Launchd stdout (if installed): /tmp/openclaw-state/logs/gateway.log", - "Launchd stderr (if installed): /tmp/openclaw-state/logs/gateway.err.log", + "Launchd stderr (if installed): suppressed", "Restart attempts: /tmp/openclaw-state/logs/gateway-restart.log", ]); }); diff --git a/src/daemon/runtime-hints.ts b/src/daemon/runtime-hints.ts index 33750ad5211..58850017d36 100644 --- a/src/daemon/runtime-hints.ts +++ b/src/daemon/runtime-hints.ts @@ -17,7 +17,7 @@ export function buildPlatformRuntimeLogHints(params: { const logs = resolveGatewayLogPaths(env); return [ `Launchd stdout (if installed): ${toDarwinDisplayPath(logs.stdoutPath)}`, - `Launchd stderr (if installed): ${toDarwinDisplayPath(logs.stderrPath)}`, + "Launchd stderr (if installed): suppressed", `Restart attempts: ${toDarwinDisplayPath(resolveGatewayRestartLogPath(env))}`, ]; } diff --git a/src/daemon/runtime-hints.windows-paths.test.ts b/src/daemon/runtime-hints.windows-paths.test.ts index b5a93797aef..9374d85d676 100644 --- a/src/daemon/runtime-hints.windows-paths.test.ts +++ b/src/daemon/runtime-hints.windows-paths.test.ts @@ -30,7 +30,7 @@ describe("buildPlatformRuntimeLogHints", () => { }), ).toEqual([ "Launchd stdout (if installed): /tmp/openclaw-state/logs/gateway.log", - "Launchd stderr (if installed): /tmp/openclaw-state/logs/gateway.err.log", + "Launchd stderr (if installed): suppressed", "Restart attempts: /tmp/openclaw-state/logs/gateway-restart.log", ]); }); diff --git a/src/logging/diagnostic-support-redaction.ts b/src/logging/diagnostic-support-redaction.ts index caa926f8c7e..efdd91603cd 100644 --- a/src/logging/diagnostic-support-redaction.ts +++ b/src/logging/diagnostic-support-redaction.ts @@ -281,8 +281,8 @@ function redactKnownPathPrefixesForSupport( } export function redactTextForSupport(value: string): string { - let redacted = redactSensitiveTextForSupport(value); - redacted = redactCommonCredentialTextForSupport(redacted); + let redacted = redactCommonCredentialTextForSupport(value); + redacted = redactSensitiveTextForSupport(redacted); redacted = redactUrlSecretsForSupport(redacted); redacted = redactServiceIdentifiersForSupport(redacted); redacted = redactContactIdentifiersForSupport(redacted); diff --git a/src/logging/logger-redaction-behavior.test.ts b/src/logging/logger-redaction-behavior.test.ts index dc0a538a936..5579f91e87a 100644 --- a/src/logging/logger-redaction-behavior.test.ts +++ b/src/logging/logger-redaction-behavior.test.ts @@ -9,6 +9,7 @@ import { import { getChildLogger, getLogger, resetLogger, setLoggerOverride } from "../logging.js"; import { createSuiteLogPathTracker } from "./log-test-helpers.js"; import { __test__ as loggerTest } from "./logger.js"; +import { createDiagnosticLogRecordCapture } from "./test-helpers/diagnostic-log-capture.js"; const secret = "sk-testsecret1234567890abcd"; const TRACE_ID = "4bf92f3577b34da6a3ce929d0e0e4736"; @@ -71,6 +72,58 @@ describe("file log redaction", () => { expect(content).not.toContain(secret); }); + it("redacts sensitive structured fields before emitting diagnostic log records", async () => { + const logPath = logPathTracker.nextPath(); + setLoggerOverride({ level: "info", file: logPath }); + const capture = createDiagnosticLogRecordCapture(); + try { + getLogger().info( + { + password: "hunter2", + token: "token-value-1234567890", + }, + "credential diagnostic", + ); + await capture.flush(); + + const serialized = JSON.stringify(capture.records); + expect(serialized).toContain("credential diagnostic"); + expect(serialized).not.toContain("hunter2"); + expect(serialized).not.toContain("token-value-1234567890"); + expect(capture.records.at(-1)?.attributes?.password).toBe("***"); + } finally { + capture.cleanup(); + } + }); + + it("honors logging redaction opt-out for structured file log fields", () => { + const logPath = logPathTracker.nextPath(); + const configPath = logPathTracker.nextPath(); + fs.writeFileSync( + configPath, + JSON.stringify({ + logging: { + redactSensitive: "off", + }, + }), + ); + process.env.OPENCLAW_CONFIG_PATH = configPath; + setLoggerOverride({ level: "info", file: logPath }); + + getLogger().info({ + token: "token-value-1234567890", + access: "ya29.fake-access-token-with-enough-length", + password: "abcd-efgh-ijkl-mnop", + message: `Authorization: Bearer ${secret}`, + }); + + const content = fs.readFileSync(logPath, "utf8"); + expect(content).toContain("token-value-1234567890"); + expect(content).toContain("ya29.fake-access-token-with-enough-length"); + expect(content).toContain("abcd-efgh-ijkl-mnop"); + expect(content).toContain(secret); + }); + it("uses logging.file from the active config path", () => { const logPath = logPathTracker.nextPath(); const configPath = logPathTracker.nextPath(); diff --git a/src/logging/logger.ts b/src/logging/logger.ts index 79c8ffb54fc..6e818abd093 100644 --- a/src/logging/logger.ts +++ b/src/logging/logger.ts @@ -21,7 +21,7 @@ import { import { readLoggingConfig, shouldSkipMutatingLoggingConfigRead } from "./config.js"; import { resolveEnvLogLevelOverride } from "./env-log-level.js"; import { type LogLevel, levelToMinLevel, normalizeLogLevel } from "./levels.js"; -import { redactSensitiveText } from "./redact.js"; +import { redactSecrets, redactSensitiveText } from "./redact.js"; import { loggingState } from "./state.js"; import { formatTimestamp } from "./timestamps.js"; import type { LoggerSettings } from "./types.js"; @@ -444,10 +444,20 @@ function buildDiagnosticLogRecord(logObj: TsLogRecord) { }; } +function isLogRedactionDisabled(): boolean { + return readLoggingConfig()?.redactSensitive === "off"; +} + +function redactLogRecordForTransport(record: T): T { + return isLogRedactionDisabled() ? record : redactSecrets(record); +} + function attachDiagnosticEventTransport(logger: TsLogger): void { logger.attachTransport((logObj: LogObj) => { try { - emitDiagnosticEvent(buildDiagnosticLogRecord(logObj as TsLogRecord)); + emitDiagnosticEvent( + buildDiagnosticLogRecord(redactLogRecordForTransport(logObj) as TsLogRecord), + ); } catch { // never block on logging failures } @@ -518,6 +528,8 @@ export function isFileLogLevelEnabled(level: LogLevel): boolean { function buildLogger(settings: ResolvedSettings): TsLogger { const logger = new TsLogger({ name: "openclaw", + // Custom structured redaction runs at each transport boundary; avoid tslog pre-masking divergent records. + maskValuesOfKeys: [], minLevel: levelToMinLevel(settings.level), type: "hidden", // no ansi formatting }); @@ -552,9 +564,8 @@ function buildLogger(settings: ResolvedSettings): TsLogger { const time = formatTimestamp(logObj.date ?? new Date(), { style: "long" }); const traceFields = buildTraceFileLogFields(logObj as TsLogRecord); const structuredFields = buildStructuredFileLogFields(logObj as TsLogRecord); - const line = redactSensitiveText( - JSON.stringify({ ...logObj, time, ...structuredFields, ...traceFields }), - ); + const record = { ...logObj, time, ...structuredFields, ...traceFields }; + const line = redactSensitiveText(JSON.stringify(redactLogRecordForTransport(record))); const payload = `${line}\n`; const payloadBytes = Buffer.byteLength(payload, "utf8"); const nextBytes = currentFileBytes + payloadBytes; diff --git a/src/logging/redact.test.ts b/src/logging/redact.test.ts index f8da09ffe75..ac102702c10 100644 --- a/src/logging/redact.test.ts +++ b/src/logging/redact.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { getDefaultRedactPatterns, + redactSecrets, redactSensitiveFieldValue, redactSensitiveLines, redactSensitiveText, @@ -356,6 +357,38 @@ describe("redactSensitiveText", () => { expect(output).toBe("r8_ABC…stuv"); }); + it("masks OAuth and JWT token shapes", () => { + const input = [ + "ya29.fake-access-token-with-enough-length", + "1//0fake-refresh-token-with-enough-length", + "eyJheaderabcd.eyJpayloadabcd.signatureabcd123456", + ].join(" "); + const output = redactSensitiveText(input, { + mode: "tools", + patterns: defaults, + }); + expect(output).not.toContain("ya29.fake-access-token"); + expect(output).not.toContain("1//0fake-refresh-token"); + expect(output).not.toContain("eyJheaderabcd.eyJpayloadabcd.signatureabcd123456"); + }); + + it("masks app-specific password shapes only in secret contexts", () => { + const input = [ + "password=abcd-efgh-ijkl-mnop", + "--password qrst-uvwx-yzab-cdef", + '{"password":"lmno-pqrs-tuvw-xyza"}', + "main-test-case-name", + ].join(" "); + const output = redactSensitiveText(input, { + mode: "tools", + patterns: defaults, + }); + expect(output).not.toContain("abcd-efgh-ijkl-mnop"); + expect(output).not.toContain("qrst-uvwx-yzab-cdef"); + expect(output).not.toContain("lmno-pqrs-tuvw-xyza"); + expect(output).toContain("main-test-case-name"); + }); + it("skips redaction when mode is off", () => { const input = "OPENAI_API_KEY=sk-1234567890abcdef"; const output = redactSensitiveText(input, { @@ -406,6 +439,65 @@ describe("redactSensitiveText", () => { }); }); +describe("redactSecrets", () => { + it("redacts nested structured payloads before JSON persistence", () => { + const input = { + plugin: { + config: { + apiKey: "AIzaSyD-very-real-looking-google-api-key-123", + access: "ya29.fake-access-token-with-enough-length", + refresh: "1//0fake-refresh-token-with-enough-length", + password: "abcd-efgh-ijkl-mnop", + }, + }, + transcript: [ + { + text: "jwt eyJheaderabcd.eyJpayloadabcd.signatureabcd123456 and main-test-case-name", + }, + { + text: "standalone app password abcd-efgh-ijkl-mnop", + errorMessage: "failed with app password qrst-uvwx-yzab-cdef", + }, + ], + }; + + const output = redactSecrets(input); + const serialized = JSON.stringify(output); + expect(serialized).not.toContain("AIzaSyD-very-real-looking"); + expect(serialized).not.toContain("ya29.fake-access-token"); + expect(serialized).not.toContain("1//0fake-refresh-token"); + expect(serialized).not.toContain("eyJheaderabcd.eyJpayloadabcd.signatureabcd123456"); + expect(serialized).not.toContain("abcd-efgh-ijkl-mnop"); + expect(serialized).not.toContain("qrst-uvwx-yzab-cdef"); + expect(serialized).toContain("main-test-case-name"); + }); + + it("preserves benign bare access and refresh fields", () => { + const output = redactSecrets({ + permissions: { + access: "read", + refresh: "monthly", + }, + oauth: { + access: "ya29.fake-access-token-with-enough-length", + refresh: "1//0fake-refresh-token-with-enough-length", + accessToken: "opaque-access-token-value", + refreshToken: "opaque-refresh-token-value", + }, + }); + + expect(output.permissions).toEqual({ + access: "read", + refresh: "monthly", + }); + const serialized = JSON.stringify(output); + expect(serialized).not.toContain("ya29.fake-access-token"); + expect(serialized).not.toContain("1//0fake-refresh-token"); + expect(serialized).not.toContain("opaque-access-token-value"); + expect(serialized).not.toContain("opaque-refresh-token-value"); + }); +}); + describe("redactSensitiveLines", () => { it("redacts matching content across all lines", () => { const resolved = resolveRedactOptions({ mode: "tools", patterns: defaults }); diff --git a/src/logging/redact.ts b/src/logging/redact.ts index 6f6e2aa37c0..a6149479ecb 100644 --- a/src/logging/redact.ts +++ b/src/logging/redact.ts @@ -14,9 +14,24 @@ const PAYMENT_CREDENTIAL_ENV_KEYS = String.raw`CARD[_-]?NUMBER|CARD[_-]?CVC|CARD const PAYMENT_CREDENTIAL_QUERY_KEYS = String.raw`card[-_]?number|card[-_]?cvc|card[-_]?cvv|cvc|cvv|security[-_]?code|payment[-_]?credential|shared[-_]?payment[-_]?token`; const PAYMENT_CREDENTIAL_JSON_KEYS = String.raw`cardNumber|card_number|cardCvc|card_cvc|cardCvv|card_cvv|cvc|cvv|securityCode|security_code|paymentCredential|payment_credential|sharedPaymentToken|shared_payment_token`; const STRUCTURED_SECRET_FIELD_RE = new RegExp( - String.raw`^(?:api[-_]?key|apiKey|token|secret|password|passwd|access[-_]?token|accessToken|refresh[-_]?token|refreshToken|auth[-_]?token|authToken|client[-_]?secret|clientSecret|app[-_]?secret|appSecret|${PAYMENT_CREDENTIAL_QUERY_KEYS}|${PAYMENT_CREDENTIAL_JSON_KEYS})$`, + String.raw`^(?:api[-_]?key|apiKey|token|secret|password|passwd|access[-_]?token|accessToken|refresh[-_]?token|refreshToken|id[-_]?token|idToken|client[-_]?secret|clientSecret|${PAYMENT_CREDENTIAL_QUERY_KEYS}|${PAYMENT_CREDENTIAL_JSON_KEYS})$`, "i", ); +const STRUCTURED_APP_PASSWORD_FIELD_RE = + /^(?:apple|icloud|app[-_]?specific[-_]?password|appSpecificPassword|application[-_]?password|text|content|message|error|errorMessage|detail|details|reason)$/i; +const APP_SPECIFIC_PASSWORD_RE = /\b([a-z]{4}-[a-z]{4}-[a-z]{4}-[a-z]{4})\b/g; +const BENIGN_APP_PASSWORD_WORDS = new Set([ + "case", + "claw", + "demo", + "file", + "main", + "name", + "open", + "path", + "slug", + "test", +]); const DEFAULT_REDACT_PATTERNS: string[] = [ // ENV-style assignments. Keep this case-sensitive so diagnostics like @@ -49,6 +64,9 @@ const DEFAULT_REDACT_PATTERNS: string[] = [ String.raw`\b(xapp-[A-Za-z0-9-]{10,})\b`, String.raw`\b(gsk_[A-Za-z0-9_-]{10,})\b`, String.raw`\b(AIza[0-9A-Za-z\-_]{20,})\b`, + String.raw`\b(ya29\.[0-9A-Za-z_\-./+=]{10,})\b`, + String.raw`\b(1//0[0-9A-Za-z_\-./+=]{10,})\b`, + String.raw`\b(eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,})\b`, String.raw`\b(pplx-[A-Za-z0-9_-]{10,})\b`, String.raw`\b(npm_[A-Za-z0-9]{10,})\b`, // Additional access-key and token-style prefixes. @@ -137,6 +155,16 @@ function redactText(text: string, patterns: RegExp[]): string { return next; } +function looksLikeAppSpecificPassword(candidate: string): boolean { + return candidate.split("-").every((part) => !BENIGN_APP_PASSWORD_WORDS.has(part.toLowerCase())); +} + +function redactAppSpecificPasswords(text: string): string { + return replacePatternBounded(text, APP_SPECIFIC_PASSWORD_RE, (match: string, token: string) => + looksLikeAppSpecificPassword(token) ? redactMatch(match, [token]) : match, + ); +} + function resolveConfigRedaction(): RedactOptions { const cfg = readLoggingConfig(); return { @@ -182,6 +210,16 @@ export function redactToolDetail(detail: string): string { return redactSensitiveText(detail, resolved); } +function resolveToolPayloadRedaction(): RedactOptions { + const cfg = readLoggingConfig(); + const userPatterns = cfg?.redactPatterns; + const patterns = + userPatterns && userPatterns.length > 0 + ? [...userPatterns, ...DEFAULT_REDACT_PATTERNS] + : undefined; + return { mode: "tools", patterns }; +} + // Forces tools-mode regardless of `logging.redactSensitive` (which governs log // output, not UI surfaces), and merges user `logging.redactPatterns` with the // built-in defaults so both apply. @@ -189,18 +227,22 @@ export function redactToolPayloadText(text: string): string { if (!text) { return text; } - const cfg = readLoggingConfig(); - const userPatterns = cfg?.redactPatterns; - const patterns = - userPatterns && userPatterns.length > 0 - ? [...userPatterns, ...DEFAULT_REDACT_PATTERNS] - : undefined; - return redactSensitiveText(text, { mode: "tools", patterns }); + return redactSensitiveText(text, resolveToolPayloadRedaction()); } -export function redactSensitiveFieldValue(key: string, value: string): string { - const redacted = redactToolPayloadText(value); - if (redacted !== value) { +function redactSensitiveFieldValueWithOptions( + key: string, + value: string, + options: RedactOptions, +): string { + const redacted = redactSensitiveText(value, options); + const shouldRedactAppPassword = redacted !== value || STRUCTURED_APP_PASSWORD_FIELD_RE.test(key); + if (shouldRedactAppPassword) { + const appRedacted = redactAppSpecificPasswords(redacted); + if (appRedacted !== value) { + return appRedacted; + } + } else if (redacted !== value) { return redacted; } if (STRUCTURED_SECRET_FIELD_RE.test(key)) { @@ -209,6 +251,71 @@ export function redactSensitiveFieldValue(key: string, value: string): string { return value; } +export function redactSensitiveFieldValue(key: string, value: string): string { + return redactSensitiveFieldValueWithOptions(key, value, resolveToolPayloadRedaction()); +} + +function isPlainRedactableObject(value: object): value is Record { + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function redactStructuredSecretValue( + key: string, + value: unknown, + seen: WeakSet, + options: RedactOptions, +): unknown { + if (typeof value === "string") { + return redactSensitiveFieldValueWithOptions(key, value, options); + } + if (value === null || value === undefined) { + return value; + } + if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { + return value; + } + if (Array.isArray(value)) { + if (seen.has(value)) { + return "[Circular]"; + } + seen.add(value); + const out = value.map((entry) => redactStructuredSecretValue(key, entry, seen, options)); + seen.delete(value); + return out; + } + if (typeof value === "object") { + if (seen.has(value)) { + return "[Circular]"; + } + if (!isPlainRedactableObject(value)) { + return value; + } + seen.add(value); + const out: Record = {}; + for (const [nestedKey, nestedValue] of Object.entries(value)) { + out[nestedKey] = redactStructuredSecretValue(nestedKey, nestedValue, seen, options); + } + seen.delete(value); + return out; + } + return value; +} + +export function redactSecrets(value: T): T { + const options = resolveToolPayloadRedaction(); + if (typeof value === "string") { + return redactSensitiveText(value, options) as T; + } + if (value === null || value === undefined) { + return value; + } + if (typeof value !== "object") { + return value; + } + return redactStructuredSecretValue("", value, new WeakSet(), options) as T; +} + export function getDefaultRedactPatterns(): string[] { return [...DEFAULT_REDACT_PATTERNS]; } diff --git a/src/trajectory/runtime.test.ts b/src/trajectory/runtime.test.ts index b7f82861da2..c5aa9077211 100644 --- a/src/trajectory/runtime.test.ts +++ b/src/trajectory/runtime.test.ts @@ -80,6 +80,8 @@ describe("trajectory runtime", () => { systemPrompt: "system prompt", headers: [{ name: "Authorization", value: "Bearer sk-test-secret-token" }], command: "curl -H 'Authorization: Bearer sk-other-secret-token'", + oauth: "ya29.fake-access-token-with-enough-length", + apple: "abcd-efgh-ijkl-mnop", tools: toTrajectoryToolDefinitions([ { name: "z-tool", parameters: { z: 1 } }, { name: "a-tool", description: "alpha", parameters: { a: 1 } }, @@ -98,6 +100,8 @@ describe("trajectory runtime", () => { ]); expect(JSON.stringify(parsed.data)).not.toContain("sk-test-secret-token"); expect(JSON.stringify(parsed.data)).not.toContain("sk-other-secret-token"); + expect(JSON.stringify(parsed.data)).not.toContain("ya29.fake-access-token"); + expect(JSON.stringify(parsed.data)).not.toContain("abcd-efgh-ijkl-mnop"); }); it("bounds large runtime event fields before serialization", () => { diff --git a/src/trajectory/runtime.ts b/src/trajectory/runtime.ts index 875f70a4266..0329389090a 100644 --- a/src/trajectory/runtime.ts +++ b/src/trajectory/runtime.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { sanitizeDiagnosticPayload } from "../agents/payload-redaction.js"; import { getQueuedFileWriter, type QueuedFileWriter } from "../agents/queued-file-writer.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { redactSecrets } from "../logging/redact.js"; import { parseBooleanValue } from "../utils/boolean.js"; import { safeJsonStringify } from "../utils/safe-json.js"; import { @@ -201,7 +202,10 @@ function limitTrajectoryPayloadValue( } function sanitizeTrajectoryPayload(data: Record): Record { - return sanitizeDiagnosticPayload(limitTrajectoryPayloadValue(data)) as Record; + return redactSecrets(sanitizeDiagnosticPayload(limitTrajectoryPayloadValue(data))) as Record< + string, + unknown + >; } export function toTrajectoryToolDefinitions(