diff --git a/src/commands/doctor-auth-legacy-oauth.ts b/src/commands/doctor-auth-legacy-oauth.ts index 1849f8efdc8f..933b7d611de0 100644 --- a/src/commands/doctor-auth-legacy-oauth.ts +++ b/src/commands/doctor-auth-legacy-oauth.ts @@ -1,3 +1,4 @@ +import { collectConfiguredModelRefs } from "@openclaw/model-catalog-core/configured-model-refs"; /** Removes retired provider profiles and repairs legacy OAuth profile ids. */ import { sanitizeForLog } from "../../packages/terminal-core/src/ansi.js"; import { loadPersistedAuthProfileStore } from "../agents/auth-profiles/persisted.js"; @@ -29,6 +30,14 @@ function sanitizePromptLabel(label: string | undefined): string | undefined { return sanitized || undefined; } +function configSelectsProvider(cfg: OpenClawConfig, providerIds: readonly string[]): boolean { + const selectedProviders = new Set(providerIds); + return collectConfiguredModelRefs(cfg).some(({ value }) => { + const separator = value.indexOf("/"); + return separator > 0 && selectedProviders.has(value.slice(0, separator)); + }); +} + /** * Applies provider-declared OAuth profile id repairs to config after prompting. * @@ -50,9 +59,15 @@ export async function maybeRepairLegacyOAuthProfileIds( const repairCandidates = listAuthProfileRepairCandidates(nextCfg, process.env); for (const provider of providers) { for (const profileId of provider.deprecatedProfileIds ?? []) { - const profileStores = repairCandidates.filter((candidate) => - Boolean(loadPersistedAuthProfileStore(candidate.agentDir)?.profiles[profileId]), - ); + const storedProfileProviders = new Set(); + const profileStores = repairCandidates.filter((candidate) => { + const profile = loadPersistedAuthProfileStore(candidate.agentDir)?.profiles[profileId]; + if (!profile) { + return false; + } + storedProfileProviders.add(profile.provider); + return true; + }); if (profileStores.length === 0 && !configReferencesAuthProfile(nextCfg, profileId)) { continue; } @@ -69,13 +84,20 @@ export async function maybeRepairLegacyOAuthProfileIds( if (!apply) { continue; } - // Preserve provider-owned runtime selection while the retired profile still - // identifies it. Removing the profile first loses that migration signal. - nextCfg = applyProviderConfigDefaultsForConfig({ - provider: provider.id, - config: nextCfg, - env: process.env, - }); + const configuredProfileProvider = nextCfg.auth?.profiles?.[profileId]?.provider; + const selectedProviderIds = new Set([provider.id, ...storedProfileProviders]); + if (configuredProfileProvider) { + selectedProviderIds.add(configuredProfileProvider); + } + if (configSelectsProvider(nextCfg, [...selectedProviderIds])) { + // Preserve a selected provider's runtime routing before removing the + // retired profile that still identifies its native CLI migration. + nextCfg = applyProviderConfigDefaultsForConfig({ + provider: provider.id, + config: nextCfg, + env: process.env, + }); + } nextCfg = removeAuthProfileConfig(nextCfg, profileId); for (const candidate of profileStores) { retiredProfileCleanupPlans.push({ diff --git a/src/commands/doctor-auth.deprecated-cli-profiles.test.ts b/src/commands/doctor-auth.deprecated-cli-profiles.test.ts index 1f70a089c577..dbe96750641d 100644 --- a/src/commands/doctor-auth.deprecated-cli-profiles.test.ts +++ b/src/commands/doctor-auth.deprecated-cli-profiles.test.ts @@ -289,6 +289,7 @@ describe("maybeRepairLegacyOAuthProfileIds", () => { expect(next.agents?.defaults?.models?.["anthropic/claude-sonnet-4-6"]?.agentRuntime).toEqual({ id: "claude-cli", }); + expect(providerPolicyMocks.applyConfigDefaults).toHaveBeenCalledOnce(); expect(next.models?.providers?.anthropic?.apiKey).toBeUndefined(); expect(result.retiredProfileCleanupPlans).toContainEqual({ agentDir: undefined, @@ -323,6 +324,7 @@ describe("maybeRepairLegacyOAuthProfileIds", () => { expect(next.models?.providers?.anthropic?.apiKey).toBeUndefined(); expect(retiredProfileCleanupPlans).toEqual([]); + expect(providerPolicyMocks.applyConfigDefaults).not.toHaveBeenCalled(); }); it("removes a retired profile from a secondary agent store", async () => { @@ -360,6 +362,42 @@ describe("maybeRepairLegacyOAuthProfileIds", () => { }); }); + it("repairs selected provider routing for a store-only retired profile", async () => { + authProfileStoreMock.store = { + version: 1, + profiles: { + "anthropic:claude-cli": { + type: "oauth", + provider: "claude-cli", + access: "copied-native-access", + refresh: "copied-native-refresh", + expires: Date.now() + 60_000, + }, + }, + }; + resolvePluginProvidersMock.mockReturnValue([ + { + id: "anthropic", + label: "Anthropic", + auth: [], + deprecatedProfileIds: ["anthropic:claude-cli"], + }, + ]); + + const result = await maybeRepairLegacyOAuthProfileIds( + { + agents: { defaults: { model: { primary: "claude-cli/claude-sonnet-4-6" } } }, + } as OpenClawConfig, + makePrompter(true), + ); + + expect(providerPolicyMocks.applyConfigDefaults).toHaveBeenCalledOnce(); + expect(result.retiredProfileCleanupPlans).toContainEqual({ + agentDir: undefined, + profileIds: ["anthropic:claude-cli"], + }); + }); + it("strips provider-controlled terminal escapes from repair prompts", async () => { authProfileStoreMock.store = { version: 1, diff --git a/src/commands/doctor-auth.profile-health.test.ts b/src/commands/doctor-auth.profile-health.test.ts index deac7db12a45..6861af650b57 100644 --- a/src/commands/doctor-auth.profile-health.test.ts +++ b/src/commands/doctor-auth.profile-health.test.ts @@ -328,6 +328,29 @@ describe("noteAuthProfileHealth", () => { ]); }); + it("reports expired credentials independently from an active cooldown", async () => { + const now = 1_700_000_000_000; + vi.spyOn(Date, "now").mockReturnValue(now); + const mainDir = path.join(tempDir, "main-agent"); + authProfileMocks.hasAnyAuthProfileStoreSource.mockReturnValue(true); + authProfileMocks.resolveProfileUnusableUntilForDisplay.mockReturnValue(now + 5 * 60_000); + authProfileMocks.ensureAuthProfileStore.mockReturnValue({ + ...expiredStore("openai:expired", now - 60_000), + usageStats: { "openai:expired": { cooldownUntil: now + 5 * 60_000 } }, + }); + + const findings = await collectAuthProfileHealthFindings({ + cfg: { + agents: { list: [{ id: "main", default: true, agentDir: mainDir }] }, + } as OpenClawConfig, + }); + + expect(findings.map((finding) => finding.message)).toEqual([ + "Auth profile openai:expired is cooldown (5m).", + "Auth profile openai:expired is expired (0m).", + ]); + }); + it("routes legacy Gemini CLI cooldowns to supported Google API-key setup", async () => { const now = 1_700_000_000_000; vi.spyOn(Date, "now").mockReturnValue(now); @@ -578,6 +601,38 @@ describe("noteAuthProfileHealth", () => { expect(body).not.toContain("(agents:"); }); + it("offers credential repair while the same profile is cooling down", async () => { + const now = 1_700_000_000_000; + vi.spyOn(Date, "now").mockReturnValue(now); + const mainDir = path.join(tempDir, "main-agent"); + writeAuthStore(mainDir); + authProfileMocks.hasAnyAuthProfileStoreSource.mockReturnValue(true); + authProfileMocks.resolveProfileUnusableUntilForDisplay.mockReturnValue(now + 5 * 60_000); + authProfileMocks.ensureAuthProfileStore.mockReturnValue({ + ...expiredStore("openai-codex:expired", now - 60_000), + usageStats: { "openai-codex:expired": { cooldownUntil: now + 5 * 60_000 } }, + }); + const confirmAutoFix = vi.fn(async () => false); + + await noteAuthProfileHealth({ + cfg: { + agents: { list: [{ id: "main", default: true, agentDir: mainDir }] }, + } as OpenClawConfig, + prompter: { confirmAutoFix } as unknown as DoctorPrompter, + allowKeychainPrompt: false, + }); + + expect(confirmAutoFix).toHaveBeenCalledOnce(); + expect(noteMock).toHaveBeenCalledWith( + expect.stringContaining("openai-codex:expired: cooldown (5m)"), + "Auth profile cooldowns", + ); + expect(noteMock).toHaveBeenCalledWith( + expect.stringContaining("openai-codex:expired: expired"), + "Model auth", + ); + }); + it("does not treat inherited main auth as a local secondary-agent source", async () => { const now = 1_700_000_000_000; vi.spyOn(Date, "now").mockReturnValue(now); diff --git a/src/commands/doctor-claude-cli.test.ts b/src/commands/doctor-claude-cli.test.ts index f002d21f600c..6bdb8b0a6e45 100644 --- a/src/commands/doctor-claude-cli.test.ts +++ b/src/commands/doctor-claude-cli.test.ts @@ -134,6 +134,47 @@ describe("noteClaudeCliHealth", () => { }); }); + it("probes auth with the same cleared environment as Claude execution", async () => { + await withTempHome(({ homeDir, workspaceDir }) => { + resolveCliBackendConfigMock.mockReturnValue({ + id: "claude-cli", + pluginId: "anthropic", + config: { + command: "claude", + clearEnv: ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"], + }, + }); + const isAuthenticated = vi.fn(() => true); + + noteClaudeCliHealth( + { + agents: { + defaults: { model: "claude-cli/claude-sonnet-4-6" }, + entries: { main: { default: true } }, + }, + }, + { + env: { + ANTHROPIC_API_KEY: "ambient-api-key", + CLAUDE_CODE_OAUTH_TOKEN: "ambient-oauth-token", + CLAUDE_CONFIG_DIR: "/tmp/claude-config", + PATH: "/usr/bin", + }, + homeDir, + workspaceDir, + isAuthenticated, + noteFn: vi.fn(), + resolveCommandPath: () => "/usr/bin/claude", + }, + ); + + expect(isAuthenticated).toHaveBeenCalledWith("/usr/bin/claude", { + CLAUDE_CONFIG_DIR: "/tmp/claude-config", + PATH: "/usr/bin", + }); + }); + }); + it("stays quiet for a healthy non-default Claude CLI runtime agent", async () => { await withTempHome(({ homeDir, workspaceDir }) => { resolveModelAgentRuntimeMetadataMock.mockImplementation(({ agentId }) => ({ diff --git a/src/commands/doctor-claude-cli.ts b/src/commands/doctor-claude-cli.ts index d2b79705aac1..0c7323535611 100644 --- a/src/commands/doctor-claude-cli.ts +++ b/src/commands/doctor-claude-cli.ts @@ -206,8 +206,12 @@ export function noteClaudeCliHealth( ((rawCommand: string, nextEnv?: NodeJS.ProcessEnv) => resolveExecutablePath(rawCommand, { env: nextEnv })); const commandPath = resolveCommandPath(command, env); + const authEnv = { ...env }; + for (const envName of backend?.config.clearEnv ?? []) { + delete authEnv[envName]; + } const authenticated = commandPath - ? (deps?.isAuthenticated ?? isClaudeCliAuthenticated)(commandPath, env) + ? (deps?.isAuthenticated ?? isClaudeCliAuthenticated)(commandPath, authEnv) : false; const defaultAgentId = tryResolveDefaultAgentId(cfg); const showAgentLabels = diff --git a/src/gateway/desktop/observe-bridge.test.ts b/src/gateway/desktop/observe-bridge.test.ts index b571cf92e1ae..acc0c85c3ae4 100644 --- a/src/gateway/desktop/observe-bridge.test.ts +++ b/src/gateway/desktop/observe-bridge.test.ts @@ -209,7 +209,7 @@ describe("worker desktop observer proxy", () => { harness.ws.once("close", (code, reason) => resolve({ code, reason: reason.toString() })); }); harness.ws.send( - Buffer.concat([Buffer.from("RFB 003.008\n", "ascii"), Buffer.from([1, 1, 255])]), + Buffer.concat([Buffer.from("RFB 003.008\n", "ascii"), Buffer.from([1, 1, 254])]), ); await expect(closed).resolves.toEqual({ code: 1008, diff --git a/src/gateway/desktop/rfb-probe.test.ts b/src/gateway/desktop/rfb-probe.test.ts index 0b4639e513b2..898289650f33 100644 --- a/src/gateway/desktop/rfb-probe.test.ts +++ b/src/gateway/desktop/rfb-probe.test.ts @@ -148,11 +148,14 @@ describe("RFB server probe", () => { }); describe("RFB security classification", () => { - it("classifies supported security with password auth preferred over ARD", () => { + it("classifies the first browser-supported security type in server preference order", () => { expect(classifyRfbSecurity([1])).toBe("none"); expect(classifyRfbSecurity([30])).toBe("ard-account"); expect(classifyRfbSecurity([19])).toBe("unsupported"); - expect(classifyRfbSecurity([30, 2])).toBe("vnc-password"); + expect(classifyRfbSecurity([30, 2])).toBe("ard-account"); + expect(classifyRfbSecurity([2, 30])).toBe("vnc-password"); + expect(classifyRfbSecurity([33, 2])).toBe("vnc-password"); + expect(classifyRfbSecurity([19, 2])).toBe("unsupported"); expect(classifyRfbSecurity([30, 33, 36, 35])).toBe("ard-account"); }); }); diff --git a/src/gateway/desktop/rfb-probe.ts b/src/gateway/desktop/rfb-probe.ts index d61c2d90ddef..2762b00e2fab 100644 --- a/src/gateway/desktop/rfb-probe.ts +++ b/src/gateway/desktop/rfb-probe.ts @@ -192,14 +192,24 @@ export async function probeRfbServer(params: { export function classifyRfbSecurity( securityTypes: readonly number[], ): "none" | "vnc-password" | "ard-account" | "unsupported" { - if (securityTypes.includes(2)) { - return "vnc-password"; - } - if (securityTypes.includes(30)) { - return "ard-account"; - } - if (securityTypes.includes(1)) { - return "none"; + // noVNC selects the first security type it supports in the server's order. + // Mirror that choice so the Gateway credential flow cannot disagree with the + // browser (macOS advertises ARD before its VncAuth compatibility option). + for (const securityType of securityTypes) { + if (securityType === 1) { + return "none"; + } + if (securityType === 2) { + return "vnc-password"; + } + if (securityType === 30) { + return "ard-account"; + } + if ([6, 16, 19, 22, 113].includes(securityType)) { + // noVNC supports these schemes and stops here, but OpenClaw has no matching credential UX. + // Scanning onward would make the probe choose a route the browser never selects. + return "unsupported"; + } } return "unsupported"; } diff --git a/src/gateway/desktop/rfb-view-only-filter.test.ts b/src/gateway/desktop/rfb-view-only-filter.test.ts index 4a4c625932fb..8cbd2cc70152 100644 --- a/src/gateway/desktop/rfb-view-only-filter.test.ts +++ b/src/gateway/desktop/rfb-view-only-filter.test.ts @@ -63,10 +63,10 @@ describe("RFB view-only client message filter", () => { }); }); - it("fails closed on unsupported security types", () => { + it.each([19, 30])("fails closed on unsupported security type %s", (securityType) => { const filter = createRfbClientMessageFilter(); - expect(filter.filter(Buffer.concat([VERSION, Buffer.from([19])]))).toEqual({ - error: "unsupported RFB security type 19", + expect(filter.filter(Buffer.concat([VERSION, Buffer.from([securityType])]))).toEqual({ + error: `unsupported RFB security type ${securityType}`, }); }); @@ -81,15 +81,23 @@ describe("RFB view-only client message filter", () => { const framebufferUpdateRequest = Buffer.from([3, 1, 0, 0, 0, 0, 0, 64, 0, 64]); const keyEvent = Buffer.from([4, 1, 0, 0, 0, 0, 0, 65]); const pointerEvent = Buffer.from([5, 1, 0, 10, 0, 20]); + const extendedPointerEvent = Buffer.from([5, 0x80, 0, 10, 0, 20, 1]); const cutText = Buffer.concat([Buffer.from([6, 0, 0, 0, 0, 0, 0, 3]), Buffer.from("abc")]); + const setDesktopSize = Buffer.alloc(24); + setDesktopSize[0] = 251; + const extendedKeyEvent = Buffer.alloc(12); + extendedKeyEvent[0] = 255; const result = filter.filter( Buffer.concat([ keyEvent, setPixelFormat, pointerEvent, + extendedPointerEvent, setEncodings, cutText, + setDesktopSize, + extendedKeyEvent, framebufferUpdateRequest, ]), ); @@ -187,8 +195,8 @@ describe("RFB view-only client message filter", () => { it("fails closed on unknown message types", () => { const filter = enterMessagePhase(); - expect(filter.filter(Buffer.from([255]))).toEqual({ - error: "unsupported RFB client message type 255", + expect(filter.filter(Buffer.from([254]))).toEqual({ + error: "unsupported RFB client message type 254", }); }); diff --git a/src/gateway/desktop/rfb-view-only-filter.ts b/src/gateway/desktop/rfb-view-only-filter.ts index 50c9e483f670..92026d0171b6 100644 --- a/src/gateway/desktop/rfb-view-only-filter.ts +++ b/src/gateway/desktop/rfb-view-only-filter.ts @@ -44,7 +44,9 @@ export function createRfbClientMessageFilter( case 4: return 8; case 5: - return 6; + // noVNC's extended pointer event sets the marker bit in the button mask + // and appends one byte for buttons 8-15. + return pending.length < 2 ? 2 : (pending.readUInt8(1) & 0x80) !== 0 ? 7 : 6; case 6: // noVNC marks extended clipboard payloads with a negative signed length. return pending.length < 8 ? 8 : 8 + Math.abs(pending.readInt32BE(4)); @@ -53,6 +55,12 @@ export function createRfbClientMessageFilter( case 248: // ClientFence's payload length byte follows its 8-byte fixed header. return pending.length < 9 ? 9 : 9 + pending.readUInt8(8); + case 251: + // SetDesktopSize has one fixed 16-byte screen record in noVNC. + return 24; + case 255: + // QEMU extended key event: type, subtype, down flag, keysym, keycode. + return 12; default: return `unsupported RFB client message type ${pending[0]}`; } diff --git a/src/gateway/server-methods/agent.base.test-utils.ts b/src/gateway/server-methods/agent.base.test-utils.ts index 6c64c98ae208..85480966e6bf 100644 --- a/src/gateway/server-methods/agent.base.test-utils.ts +++ b/src/gateway/server-methods/agent.base.test-utils.ts @@ -36,6 +36,7 @@ import { backendGatewayClient, operatorWriteCliClient, waitForAgentCommandCall, + waitForAgentCommandCallAfter, invokeAgent, describe0AfterEach0, } from "./agent.test-harness.js"; @@ -1528,11 +1529,12 @@ describe("gateway agent handler", () => { canonicalKey: "agent:main:main", }); + const commandCallCount = mocks.agentCommand.mock.calls.length; const capturedEntry = await runMainAgentAndCaptureEntry( "test-idem-terminal-main-newer-transcript", ); - const call = await waitForAgentCommandCall<{ sessionId?: string }>(); + const call = await waitForAgentCommandCallAfter<{ sessionId?: string }>(commandCallCount); if (scenario.expectReuse) { expect(call.sessionId).toBe("terminal-main-session"); expect(capturedEntry?.sessionId).toBe("terminal-main-session"); diff --git a/src/gateway/server-methods/agent.sessions-and-models.test-utils.ts b/src/gateway/server-methods/agent.sessions-and-models.test-utils.ts index 177e933b2c53..a18a6625c1ce 100644 --- a/src/gateway/server-methods/agent.sessions-and-models.test-utils.ts +++ b/src/gateway/server-methods/agent.sessions-and-models.test-utils.ts @@ -43,6 +43,7 @@ import { operatorWriteGatewayClient, resetAgentTaskRegistryForTests, waitForAgentCommandCall, + waitForAgentCommandCallAfter, invokeAgent, describe0AfterEach0, } from "./agent.test-harness.js"; @@ -192,6 +193,7 @@ describe("gateway agent handler", () => { useTestStateDir(root); resetAgentTaskRegistryForTests(); primeMainAgentRun(); + const commandCallCount = mocks.agentCommand.mock.calls.length; await invokeAgent( { @@ -201,6 +203,7 @@ describe("gateway agent handler", () => { }, { reqId: "task-registry-agent-run" }, ); + await waitForAgentCommandCallAfter(commandCallCount); await waitForAssertion(() => { expectRecordFields(findTaskByRunId("task-registry-agent-run"), { @@ -270,6 +273,7 @@ describe("gateway agent handler", () => { pluginRuntimeOwnerId: "memory-core", }, }; + const initialCommandCallCount = mocks.agentCommand.mock.calls.length; const respond = await invokeAgent( { @@ -283,6 +287,7 @@ describe("gateway agent handler", () => { client: pluginClient, }, ); + await waitForAgentCommandCallAfter(initialCommandCallCount); const acceptedPayload = respond.mock.calls.find( ([ok, payload]) => @@ -687,6 +692,7 @@ describe("gateway agent handler", () => { resetAgentTaskRegistryForTests(); primeMainAgentRun(); mocks.agentCommand.mockRejectedValueOnce(new Error("agent unavailable")); + const commandCallCount = mocks.agentCommand.mock.calls.length; await invokeAgent( { @@ -696,6 +702,7 @@ describe("gateway agent handler", () => { }, { reqId: "task-registry-agent-run-error" }, ); + await waitForAgentCommandCallAfter(commandCallCount); await waitForAssertion(() => { expectRecordFields(findTaskByRunId("task-registry-agent-run-error"), { @@ -718,6 +725,7 @@ describe("gateway agent handler", () => { meta: { durationMs: 100, aborted: true }, }); const context = makeContext(); + const commandCallCount = mocks.agentCommand.mock.calls.length; await invokeAgent( { @@ -727,6 +735,7 @@ describe("gateway agent handler", () => { }, { context, reqId: "task-registry-agent-run-aborted" }, ); + await waitForAgentCommandCallAfter(commandCallCount); await waitForAssertion(() => { expectRecordFields(findTaskByRunId("task-registry-agent-run-aborted"), { diff --git a/src/gateway/server-methods/agent.test-harness.ts b/src/gateway/server-methods/agent.test-harness.ts index 16e6cae55525..5a0f9be09e89 100644 --- a/src/gateway/server-methods/agent.test-harness.ts +++ b/src/gateway/server-methods/agent.test-harness.ts @@ -6,6 +6,7 @@ import { expect, vi } from "vitest"; import type { readAcpSessionMeta } from "../../acp/runtime/session-meta.js"; import type { AgentInternalEvent } from "../../agents/internal-events.js"; import { setSubagentRegistryDepsForTest } from "../../agents/subagents/registry/subagent-registry-deps.js"; +import type { SubagentRegistryDeps } from "../../agents/subagents/registry/subagent-registry-deps.js"; import { resetSubagentRegistryForTests } from "../../agents/subagents/registry/subagent-registry.test-helpers.js"; import type { SessionEntry } from "../../config/sessions.js"; import type { SessionTranscriptStats } from "../../config/sessions/session-accessor.js"; @@ -44,6 +45,7 @@ const mocks = vi.hoisted(() => ({ sizeBytes: 0, })), agentCommand: vi.fn(), + agentCommandListeners: new Set<() => void>(), clearAgentRunContext: vi.fn(), registerAgentRunContext: vi.fn(), emitAgentEvent: vi.fn(), @@ -145,11 +147,20 @@ vi.mock("../../sessions/user-turn-transcript.js", async () => { }; }); -vi.mock("../../commands/agent.js", () => ({ - agentCommand: mocks.agentCommand, - agentCommandFromGatewayIngress: mocks.agentCommand, - agentCommandFromIngress: mocks.agentCommand, -})); +vi.mock("../../commands/agent.js", () => { + const agentCommand = (...args: Parameters) => { + const result = mocks.agentCommand(...args); + for (const listener of mocks.agentCommandListeners) { + listener(); + } + return result; + }; + return { + agentCommand, + agentCommandFromGatewayIngress: agentCommand, + agentCommandFromIngress: agentCommand, + }; +}); vi.mock("../../agents/prepared-model-runtime.js", () => ({ // Direct handler tests bypass Gateway startup, so provide the lifecycle fact @@ -906,6 +917,29 @@ export async function waitForAgentCommandCall< return call as T; } +export async function waitForAgentCommandCallAfter( + commandCallCount: number, +): Promise { + if (mocks.agentCommand.mock.calls.length <= commandCallCount) { + await new Promise((resolve) => { + const onCommand = () => { + if (mocks.agentCommand.mock.calls.length <= commandCallCount) { + return; + } + mocks.agentCommandListeners.delete(onCommand); + resolve(); + }; + mocks.agentCommandListeners.add(onCommand); + onCommand(); + }); + } + const call = mocks.agentCommand.mock.calls[commandCallCount]; + if (!call) { + throw new Error(`expected agentCommand call ${commandCallCount}`); + } + return call[0] as unknown as T; +} + export function mockSessionResetSuccess(params: { reason: "new" | "reset"; key?: string; @@ -1005,6 +1039,13 @@ export function applyGatewaySubagentRegistryTestDeps( overrides?: Parameters[0], ) { setSubagentRegistryDepsForTest({ + // Direct handler tests have no live Gateway owner. Keep registry polling + // deterministic so a real connection timeout cannot cross test boundaries. + callGateway: (async () => ({ + status: "ok", + startedAt: Date.now(), + endedAt: Date.now(), + })) as SubagentRegistryDeps["callGateway"], loadAgentRuntimePluginRegistryHandle: () => undefined, ...overrides, }); diff --git a/src/plugin-sdk/provider-auth-claude-compat.ts b/src/plugin-sdk/provider-auth-claude-compat.ts index 06aca57eb17f..2b96eea4156c 100644 --- a/src/plugin-sdk/provider-auth-claude-compat.ts +++ b/src/plugin-sdk/provider-auth-claude-compat.ts @@ -1,3 +1,21 @@ +import { execSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import { userInfo } from "node:os"; +import path from "node:path"; +import { asNonArrayRecord, isRecord } from "@openclaw/normalization-core/record-coerce"; +import { resolveOsHomeRelativePath } from "../infra/home-dir.js"; +import { loadJsonFileThroughSymlink } from "../infra/json-file.js"; + +const CLAUDE_CLI_CREDENTIALS_FILE = ".credentials.json"; +const CLAUDE_CLI_USER_SETTINGS_FILE = "settings.json"; +const CLAUDE_CLI_KEYCHAIN_SERVICE = "Claude Code-credentials"; +const CLAUDE_CLI_KEYCHAIN_TIMEOUT_MS = 2_000; +const CLAUDE_CLI_KEYCHAIN_ACCOUNT_FALLBACK = "claude-code-user"; +const MACOS_SECURITY_PATH = "/usr/bin/security"; +// Pinned Claude SDK YK() accepts this exact ASCII set and otherwise uses the fallback. +const SAFE_KEYCHAIN_ACCOUNT_PATTERN = /^[a-zA-Z0-9._-]+$/u; + /** Retired Claude CLI credential shape kept only for source compatibility. */ type ClaudeCliCredential = | { @@ -32,15 +50,307 @@ type ClaudeCliCredentialReadOptions = { ttlMs?: number; platform?: NodeJS.Platform; homeDir?: string; - execSync?: typeof import("node:child_process").execSync; + execSync?: typeof execSync; }; +type ClaudeCliCache = { + value: ClaudeCliCredential | null; + readAt: number; + cacheKey: string; + sourceFingerprint: string; +}; + +let claudeCliCache: ClaudeCliCache | null = null; + +function resolveClaudeCliConfigDir(homeDir?: string): string { + if (homeDir !== undefined) { + return path.join(resolveOsHomeRelativePath(homeDir), ".claude"); + } + const configuredDir = process.env.CLAUDE_CONFIG_DIR; + return configuredDir + ? path.resolve(configuredDir) + : path.join(resolveOsHomeRelativePath("~"), ".claude"); +} + +function resolveClaudeCliPath(homeDir: string | undefined, fileName: string): string { + return path.join(resolveClaudeCliConfigDir(homeDir), fileName); +} + +function resolveClaudeCliCredentialsPath(homeDir?: string): string { + if (homeDir !== undefined) { + return path.join(resolveClaudeCliConfigDir(homeDir), CLAUDE_CLI_CREDENTIALS_FILE); + } + const secureStorageDir = process.env.CLAUDE_SECURESTORAGE_CONFIG_DIR; + if (secureStorageDir === undefined) { + return resolveClaudeCliPath(undefined, CLAUDE_CLI_CREDENTIALS_FILE); + } + // Claude treats an explicit empty override as the default credential store, + // even when CLAUDE_CONFIG_DIR points at a separate settings directory. + const credentialDir = secureStorageDir + ? path.resolve(secureStorageDir) + : path.join(resolveOsHomeRelativePath("~"), ".claude"); + return path.join(credentialDir, CLAUDE_CLI_CREDENTIALS_FILE); +} + +function resolveClaudeCliAccountPath(homeDir?: string): string { + if (homeDir !== undefined) { + return path.join(resolveOsHomeRelativePath(homeDir), ".claude.json"); + } + const configuredDir = process.env.CLAUDE_CONFIG_DIR; + return configuredDir + ? path.join(path.resolve(configuredDir), ".claude.json") + : path.join(resolveOsHomeRelativePath("~"), ".claude.json"); +} + +function resolveClaudeCliKeychainService(homeDir?: string): string { + if (homeDir !== undefined) { + return CLAUDE_CLI_KEYCHAIN_SERVICE; + } + const secureStorageDir = process.env.CLAUDE_SECURESTORAGE_CONFIG_DIR; + const configDir = process.env.CLAUDE_CONFIG_DIR; + const selectedDir = secureStorageDir !== undefined ? secureStorageDir : configDir; + if (!selectedDir) { + return CLAUDE_CLI_KEYCHAIN_SERVICE; + } + // Claude Code normalizes this selector before hashing its Keychain service suffix. + // Keep byte-for-byte parity or decomposed Unicode config paths query a different item. + const suffix = createHash("sha256") + .update(selectedDir.normalize("NFC")) + .digest("hex") + .slice(0, 8); + return `${CLAUDE_CLI_KEYCHAIN_SERVICE}-${suffix}`; +} + +function readFileMtimeMs(filePath: string): number | null { + try { + return fs.statSync(filePath).mtimeMs; + } catch { + return null; + } +} + +function parseClaudeCliOauthCredential(value: unknown): ClaudeCliCredential | null { + if (!value || typeof value !== "object") { + return null; + } + const data = asNonArrayRecord(value); + const accessToken = data.accessToken; + const refreshToken = data.refreshToken; + const expiresAt = data.expiresAt; + if ( + typeof accessToken !== "string" || + !accessToken || + // The shipped token variant is access-only (no refresh token), not expiry-free. + // Both public credential variants require a finite expiry for safe reuse. + typeof expiresAt !== "number" || + !Number.isFinite(expiresAt) || + expiresAt <= 0 + ) { + return null; + } + const subscriptionType = + typeof data.subscriptionType === "string" && data.subscriptionType.trim() + ? data.subscriptionType.trim() + : undefined; + const rateLimitTier = + typeof data.rateLimitTier === "string" && data.rateLimitTier.trim() + ? data.rateLimitTier.trim() + : undefined; + const plan = { + ...(subscriptionType ? { subscriptionType } : {}), + ...(rateLimitTier ? { rateLimitTier } : {}), + }; + return typeof refreshToken === "string" && refreshToken + ? { + type: "oauth", + provider: "anthropic", + access: accessToken, + refresh: refreshToken, + expires: expiresAt, + ...plan, + } + : { + type: "token", + provider: "anthropic", + token: accessToken, + expires: expiresAt, + ...plan, + }; +} + +function readClaudeAccountEmail(homeDir?: string): string | undefined { + const raw = loadJsonFileThroughSymlink(resolveClaudeCliAccountPath(homeDir)); + const account = asNonArrayRecord(raw).oauthAccount; + const email = asNonArrayRecord(account).emailAddress; + return typeof email === "string" && email.trim() ? email.trim() : undefined; +} + +function withClaudeAccountEmail( + credential: ClaudeCliCredential | null, + homeDir?: string, +): ClaudeCliCredential | null { + if (!credential || credential.type === "api_key_helper") { + return credential; + } + if ( + path.dirname(resolveClaudeCliCredentialsPath(homeDir)) !== resolveClaudeCliConfigDir(homeDir) + ) { + // oauthAccount is config-scoped, so it cannot identify a credential selected + // from an independent secure-storage root. + return credential; + } + const email = readClaudeAccountEmail(homeDir); + return email ? { ...credential, email } : credential; +} + +function readClaudeApiKeyHelper(homeDir?: string): ClaudeCliCredential | null { + const raw = loadJsonFileThroughSymlink( + resolveClaudeCliPath(homeDir, CLAUDE_CLI_USER_SETTINGS_FILE), + ); + const helper = asNonArrayRecord(raw).apiKeyHelper; + return typeof helper === "string" && helper.trim() + ? { + type: "api_key_helper", + provider: "anthropic", + helperHash: createHash("sha256").update(helper.trim()).digest("hex"), + } + : null; +} + +function readClaudeKeychain( + execSyncImpl: typeof execSync, + timeout: number | undefined, + service: string, +): Record | null { + try { + const account = resolveClaudeCliKeychainAccount(); + const result = execSyncImpl( + `${MACOS_SECURITY_PATH} find-generic-password -a "${account}" -w -s "${service}"`, + { + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + ...(timeout === undefined ? {} : { timeout }), + }, + ); + const parsed: unknown = JSON.parse(result.trim()); + return isRecord(parsed) ? parsed : null; + } catch { + return null; + } +} + +function hasClaudeKeychainItem(execSyncImpl: typeof execSync, service: string): boolean { + try { + const account = resolveClaudeCliKeychainAccount(); + execSyncImpl(`${MACOS_SECURITY_PATH} find-generic-password -a "${account}" -s "${service}"`, { + encoding: "utf8", + timeout: CLAUDE_CLI_KEYCHAIN_TIMEOUT_MS, + stdio: ["pipe", "pipe", "pipe"], + }); + return true; + } catch { + return false; + } +} + +function resolveClaudeCliKeychainAccount(): string { + let account: string | undefined; + try { + account = process.env.USER || userInfo().username; + } catch { + account = undefined; + } + return account && SAFE_KEYCHAIN_ACCOUNT_PATTERN.test(account) + ? account + : CLAUDE_CLI_KEYCHAIN_ACCOUNT_FALLBACK; +} + +function readClaudeCliCredentials( + options: ClaudeCliCredentialReadOptions, +): ClaudeCliCredential | null { + const helper = readClaudeApiKeyHelper(options.homeDir); + if (helper) { + return helper; + } + + const platform = options.platform ?? process.platform; + const execSyncImpl = options.execSync ?? execSync; + const keychainService = resolveClaudeCliKeychainService(options.homeDir); + const tryKeychain = platform === "darwin" && options.allowKeychainPrompt !== false; + if (tryKeychain) { + const payload = readClaudeKeychain( + execSyncImpl, + options.tryKeychainWithoutPrompt ? CLAUDE_CLI_KEYCHAIN_TIMEOUT_MS : undefined, + keychainService, + ); + const credential = parseClaudeCliOauthCredential(payload?.claudeAiOauth); + if (credential) { + return withClaudeAccountEmail(credential, options.homeDir); + } + } + + const credentialsPath = resolveClaudeCliCredentialsPath(options.homeDir); + const raw = loadJsonFileThroughSymlink(credentialsPath); + const credential = withClaudeAccountEmail( + parseClaudeCliOauthCredential(asNonArrayRecord(raw).claudeAiOauth), + options.homeDir, + ); + if (credential) { + return credential; + } + if ( + options.onStoredCredentialUnreadable && + options.tryKeychainWithoutPrompt && + (fs.existsSync(credentialsPath) || + (platform === "darwin" && hasClaudeKeychainItem(execSyncImpl, keychainService))) + ) { + options.onStoredCredentialUnreadable(); + } + return null; +} + /** - * @deprecated Claude CLI owns its native login. This returns null without reading credentials. + * @deprecated Claude CLI owns native login. Kept functional for shipped Plugin SDK callers only. * Scheduled for removal after v2026.10. */ export function readClaudeCliCredentialsCached( - _options?: ClaudeCliCredentialReadOptions, + options: ClaudeCliCredentialReadOptions = {}, ): ClaudeCliCredential | null { - return null; + const platform = options.platform ?? process.platform; + const ttlMs = options.ttlMs ?? 0; + const credentialsPath = resolveClaudeCliCredentialsPath(options.homeDir); + const settingsPath = resolveClaudeCliPath(options.homeDir, CLAUDE_CLI_USER_SETTINGS_FILE); + const accountPath = resolveClaudeCliAccountPath(options.homeDir); + const keychainService = resolveClaudeCliKeychainService(options.homeDir); + const keychainIntent = + platform !== "darwin" + ? "file" + : options.allowKeychainPrompt === false + ? options.tryKeychainWithoutPrompt + ? "keychain-presence" + : "file" + : options.tryKeychainWithoutPrompt + ? "keychain-bounded" + : "keychain"; + const unreadableIntent = + options.onStoredCredentialUnreadable && options.tryKeychainWithoutPrompt ? "notify" : "silent"; + const cacheKey = `${credentialsPath}:${settingsPath}:${accountPath}:${keychainIntent}:${keychainService}:${unreadableIntent}`; + const sourceFingerprint = `${readFileMtimeMs(credentialsPath) ?? "missing"}:${readFileMtimeMs(settingsPath) ?? "missing"}:${readFileMtimeMs(accountPath) ?? "missing"}`; + const now = Date.now(); + if ( + ttlMs > 0 && + claudeCliCache?.cacheKey === cacheKey && + claudeCliCache.sourceFingerprint === sourceFingerprint && + now - claudeCliCache.readAt < ttlMs + ) { + return claudeCliCache.value; + } + + const value = readClaudeCliCredentials({ ...options, platform }); + const nextFingerprint = `${readFileMtimeMs(credentialsPath) ?? "missing"}:${readFileMtimeMs(settingsPath) ?? "missing"}:${readFileMtimeMs(accountPath) ?? "missing"}`; + claudeCliCache = + ttlMs > 0 && nextFingerprint === sourceFingerprint + ? { value, readAt: now, cacheKey, sourceFingerprint: nextFingerprint } + : null; + return value; } diff --git a/src/plugin-sdk/provider-auth.test.ts b/src/plugin-sdk/provider-auth.test.ts index dbc943048107..3feb098a4b4e 100644 --- a/src/plugin-sdk/provider-auth.test.ts +++ b/src/plugin-sdk/provider-auth.test.ts @@ -1,9 +1,11 @@ +import type { execSync } from "node:child_process"; // Provider auth tests cover credential resolution, setup state, and auth method contracts. import { createHash } from "node:crypto"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; import { clearRuntimeAuthProfileStoreSnapshots, saveAuthProfileStore, @@ -27,17 +29,336 @@ const TEST_CACHED_COPILOT_TOKEN = [ ["proxy-ep", "proxy.individual.githubcopilot.com"].join("="), ].join(";"); const TEST_GITHUB_TOKEN_FINGERPRINT = createHash("sha256").update(TEST_GITHUB_TOKEN).digest("hex"); +const tempDirs = useAutoCleanupTempDirTracker(afterEach); describe("provider auth public SDK", () => { it("retains provider-scoped profile removal", () => { expect(removeProviderAuthProfilesWithLock).toBeTypeOf("function"); }); - it("keeps the retired Claude credential reader as a null-only compatibility export", () => { + it("keeps the shipped Claude credential reader functional during its deprecation window", async () => { + const homeDir = tempDirs.make("openclaw-sdk-claude-auth-"); + const credentialsDir = path.join(homeDir, ".claude"); + await fs.mkdir(credentialsDir, { recursive: true }); + await fs.writeFile( + path.join(credentialsDir, ".credentials.json"), + JSON.stringify({ + claudeAiOauth: { + accessToken: "legacy-access", + refreshToken: "legacy-refresh", + expiresAt: 1_800_000_000_000, + subscriptionType: "max", + }, + }), + ); + + expect(readClaudeCliCredentialsCached({ homeDir, platform: "linux", ttlMs: 0 })).toEqual({ + type: "oauth", + provider: "anthropic", + access: "legacy-access", + refresh: "legacy-refresh", + expires: 1_800_000_000_000, + subscriptionType: "max", + }); + }); + + it("reads Claude credentials from CLAUDE_CONFIG_DIR", async () => { + const configDir = tempDirs.make("openclaw-sdk-claude-config-"); + await fs.writeFile( + path.join(configDir, ".credentials.json"), + JSON.stringify({ + claudeAiOauth: { + accessToken: "configured-access", + refreshToken: "configured-refresh", + expiresAt: 1_800_000_000_000, + }, + }), + ); + await fs.writeFile( + path.join(configDir, ".claude.json"), + JSON.stringify({ oauthAccount: { emailAddress: "configured@example.com" } }), + ); + vi.stubEnv("CLAUDE_CONFIG_DIR", configDir); + + try { + expect(readClaudeCliCredentialsCached({ platform: "linux", ttlMs: 0 })).toMatchObject({ + type: "oauth", + access: "configured-access", + refresh: "configured-refresh", + email: "configured@example.com", + }); + } finally { + vi.unstubAllEnvs(); + } + }); + + it("does not attach shared config identity to split-store credentials", async () => { + const configDir = tempDirs.make("openclaw-sdk-claude-config-split-"); + const secureStorageDir = tempDirs.make("openclaw-sdk-claude-secure-storage-"); + await fs.writeFile( + path.join(secureStorageDir, ".credentials.json"), + JSON.stringify({ + claudeAiOauth: { + accessToken: "secure-storage-access", + refreshToken: "secure-storage-refresh", + expiresAt: 1_800_000_000_000, + }, + }), + ); + await fs.writeFile( + path.join(configDir, ".claude.json"), + JSON.stringify({ oauthAccount: { emailAddress: "configured@example.com" } }), + ); + vi.stubEnv("CLAUDE_CONFIG_DIR", configDir); + vi.stubEnv("CLAUDE_SECURESTORAGE_CONFIG_DIR", secureStorageDir); + + try { + const credential = readClaudeCliCredentialsCached({ platform: "linux", ttlMs: 0 }); + expect(credential).toMatchObject({ + access: "secure-storage-access", + refresh: "secure-storage-refresh", + }); + expect(credential).not.toHaveProperty("email"); + } finally { + vi.unstubAllEnvs(); + } + }); + + it("isolates cached config metadata when profiles share secure storage", async () => { + const firstConfigDir = tempDirs.make("openclaw-sdk-claude-first-config-"); + const secondConfigDir = tempDirs.make("openclaw-sdk-claude-second-config-"); + const secureStorageDir = tempDirs.make("openclaw-sdk-claude-shared-storage-"); + const firstHelper = "first-profile-helper"; + const secondHelper = "second-profile-helper"; + const firstSettingsPath = path.join(firstConfigDir, "settings.json"); + const secondSettingsPath = path.join(secondConfigDir, "settings.json"); + await fs.writeFile(firstSettingsPath, JSON.stringify({ apiKeyHelper: firstHelper })); + await fs.writeFile(secondSettingsPath, JSON.stringify({ apiKeyHelper: secondHelper })); + const sharedMtime = new Date(1_800_000_000_000); + await fs.utimes(firstSettingsPath, sharedMtime, sharedMtime); + await fs.utimes(secondSettingsPath, sharedMtime, sharedMtime); + vi.stubEnv("CLAUDE_SECURESTORAGE_CONFIG_DIR", secureStorageDir); + + try { + vi.stubEnv("CLAUDE_CONFIG_DIR", firstConfigDir); + expect(readClaudeCliCredentialsCached({ platform: "linux", ttlMs: 60_000 })).toEqual({ + type: "api_key_helper", + provider: "anthropic", + helperHash: createHash("sha256").update(firstHelper).digest("hex"), + }); + + vi.stubEnv("CLAUDE_CONFIG_DIR", secondConfigDir); + expect(readClaudeCliCredentialsCached({ platform: "linux", ttlMs: 60_000 })).toEqual({ + type: "api_key_helper", + provider: "anthropic", + helperHash: createHash("sha256").update(secondHelper).digest("hex"), + }); + } finally { + vi.unstubAllEnvs(); + } + }); + + it("pins an empty secure-storage override to the default credential store", async () => { + const osHome = tempDirs.make("openclaw-sdk-claude-default-home-"); + const defaultCredentialsDir = path.join(osHome, ".claude"); + const configDir = tempDirs.make("openclaw-sdk-claude-other-config-"); + await fs.mkdir(defaultCredentialsDir, { recursive: true }); + await fs.writeFile( + path.join(defaultCredentialsDir, ".credentials.json"), + JSON.stringify({ + claudeAiOauth: { + accessToken: "default-store-access", + refreshToken: "default-store-refresh", + expiresAt: 1_800_000_000_000, + }, + }), + ); + vi.stubEnv("HOME", osHome); + vi.stubEnv("CLAUDE_CONFIG_DIR", configDir); + vi.stubEnv("CLAUDE_SECURESTORAGE_CONFIG_DIR", ""); + + try { + expect(readClaudeCliCredentialsCached({ platform: "linux", ttlMs: 0 })).toMatchObject({ + access: "default-store-access", + refresh: "default-store-refresh", + }); + } finally { + vi.unstubAllEnvs(); + } + }); + + it("reads the macOS Keychain through the absolute system executable", () => { + const execSyncImpl = vi.fn((command: string) => { + expect(command).toMatch(/^\/usr\/bin\/security find-generic-password /u); + expect(command).toContain('-a "test-user"'); + return JSON.stringify({ + claudeAiOauth: { + accessToken: "keychain-access", + refreshToken: "keychain-refresh", + expiresAt: 1_800_000_000_000, + }, + }); + }) as unknown as typeof execSync; + + vi.stubEnv("USER", "test-user"); + try { + expect( + readClaudeCliCredentialsCached({ + execSync: execSyncImpl, + platform: "darwin", + tryKeychainWithoutPrompt: true, + ttlMs: 0, + }), + ).toMatchObject({ type: "oauth", access: "keychain-access" }); + expect(execSyncImpl).toHaveBeenCalledOnce(); + } finally { + vi.unstubAllEnvs(); + } + }); + + it("does not impose a machine timeout on prompt-enabled Keychain reads", () => { + const execSyncImpl = vi.fn((_command: string, options: { timeout?: number }) => { + expect(options).not.toHaveProperty("timeout"); + return JSON.stringify({ + claudeAiOauth: { + accessToken: "prompted-access", + refreshToken: "prompted-refresh", + expiresAt: 1_800_000_000_000, + }, + }); + }) as unknown as typeof execSync; + + expect( + readClaudeCliCredentialsCached({ + allowKeychainPrompt: true, + execSync: execSyncImpl, + platform: "darwin", + ttlMs: 0, + }), + ).toMatchObject({ type: "oauth", access: "prompted-access" }); + expect(execSyncImpl).toHaveBeenCalledOnce(); + }); + + it("selects and caches the macOS Keychain service by secure-storage config", () => { + const firstDir = "/tmp/claude-secure-one"; + const secondDir = "/tmp/claude-secure-two"; + const serviceFor = (configDir: string) => + `Claude Code-credentials-${createHash("sha256").update(configDir).digest("hex").slice(0, 8)}`; + const execSyncImpl = vi.fn((command: string) => + JSON.stringify({ + claudeAiOauth: { + accessToken: command.includes(serviceFor(firstDir)) ? "first-access" : "second-access", + refreshToken: "keychain-refresh", + expiresAt: 1_800_000_000_000, + }, + }), + ) as unknown as typeof execSync; + + vi.stubEnv("CLAUDE_SECURESTORAGE_CONFIG_DIR", firstDir); + expect( + readClaudeCliCredentialsCached({ + execSync: execSyncImpl, + platform: "darwin", + ttlMs: 60_000, + }), + ).toMatchObject({ access: "first-access" }); + + vi.stubEnv("CLAUDE_SECURESTORAGE_CONFIG_DIR", secondDir); + expect( + readClaudeCliCredentialsCached({ + execSync: execSyncImpl, + platform: "darwin", + ttlMs: 60_000, + }), + ).toMatchObject({ access: "second-access" }); + expect(execSyncImpl).toHaveBeenCalledTimes(2); + expect(execSyncImpl).toHaveBeenLastCalledWith( + expect.stringContaining(serviceFor(secondDir)), + expect.any(Object), + ); + vi.unstubAllEnvs(); + }); + + it("keeps explicit no-prompt macOS Keychain reads presence-only", () => { + const execSyncImpl = vi.fn((command: string) => { + expect(command).toMatch(/^\/usr\/bin\/security find-generic-password /u); + expect(command).not.toContain(" -w"); + return "keychain metadata"; + }) as unknown as typeof execSync; const onStoredCredentialUnreadable = vi.fn(); - expect(readClaudeCliCredentialsCached({ onStoredCredentialUnreadable })).toBeNull(); - expect(onStoredCredentialUnreadable).not.toHaveBeenCalled(); + expect( + readClaudeCliCredentialsCached({ + allowKeychainPrompt: false, + execSync: execSyncImpl, + platform: "darwin", + tryKeychainWithoutPrompt: true, + onStoredCredentialUnreadable, + ttlMs: 0, + }), + ).toBeNull(); + expect(execSyncImpl).toHaveBeenCalledOnce(); + expect(onStoredCredentialUnreadable).toHaveBeenCalledOnce(); + }); + + it("does not reuse a no-prompt Keychain miss for a prompt-enabled read", () => { + const homeDir = tempDirs.make("openclaw-sdk-claude-keychain-cache-"); + const execSyncImpl = vi.fn((command: string) => + command.includes(" -w") + ? JSON.stringify({ + claudeAiOauth: { + accessToken: "prompted-access", + refreshToken: "prompted-refresh", + expiresAt: 1_800_000_000_000, + }, + }) + : "keychain metadata", + ) as unknown as typeof execSync; + + expect( + readClaudeCliCredentialsCached({ + allowKeychainPrompt: false, + execSync: execSyncImpl, + homeDir, + platform: "darwin", + tryKeychainWithoutPrompt: true, + ttlMs: 60_000, + }), + ).toBeNull(); + expect( + readClaudeCliCredentialsCached({ + allowKeychainPrompt: true, + execSync: execSyncImpl, + homeDir, + platform: "darwin", + tryKeychainWithoutPrompt: true, + ttlMs: 60_000, + }), + ).toMatchObject({ type: "oauth", access: "prompted-access" }); + expect(execSyncImpl).toHaveBeenCalledOnce(); + expect(execSyncImpl).toHaveBeenCalledWith(expect.stringContaining(" -w"), expect.any(Object)); + }); + + it("does not reuse a silent malformed-file miss for a diagnostic read", async () => { + const homeDir = tempDirs.make("openclaw-sdk-claude-unreadable-cache-"); + const credentialsDir = path.join(homeDir, ".claude"); + await fs.mkdir(credentialsDir, { recursive: true }); + await fs.writeFile(path.join(credentialsDir, ".credentials.json"), "{}\n"); + const onStoredCredentialUnreadable = vi.fn(); + + expect( + readClaudeCliCredentialsCached({ homeDir, platform: "linux", ttlMs: 60_000 }), + ).toBeNull(); + expect( + readClaudeCliCredentialsCached({ + homeDir, + onStoredCredentialUnreadable, + platform: "linux", + tryKeychainWithoutPrompt: true, + ttlMs: 60_000, + }), + ).toBeNull(); + expect(onStoredCredentialUnreadable).toHaveBeenCalledOnce(); }); }); diff --git a/test/scripts/run-tsgo.test.ts b/test/scripts/run-tsgo.test.ts index 143f618ccc8f..bfd9036572b4 100644 --- a/test/scripts/run-tsgo.test.ts +++ b/test/scripts/run-tsgo.test.ts @@ -372,10 +372,11 @@ describe.skipIf(process.platform === "win32")("run-tsgo watchdog", () => { const compilerPid = await waitForPidFile(pidFile, 10_000); wrapper.kill("SIGTERM"); - await expect(waitForChildClose(wrapper, 15_000)).resolves.toEqual({ - code: 143, - signal: null, - }); + const wrapperResult = await waitForChildClose(wrapper, 15_000); + expect([ + { code: 143, signal: null }, + { code: null, signal: "SIGTERM" }, + ]).toContainEqual(wrapperResult); await expect(waitForDead(compilerPid, 2_000)).resolves.toBeUndefined(); } finally { if (wrapper.exitCode === null && wrapper.signalCode === null) {