mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 04:15:48 -06:00
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:
@@ -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);
|
||||
|
||||
@@ -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 }) => ({
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user