fix(gateway): restore view-only macOS desktop observation (#128306)

Worked on by:
- @shakkernerd

Co-authored-by: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com>
Co-authored-by: shakkernerd <165377636+shakkernerd@users.noreply.github.com>
This commit is contained in:
Tak Hoffman
2026-08-25 18:49:37 -05:00
committed by GitHub
parent f50e043029
commit b1ea61e847
16 changed files with 918 additions and 45 deletions
+32 -10
View File
@@ -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<string>();
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({
@@ -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,
@@ -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);
+41
View File
@@ -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 }) => ({
+5 -1
View File
@@ -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 =
+1 -1
View File
@@ -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,
+5 -2
View File
@@ -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");
});
});
+18 -8
View File
@@ -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";
}
@@ -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",
});
});
+9 -1
View File
@@ -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]}`;
}
@@ -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");
@@ -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"), {
@@ -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<typeof mocks.agentCommand>) => {
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<T extends AgentCommandCall = AgentCommandCall>(
commandCallCount: number,
): Promise<T> {
if (mocks.agentCommand.mock.calls.length <= commandCallCount) {
await new Promise<void>((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<typeof setSubagentRegistryDepsForTest>[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,
});
+314 -4
View File
@@ -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<string, unknown> | 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;
}
+324 -3
View File
@@ -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();
});
});
+5 -4
View File
@@ -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) {