mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 20:05:46 -06:00
fix(anthropic): keep Claude CLI authentication native (#129052)
Stop OpenClaw from copying or refreshing Claude CLI OAuth tokens. Claude CLI now owns native login and refresh state; Doctor removes retired copies while preserving CLI routing. Co-authored-by: Ayaan Zaidi <hi@obviy.us>
This commit is contained in:
@@ -1712,7 +1712,6 @@ src/agents/auth-profiles/policy.ts 1
|
||||
src/agents/auth-profiles/portability.ts 1
|
||||
src/agents/auth-profiles/profiles.ts 1
|
||||
src/agents/auth-profiles/repair.ts 1
|
||||
src/agents/auth-profiles/runtime-external-profile-references.ts 2
|
||||
src/agents/auth-profiles/runtime-snapshots.ts 1
|
||||
src/agents/auth-profiles/source-check.ts 1
|
||||
src/agents/auth-profiles/sqlite.ts 2
|
||||
@@ -1738,7 +1737,7 @@ src/agents/cache-trace.ts 3
|
||||
src/agents/channel-tools.ts 3
|
||||
src/agents/cli-auth-epoch.ts 1
|
||||
src/agents/cli-backends.ts 1
|
||||
src/agents/cli-credentials.ts 15
|
||||
src/agents/cli-credentials.ts 10
|
||||
src/agents/cli-executable-identity.ts 3
|
||||
src/agents/cli-output-records.ts 1
|
||||
src/agents/cli-runner.ts 1
|
||||
@@ -2527,7 +2526,6 @@ src/commands/doctor-agent-memory-schema.ts 1
|
||||
src/commands/doctor-auth-flat-profiles.ts 5
|
||||
src/commands/doctor-auth-migration-receipts.ts 3
|
||||
src/commands/doctor-auth-profile-config.ts 4
|
||||
src/commands/doctor-claude-cli.ts 1
|
||||
src/commands/doctor-config-analysis.ts 3
|
||||
src/commands/doctor-config-flow.ts 3
|
||||
src/commands/doctor-config-preflight.cron.ts 4
|
||||
|
||||
@@ -323,7 +323,6 @@ src/agents/bash-tools.exec.approval-id.test.ts
|
||||
src/agents/bash-tools.process.ts
|
||||
src/agents/btw.test.ts
|
||||
src/agents/btw.ts
|
||||
src/agents/cli-auth-epoch.test.ts
|
||||
src/agents/cli-runner.reliability.test.ts
|
||||
src/agents/cli-runner.spawn.test.ts
|
||||
src/agents/cli-runner/execute.supervisor-capture.test.ts
|
||||
|
||||
@@ -56,9 +56,9 @@ claude auth status --text
|
||||
openclaw models auth login --provider anthropic --method cli --set-default
|
||||
```
|
||||
|
||||
This is two steps: log Claude Code into Anthropic on the host, then tell OpenClaw to route Anthropic model selection through the local `claude-cli` backend and store the matching OpenClaw auth profile.
|
||||
This is two steps: log Claude Code into Anthropic on the host, then tell OpenClaw to route Anthropic models through the local `claude-cli` backend.
|
||||
|
||||
At run time OpenClaw treats a reused Claude CLI login as Claude's own credential: it verifies the host's current `claude` login matches the selected profile's account and then lets the `claude` subprocess authenticate natively, so Claude keeps refreshing its own login during runs. OpenClaw never forwards a copied token for this path. If the host login is missing or belongs to a different account, the run fails before spawn with the exact re-authentication commands. OpenClaw-managed credentials (Anthropic OAuth login profiles, setup tokens, API keys) are still delivered to the subprocess directly and refreshed by OpenClaw where applicable.
|
||||
OpenClaw never reads, stores, refreshes, or forwards the native login tokens. The installed `claude` process reads and refreshes its own login. `CLAUDE_CONFIG_DIR` selects a separate Claude login when set on the Gateway process. OpenClaw-managed setup tokens and API keys remain separate credentials.
|
||||
|
||||
The gateway service must resolve `claude` on `PATH`. If a deployment needs a
|
||||
nonstandard executable path, register a wrapper through a
|
||||
|
||||
@@ -118,10 +118,11 @@ The `openclaw agent` command also has its own request deadline. Its 600-second f
|
||||
|
||||
The bundled Anthropic plugin runs the installed Claude Code executable through
|
||||
Anthropic's official Agent SDK. Claude Code owns its existing local login and
|
||||
subscription; OpenClaw does not extract that login or send synthesized
|
||||
Anthropic API requests. Compatible agent turns share one warm SDK query and
|
||||
Claude Code subprocess. A changed model, system prompt, authenticated identity,
|
||||
or tool policy starts a new query; persisted Claude session IDs still provide
|
||||
subscription. OpenClaw uses a non-secret route marker. It never reads, persists,
|
||||
refreshes, or forwards native tokens, or sends synthesized Anthropic API
|
||||
requests. Compatible agent turns share one warm SDK query and
|
||||
Claude Code subprocess. A changed model, system prompt, or tool policy starts a
|
||||
new query; persisted Claude session IDs still provide
|
||||
conversation continuity when the gateway or subprocess restarts.
|
||||
|
||||
Keep Claude Code updated, especially if the SDK reports an incompatible
|
||||
@@ -350,12 +351,12 @@ If no MCP servers are enabled, OpenClaw still injects a strict config when a bac
|
||||
|
||||
Session-scoped bundled MCP runtimes are cached for reuse within a session, then reaped after 10 minutes of idle time. One-shot embedded runs such as auth probes, slug generation, and active-memory recall request cleanup at run end so stdio children and Streamable HTTP/SSE streams do not outlive the run.
|
||||
|
||||
For `claude-cli`, an imported native OAuth profile reuses the matching,
|
||||
identity-verified Claude Code login without forwarding an extracted access
|
||||
token. Explicit non-native API-key and token profiles continue to use the
|
||||
protected, per-invocation credential-forwarding CLI path, keeping selected
|
||||
per-agent profiles authoritative without placing credential values in command
|
||||
arguments.
|
||||
For `claude-cli`, the installed Claude Code process uses its current native
|
||||
login. OpenClaw uses a non-secret route marker and never reads, persists,
|
||||
refreshes, selects, or forwards the native tokens.
|
||||
Set `CLAUDE_CONFIG_DIR` on the Gateway process to use a separate Claude configuration directory.
|
||||
Explicit OpenClaw-managed API-key and token profiles continue to use the
|
||||
protected, per-invocation credential-forwarding CLI path.
|
||||
|
||||
## Reseed history cap
|
||||
|
||||
|
||||
+20
-12
@@ -15,15 +15,16 @@ Anthropic builds the **Claude** model family. OpenClaw supports two auth routes:
|
||||
|
||||
OpenClaw detects the available Anthropic credential and selects the matching usage surface:
|
||||
|
||||
- Claude subscription/setup credentials show quota windows and optional extra-usage budget.
|
||||
- OpenClaw-managed subscription/setup credentials show quota windows and optional extra-usage budget.
|
||||
- Native Claude CLI logins stay under Claude's exclusive refresh control, so OpenClaw does not poll their quota endpoint.
|
||||
- `ANTHROPIC_ADMIN_KEY` or `ANTHROPIC_ADMIN_API_KEY` shows 30 days of provider-reported organization cost and Messages API usage in Control UI **Usage**, including daily spend, token/cache totals, top models, and cost categories.
|
||||
- An `sk-ant-admin...` credential stored in the Anthropic provider profile is detected as an Admin API key automatically.
|
||||
|
||||
Admin API cost history comes from Anthropic's [Usage and Cost API](https://platform.claude.com/docs/en/manage-claude/usage-cost-api). It is actual provider billing, separate from OpenClaw's session-derived estimated cost.
|
||||
|
||||
<Warning>
|
||||
Claude Code owns its existing login and subscription; OpenClaw does not extract
|
||||
that login or synthesize Anthropic API requests. Agent SDK and `claude -p`
|
||||
Claude Code owns its existing login and subscription; OpenClaw does not persist
|
||||
or refresh that login. Agent SDK and `claude -p`
|
||||
usage currently draw from the signed-in subscription's limits. API-key auth
|
||||
uses separate pay-as-you-go billing and is preferable for shared automation or
|
||||
predictable production spend.
|
||||
@@ -89,6 +90,13 @@ OpenClaw release:
|
||||
|
||||
```bash
|
||||
claude --version
|
||||
claude auth status --text
|
||||
```
|
||||
|
||||
If Claude is not logged in, authenticate once as the Gateway user:
|
||||
|
||||
```bash
|
||||
claude auth login
|
||||
```
|
||||
|
||||
If the installed build is incompatible, update Claude Code and restart
|
||||
@@ -104,15 +112,15 @@ OpenClaw release:
|
||||
# choose: Claude CLI
|
||||
```
|
||||
|
||||
OpenClaw detects the existing Claude CLI login. Normal agent turns use
|
||||
the official Agent SDK with the installed, authenticated Claude Code
|
||||
executable, including native-tool turns whose approvals remain under
|
||||
OpenClaw control. Schema-valid native calls pass through OpenClaw's
|
||||
canonical tool policy before native approval. Imported native OAuth
|
||||
profiles reuse the verified Claude Code login; explicitly selected
|
||||
API-key or token credentials still use protected file-descriptor
|
||||
forwarding. Isolated side-question
|
||||
completions and paired-node execution retain the supervised CLI path.
|
||||
Normal agent turns use the official Agent SDK with the installed,
|
||||
authenticated Claude Code executable. OpenClaw uses a non-secret route
|
||||
marker and never reads, persists, refreshes, selects, or forwards the
|
||||
native login tokens. Claude owns the login and token refresh lifecycle.
|
||||
Explicitly selected API-key or token credentials still use protected
|
||||
file-descriptor forwarding. Native-tool approvals remain under OpenClaw
|
||||
control. Schema-valid native calls pass through OpenClaw's canonical
|
||||
tool policy before native approval. Isolated side-question completions
|
||||
and paired-node execution retain the supervised CLI path.
|
||||
|
||||
Consecutive agent turns reuse the same warm Agent SDK query and Claude
|
||||
Code subprocess when their authenticated session and execution policy
|
||||
|
||||
@@ -53,6 +53,7 @@ function createContext(
|
||||
cwd: "/tmp/openclaw-workspace",
|
||||
env: {
|
||||
HOME: "/tmp/claude-login-home",
|
||||
CLAUDE_CONFIG_DIR: "/tmp/claude-login-home/custom-config",
|
||||
PATH: "/usr/local/bin:/usr/bin",
|
||||
OPENCLAW_MCP_TOKEN: "test-grant-not-a-real-secret",
|
||||
},
|
||||
|
||||
@@ -1,35 +1,34 @@
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
const { readClaudeCliCredentialsCached } = vi.hoisted(() => ({
|
||||
readClaudeCliCredentialsCached: vi.fn(),
|
||||
const { spawnSync } = vi.hoisted(() => ({
|
||||
spawnSync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/provider-auth", () => ({
|
||||
readClaudeCliCredentialsCached,
|
||||
}));
|
||||
vi.mock("node:child_process", () => ({ spawnSync }));
|
||||
|
||||
const { readClaudeCliCredentialsForRuntime, readClaudeCliCredentialsForSetupNonInteractive } =
|
||||
await import("./cli-auth-seam.js");
|
||||
const { probeClaudeCliAuthStatus } = await import("./cli-auth-seam.js");
|
||||
|
||||
beforeEach(() => {
|
||||
readClaudeCliCredentialsCached.mockReset();
|
||||
spawnSync.mockReset();
|
||||
});
|
||||
|
||||
it("keeps runtime Claude credential reads on the non-prompting path", () => {
|
||||
readClaudeCliCredentialsForRuntime();
|
||||
it("asks Claude CLI to verify its own login", () => {
|
||||
spawnSync.mockReturnValue({
|
||||
status: 0,
|
||||
stdout: JSON.stringify({ loggedIn: true, authMethod: "claude.ai" }),
|
||||
});
|
||||
|
||||
expect(readClaudeCliCredentialsCached).toHaveBeenCalledWith({ allowKeychainPrompt: false });
|
||||
});
|
||||
expect(probeClaudeCliAuthStatus()).toEqual({ status: "available" });
|
||||
|
||||
it("uses the bounded credential inspector only for non-interactive setup", () => {
|
||||
readClaudeCliCredentialsForSetupNonInteractive();
|
||||
|
||||
expect(readClaudeCliCredentialsCached).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
allowKeychainPrompt: false,
|
||||
tryKeychainWithoutPrompt: true,
|
||||
ttlMs: 0,
|
||||
onStoredCredentialUnreadable: expect.any(Function),
|
||||
}),
|
||||
expect(spawnSync).toHaveBeenCalledWith(
|
||||
"claude",
|
||||
["auth", "status", "--json"],
|
||||
expect.objectContaining({ timeout: 3_000 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not inspect Claude token storage when the CLI reports logout", () => {
|
||||
spawnSync.mockReturnValue({ status: 1, stdout: "" });
|
||||
|
||||
expect(probeClaudeCliAuthStatus()).toEqual({ status: "missing" });
|
||||
});
|
||||
|
||||
@@ -1,31 +1,33 @@
|
||||
/**
|
||||
* Claude CLI auth seam. Setup may prompt for keychain-backed credentials while
|
||||
* runtime paths stay non-interactive.
|
||||
*/
|
||||
import { readClaudeCliCredentialsCached } from "openclaw/plugin-sdk/provider-auth";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
|
||||
/** Read Claude CLI credentials for interactive setup paths. */
|
||||
export function readClaudeCliCredentialsForSetup() {
|
||||
return readClaudeCliCredentialsCached();
|
||||
}
|
||||
type ClaudeCliAuthStatus = { status: "available" } | { status: "missing" | "unreadable" };
|
||||
|
||||
/** Read Claude CLI credentials for setup checks that must not prompt. */
|
||||
export function readClaudeCliCredentialsForSetupNonInteractive() {
|
||||
let unreadable = false;
|
||||
const credential = readClaudeCliCredentialsCached({
|
||||
allowKeychainPrompt: false,
|
||||
tryKeychainWithoutPrompt: true,
|
||||
ttlMs: 0,
|
||||
onStoredCredentialUnreadable: () => {
|
||||
unreadable = true;
|
||||
},
|
||||
/** Ask Claude CLI whether its own login is usable without reading token material. */
|
||||
export function probeClaudeCliAuthStatus(params?: {
|
||||
command?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): ClaudeCliAuthStatus {
|
||||
const result = spawnSync(params?.command ?? "claude", ["auth", "status", "--json"], {
|
||||
encoding: "utf8",
|
||||
env: params?.env ?? process.env,
|
||||
maxBuffer: 64 * 1024,
|
||||
timeout: 3_000,
|
||||
windowsHide: true,
|
||||
});
|
||||
return credential
|
||||
? ({ status: "available", credential } as const)
|
||||
: ({ status: unreadable ? "unreadable" : "missing" } as const);
|
||||
}
|
||||
|
||||
/** Read Claude CLI credentials for runtime without keychain prompts. */
|
||||
export function readClaudeCliCredentialsForRuntime() {
|
||||
return readClaudeCliCredentialsCached({ allowKeychainPrompt: false });
|
||||
if (result.error || result.status === null) {
|
||||
return { status: "unreadable" };
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
return { status: "missing" };
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(result.stdout);
|
||||
if (!isRecord(parsed) || parsed.loggedIn !== true) {
|
||||
return { status: "missing" };
|
||||
}
|
||||
return { status: "available" };
|
||||
} catch {
|
||||
return { status: "unreadable" };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,10 +93,9 @@ function resolveClaudeCliAuthInput(
|
||||
credential: ClaudeCliAuthCredential | undefined,
|
||||
): ClaudeCliPreparedExecution | undefined {
|
||||
// Forwarded OAuth here is OpenClaw-managed material (its refresh path is
|
||||
// OpenClaw-owned). Imported native `claude` logins are never forwarded —
|
||||
// core runs those as identity-verified passthrough — so an expired token
|
||||
// reaching this point is a real fault worth failing loudly, not refreshable
|
||||
// state this plugin could repair.
|
||||
// OpenClaw-owned). Native `claude` logins are never forwarded; the current
|
||||
// Claude process reads its own config directory. An expired token here is
|
||||
// therefore OpenClaw-managed state that must fail loudly.
|
||||
if (credential?.type === "oauth" && "access" in credential) {
|
||||
const expires = "expires" in credential ? credential.expires : undefined;
|
||||
if (typeof expires !== "number" || !Number.isFinite(expires) || expires <= Date.now()) {
|
||||
|
||||
@@ -4,10 +4,8 @@
|
||||
*/
|
||||
/** Synthetic provider/backend id for Claude Code CLI-backed Anthropic models. */
|
||||
export const CLAUDE_CLI_BACKEND_ID = "claude-cli";
|
||||
/** Non-secret marker for Claude Code settings.json apiKeyHelper auth. */
|
||||
export const CLAUDE_CLI_API_KEY_HELPER_AUTH_MARKER = ["openclaw", "claude-cli-api-key-helper"].join(
|
||||
":",
|
||||
);
|
||||
/** Non-secret marker telling OpenClaw that the installed Claude CLI owns auth. */
|
||||
export const CLAUDE_CLI_NATIVE_AUTH_MARKER = ["openclaw", "claude-cli-native-auth"].join(":");
|
||||
/** Default Claude CLI model ref for agent defaults and live tests. */
|
||||
export const CLAUDE_CLI_DEFAULT_MODEL_REF = `${CLAUDE_CLI_BACKEND_ID}/claude-opus-5`;
|
||||
/** Provider-relative model id for Anthropic runtime-policy resolution. */
|
||||
|
||||
@@ -5,18 +5,15 @@ import type {
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { readClaudeCliCredentialsForSetup, readClaudeCliCredentialsForSetupNonInteractive } =
|
||||
vi.hoisted(() => ({
|
||||
readClaudeCliCredentialsForSetup: vi.fn(),
|
||||
readClaudeCliCredentialsForSetupNonInteractive: vi.fn(),
|
||||
}));
|
||||
const { probeClaudeCliAuthStatus } = vi.hoisted(() => ({
|
||||
probeClaudeCliAuthStatus: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./cli-auth-seam.js", async (importActual) => {
|
||||
const actual = await importActual<typeof import("./cli-auth-seam.js")>();
|
||||
return {
|
||||
...actual,
|
||||
readClaudeCliCredentialsForSetup,
|
||||
readClaudeCliCredentialsForSetupNonInteractive,
|
||||
probeClaudeCliAuthStatus,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -27,8 +24,8 @@ const { createTestWizardPrompter, registerSingleProviderPlugin } =
|
||||
const { default: anthropicPlugin } = await import("./index.js");
|
||||
|
||||
beforeEach(() => {
|
||||
readClaudeCliCredentialsForSetup.mockReset();
|
||||
readClaudeCliCredentialsForSetupNonInteractive.mockReset();
|
||||
probeClaudeCliAuthStatus.mockReset();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
@@ -480,7 +477,7 @@ describe("anthropic cli migration", () => {
|
||||
});
|
||||
|
||||
it("registered cli auth tells users to run claude auth login when local auth is missing", async () => {
|
||||
readClaudeCliCredentialsForSetup.mockReturnValue(null);
|
||||
probeClaudeCliAuthStatus.mockReturnValue({ status: "missing" });
|
||||
const method = await resolveAnthropicCliAuthMethod();
|
||||
|
||||
await expect(method.run(createProviderAuthContext())).rejects.toThrow(
|
||||
@@ -492,14 +489,7 @@ describe("anthropic cli migration", () => {
|
||||
});
|
||||
|
||||
it("registered cli auth returns the same migration result as the builder", async () => {
|
||||
const credential = {
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: Date.now() + 60_000,
|
||||
} as const;
|
||||
readClaudeCliCredentialsForSetup.mockReturnValue(credential);
|
||||
probeClaudeCliAuthStatus.mockReturnValue({ status: "available" });
|
||||
const method = await resolveAnthropicCliAuthMethod();
|
||||
const config = {
|
||||
agents: {
|
||||
@@ -518,80 +508,31 @@ describe("anthropic cli migration", () => {
|
||||
};
|
||||
|
||||
await expect(method.run(createProviderAuthContext(config))).resolves.toEqual(
|
||||
buildAnthropicCliMigrationResult(config, credential),
|
||||
buildAnthropicCliMigrationResult(config),
|
||||
);
|
||||
});
|
||||
|
||||
it("stores a claude-cli oauth profile when Claude CLI credentials are available", () => {
|
||||
const result = buildAnthropicCliMigrationResult(
|
||||
{},
|
||||
{
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: 123,
|
||||
},
|
||||
);
|
||||
it("probes auth with the Claude runtime command and setup environment", async () => {
|
||||
probeClaudeCliAuthStatus.mockReturnValue({ status: "available" });
|
||||
const method = await resolveAnthropicCliAuthMethod();
|
||||
const ctx = createProviderAuthContext();
|
||||
ctx.env = { CLAUDE_CONFIG_DIR: "/tmp/claude-work" };
|
||||
|
||||
expect(result.profiles).toEqual([
|
||||
{
|
||||
profileId: "anthropic:claude-cli",
|
||||
credential: {
|
||||
type: "oauth",
|
||||
provider: "claude-cli",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: 123,
|
||||
},
|
||||
},
|
||||
]);
|
||||
await method.run(ctx);
|
||||
|
||||
expect(probeClaudeCliAuthStatus).toHaveBeenCalledWith({
|
||||
command: "claude",
|
||||
env: { CLAUDE_CONFIG_DIR: "/tmp/claude-work" },
|
||||
});
|
||||
});
|
||||
|
||||
it("stores a claude-cli token profile when Claude CLI only exposes a bearer token", () => {
|
||||
const result = buildAnthropicCliMigrationResult(
|
||||
{},
|
||||
{
|
||||
type: "token",
|
||||
provider: "anthropic",
|
||||
token: "bearer-token",
|
||||
expires: 123,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.profiles).toEqual([
|
||||
{
|
||||
profileId: "anthropic:claude-cli",
|
||||
credential: {
|
||||
type: "token",
|
||||
provider: "claude-cli",
|
||||
token: "bearer-token",
|
||||
expires: 123,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not persist a synthetic profile for Claude CLI apiKeyHelper auth", () => {
|
||||
const result = buildAnthropicCliMigrationResult(
|
||||
{},
|
||||
{ type: "api_key_helper", provider: "anthropic", helperHash: "helper-hash" },
|
||||
);
|
||||
|
||||
it("does not copy native Claude credentials into OpenClaw", () => {
|
||||
const result = buildAnthropicCliMigrationResult({});
|
||||
expect(result.profiles).toEqual([]);
|
||||
});
|
||||
|
||||
it("registered non-interactive cli auth keeps anthropic fallbacks and selects claude-cli runtime", async () => {
|
||||
readClaudeCliCredentialsForSetupNonInteractive.mockReturnValue({
|
||||
status: "available",
|
||||
credential: {
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: Date.now() + 60_000,
|
||||
},
|
||||
});
|
||||
probeClaudeCliAuthStatus.mockReturnValue({ status: "available" });
|
||||
const method = await resolveAnthropicCliAuthMethod();
|
||||
const config = {
|
||||
agents: {
|
||||
@@ -637,8 +578,21 @@ describe("anthropic cli migration", () => {
|
||||
expect(defaults?.models?.["openai/gpt-5.2"]).toEqual({});
|
||||
});
|
||||
|
||||
it("uses the Gateway Claude config directory for non-interactive auth probes", async () => {
|
||||
vi.stubEnv("CLAUDE_CONFIG_DIR", "/tmp/gateway-claude-work");
|
||||
probeClaudeCliAuthStatus.mockReturnValue({ status: "available" });
|
||||
const method = await resolveAnthropicCliAuthMethod();
|
||||
|
||||
await method.runNonInteractive?.(createProviderAuthMethodNonInteractiveContext());
|
||||
|
||||
expect(probeClaudeCliAuthStatus).toHaveBeenCalledWith({
|
||||
command: "claude",
|
||||
env: expect.objectContaining({ CLAUDE_CONFIG_DIR: "/tmp/gateway-claude-work" }),
|
||||
});
|
||||
});
|
||||
|
||||
it("registered non-interactive cli auth reports missing local auth and exits cleanly", async () => {
|
||||
readClaudeCliCredentialsForSetupNonInteractive.mockReturnValue({ status: "missing" });
|
||||
probeClaudeCliAuthStatus.mockReturnValue({ status: "missing" });
|
||||
const method = await resolveAnthropicCliAuthMethod();
|
||||
const ctx = createProviderAuthMethodNonInteractiveContext();
|
||||
|
||||
@@ -653,15 +607,15 @@ describe("anthropic cli migration", () => {
|
||||
});
|
||||
|
||||
it("registered non-interactive cli auth reports stored credentials that need interaction", async () => {
|
||||
readClaudeCliCredentialsForSetupNonInteractive.mockReturnValue({ status: "unreadable" });
|
||||
probeClaudeCliAuthStatus.mockReturnValue({ status: "unreadable" });
|
||||
const method = await resolveAnthropicCliAuthMethod();
|
||||
const ctx = createProviderAuthMethodNonInteractiveContext();
|
||||
|
||||
await expect(method.runNonInteractive?.(ctx)).resolves.toBeNull();
|
||||
expect(ctx.runtime.error).toHaveBeenCalledWith(
|
||||
[
|
||||
'Auth choice "anthropic-cli" found Claude CLI credentials on this host, but they could not be read non-interactively.',
|
||||
"Re-run this command without --non-interactive, or use --auth-choice setup-token / --anthropic-api-key <key>.",
|
||||
'Auth choice "anthropic-cli" could not verify the installed Claude CLI login.',
|
||||
"Run claude auth status, then retry.",
|
||||
].join("\n"),
|
||||
);
|
||||
expect(ctx.runtime.exit).toHaveBeenCalledWith(1);
|
||||
|
||||
@@ -2,22 +2,16 @@
|
||||
* Claude CLI setup migration helpers. They rewrite legacy Claude CLI model refs
|
||||
* to Anthropic refs while preserving runtime allowlist entries for CLI execution.
|
||||
*/
|
||||
import {
|
||||
CLAUDE_CLI_PROFILE_ID,
|
||||
type OpenClawConfig,
|
||||
type ProviderAuthResult,
|
||||
} from "openclaw/plugin-sdk/provider-auth";
|
||||
import type { OpenClawConfig, ProviderAuthResult } from "openclaw/plugin-sdk/provider-auth";
|
||||
import {
|
||||
isRecord,
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { resolveClaudeCliAnthropicModelRefs } from "./claude-model-refs.js";
|
||||
import type { readClaudeCliCredentialsForSetup } from "./cli-auth-seam.js";
|
||||
import { CLAUDE_CLI_BACKEND_ID, CLAUDE_CLI_DEFAULT_ALLOWLIST_REFS } from "./cli-shared.js";
|
||||
|
||||
type AgentDefaultsModel = NonNullable<NonNullable<OpenClawConfig["agents"]>["defaults"]>["model"];
|
||||
type AgentDefaultsModels = NonNullable<NonNullable<OpenClawConfig["agents"]>["defaults"]>["models"];
|
||||
type ClaudeCliCredential = NonNullable<ReturnType<typeof readClaudeCliCredentialsForSetup>>;
|
||||
|
||||
function toAnthropicModelRef(raw: string): string | null {
|
||||
return resolveClaudeCliAnthropicModelRefs(raw)?.rewriteRef ?? null;
|
||||
@@ -180,47 +174,8 @@ function modelEntryWithClaudeCliRuntime(entry: unknown): Record<string, unknown>
|
||||
return base;
|
||||
}
|
||||
|
||||
function buildClaudeCliAuthProfiles(
|
||||
credential?: ClaudeCliCredential | null,
|
||||
): ProviderAuthResult["profiles"] {
|
||||
if (!credential) {
|
||||
return [];
|
||||
}
|
||||
if (credential.type === "oauth") {
|
||||
return [
|
||||
{
|
||||
profileId: CLAUDE_CLI_PROFILE_ID,
|
||||
credential: {
|
||||
type: "oauth",
|
||||
provider: CLAUDE_CLI_BACKEND_ID,
|
||||
access: credential.access,
|
||||
refresh: credential.refresh,
|
||||
expires: credential.expires,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
if (credential.type === "api_key_helper") {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
profileId: CLAUDE_CLI_PROFILE_ID,
|
||||
credential: {
|
||||
type: "token",
|
||||
provider: CLAUDE_CLI_BACKEND_ID,
|
||||
token: credential.token,
|
||||
expires: credential.expires,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** Build the config migration result for adopting Claude CLI-backed Anthropic defaults. */
|
||||
export function buildAnthropicCliMigrationResult(
|
||||
config: OpenClawConfig,
|
||||
credential?: ClaudeCliCredential | null,
|
||||
): ProviderAuthResult {
|
||||
export function buildAnthropicCliMigrationResult(config: OpenClawConfig): ProviderAuthResult {
|
||||
const defaults = config.agents?.defaults;
|
||||
const rewrittenModel = rewriteModelSelection(defaults?.model);
|
||||
const rewrittenModels = rewriteModelEntryMap(defaults?.models);
|
||||
@@ -235,7 +190,7 @@ export function buildAnthropicCliMigrationResult(
|
||||
const defaultModel = rewrittenModel.primary ?? "anthropic/claude-opus-5";
|
||||
|
||||
return {
|
||||
profiles: buildClaudeCliAuthProfiles(credential),
|
||||
profiles: [],
|
||||
configPatch: {
|
||||
agents: {
|
||||
defaults: {
|
||||
|
||||
@@ -881,7 +881,7 @@ describe("normalizeClaudeBackendConfig", () => {
|
||||
expect(backend.config.clearEnv).toContain("ANTHROPIC_BASE_URL");
|
||||
expect(backend.config.clearEnv).toContain("ANTHROPIC_CUSTOM_HEADERS");
|
||||
expect(backend.config.clearEnv).toContain("ANTHROPIC_OAUTH_TOKEN");
|
||||
expect(backend.config.clearEnv).toContain("CLAUDE_CONFIG_DIR");
|
||||
expect(backend.config.clearEnv).not.toContain("CLAUDE_CONFIG_DIR");
|
||||
expect(backend.config.clearEnv).toContain("CLAUDE_CODE_AUTO_COMPACT_WINDOW");
|
||||
expect(backend.config.clearEnv).toContain("CLAUDE_CODE_USE_BEDROCK");
|
||||
expect(backend.config.clearEnv).toContain("CLAUDE_CODE_OAUTH_TOKEN");
|
||||
|
||||
@@ -22,7 +22,8 @@ export {
|
||||
// Claude Code honors provider-routing, auth, and config-root env before
|
||||
// consulting its local login state, so inherited shell overrides must not
|
||||
// steer OpenClaw-managed Claude CLI runs toward a different provider,
|
||||
// endpoint, token source, plugin/config tree, or telemetry bootstrap mode.
|
||||
// endpoint, token source, plugin source, or telemetry bootstrap mode. Claude's
|
||||
// config directory remains inherited because it owns the selected native login.
|
||||
/** Environment variables removed before launching OpenClaw-managed Claude CLI runs. */
|
||||
export const CLAUDE_CLI_CLEAR_ENV = [
|
||||
"ANTHROPIC_API_KEY",
|
||||
@@ -33,7 +34,6 @@ export const CLAUDE_CLI_CLEAR_ENV = [
|
||||
"ANTHROPIC_CUSTOM_HEADERS",
|
||||
"ANTHROPIC_OAUTH_TOKEN",
|
||||
"ANTHROPIC_UNIX_SOCKET",
|
||||
"CLAUDE_CONFIG_DIR",
|
||||
// Re-injected per run from OpenClaw's canonical context budget.
|
||||
"CLAUDE_CODE_AUTO_COMPACT_WINDOW",
|
||||
// Re-injected only for 200K runs. Claude's user settings `env` block has
|
||||
|
||||
@@ -4,6 +4,7 @@ import { listAgentIds, resolveAgentConfig } from "openclaw/plugin-sdk/agent-scop
|
||||
* model refs and cache-retention params based on configured auth mode.
|
||||
*/
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { CLAUDE_CLI_PROFILE_ID } from "openclaw/plugin-sdk/provider-auth";
|
||||
import {
|
||||
isRecord,
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
@@ -32,6 +33,9 @@ function resolveAnthropicDefaultAuthMode(
|
||||
config: OpenClawConfig,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): "api_key" | "oauth" | null {
|
||||
if (usesRetiredClaudeCliProviderEntry(config)) {
|
||||
return "oauth";
|
||||
}
|
||||
const profiles = config.auth?.profiles ?? {};
|
||||
const anthropicProfiles = Object.entries(profiles).filter(
|
||||
([, profile]) =>
|
||||
@@ -85,6 +89,13 @@ function resolveAnthropicDefaultAuthMode(
|
||||
return null;
|
||||
}
|
||||
|
||||
function usesRetiredClaudeCliProviderEntry(config: OpenClawConfig): boolean {
|
||||
return Object.entries(config.models?.providers ?? {}).some(
|
||||
([provider, entry]) =>
|
||||
normalizeProviderId(provider) === "anthropic" && entry.apiKey === CLAUDE_CLI_PROFILE_ID,
|
||||
);
|
||||
}
|
||||
|
||||
function resolveModelPrimaryValue(
|
||||
value: string | { primary?: string; fallbacks?: string[] } | undefined,
|
||||
): string | undefined {
|
||||
@@ -159,6 +170,9 @@ function usesClaudeCliModelSelection(config: OpenClawConfig): boolean {
|
||||
}
|
||||
|
||||
function usesSelectedClaudeCliAuthProfile(config: OpenClawConfig): boolean {
|
||||
if (usesRetiredClaudeCliProviderEntry(config)) {
|
||||
return true;
|
||||
}
|
||||
const profiles = config.auth?.profiles ?? {};
|
||||
const orderedProfileIds = [
|
||||
...(config.auth?.order?.anthropic ?? []),
|
||||
|
||||
@@ -12,28 +12,23 @@ import {
|
||||
import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures";
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { readClaudeCliCredentialsForSetupMock, readClaudeCliCredentialsForRuntimeMock } = vi.hoisted(
|
||||
() => ({
|
||||
readClaudeCliCredentialsForSetupMock: vi.fn(),
|
||||
readClaudeCliCredentialsForRuntimeMock: vi.fn(),
|
||||
}),
|
||||
);
|
||||
const { probeClaudeCliAuthStatusMock } = vi.hoisted(() => ({
|
||||
probeClaudeCliAuthStatusMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./cli-auth-seam.js", () => {
|
||||
return {
|
||||
readClaudeCliCredentialsForSetup: readClaudeCliCredentialsForSetupMock,
|
||||
readClaudeCliCredentialsForRuntime: readClaudeCliCredentialsForRuntimeMock,
|
||||
probeClaudeCliAuthStatus: probeClaudeCliAuthStatusMock,
|
||||
};
|
||||
});
|
||||
|
||||
import { buildClaudeCliCatalogEntries } from "./cli-catalog.js";
|
||||
import { CLAUDE_CLI_API_KEY_HELPER_AUTH_MARKER } from "./cli-constants.js";
|
||||
import { CLAUDE_CLI_NATIVE_AUTH_MARKER } from "./cli-constants.js";
|
||||
import anthropicPlugin from "./index.js";
|
||||
import anthropicProviderDiscovery from "./provider-discovery.js";
|
||||
|
||||
beforeEach(() => {
|
||||
readClaudeCliCredentialsForSetupMock.mockReset();
|
||||
readClaudeCliCredentialsForRuntimeMock.mockReset();
|
||||
probeClaudeCliAuthStatusMock.mockReset();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
@@ -104,6 +99,12 @@ describe("anthropic provider replay hooks", () => {
|
||||
expect(backend.config.reliability?.watchdog?.resume).toBeUndefined();
|
||||
});
|
||||
|
||||
it("declares the copied Claude CLI profile as retired", async () => {
|
||||
const provider = await registerSingleProviderPlugin(anthropicPlugin);
|
||||
|
||||
expect(provider.deprecatedProfileIds).toEqual(["anthropic:claude-cli"]);
|
||||
});
|
||||
|
||||
it("lets native session discovery be disabled without disabling Anthropic", () => {
|
||||
const registerCliBackend = vi.fn();
|
||||
const registerNodeHostCommand = vi.fn();
|
||||
@@ -404,6 +405,35 @@ describe("anthropic provider replay hooks", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("backfills Claude CLI routing from a retired provider-entry profile reference", async () => {
|
||||
const provider = await registerSingleProviderPlugin(anthropicPlugin);
|
||||
|
||||
const next = provider.applyConfigDefaults?.({
|
||||
provider: "anthropic",
|
||||
env: {},
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
anthropic: {
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
apiKey: "anthropic:claude-cli",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "anthropic/claude-sonnet-4-6" },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
|
||||
expect(next?.agents?.defaults?.models?.["anthropic/claude-sonnet-4-6"]?.agentRuntime).toEqual({
|
||||
id: "claude-cli",
|
||||
});
|
||||
});
|
||||
|
||||
it("backfills raw and canonical Claude CLI policies for provider-qualified shorthand refs", async () => {
|
||||
const provider = await registerSingleProviderPlugin(anthropicPlugin);
|
||||
|
||||
@@ -1374,62 +1404,7 @@ describe("anthropic provider replay hooks", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves claude-cli synthetic oauth auth", async () => {
|
||||
readClaudeCliCredentialsForRuntimeMock.mockReset();
|
||||
readClaudeCliCredentialsForRuntimeMock.mockReturnValue({
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: 123,
|
||||
});
|
||||
|
||||
const provider = await registerSingleProviderPlugin(anthropicPlugin);
|
||||
|
||||
expect(
|
||||
provider.resolveSyntheticAuth?.({
|
||||
provider: "claude-cli",
|
||||
} as never),
|
||||
).toEqual({
|
||||
apiKey: "access-token",
|
||||
source: "Claude CLI native auth",
|
||||
mode: "oauth",
|
||||
expiresAt: 123,
|
||||
});
|
||||
expect(readClaudeCliCredentialsForRuntimeMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("resolves claude-cli synthetic token auth", async () => {
|
||||
readClaudeCliCredentialsForRuntimeMock.mockReset();
|
||||
readClaudeCliCredentialsForRuntimeMock.mockReturnValue({
|
||||
type: "token",
|
||||
provider: "anthropic",
|
||||
token: "bearer-token",
|
||||
expires: 123,
|
||||
});
|
||||
|
||||
const provider = await registerSingleProviderPlugin(anthropicPlugin);
|
||||
|
||||
expect(
|
||||
provider.resolveSyntheticAuth?.({
|
||||
provider: "claude-cli",
|
||||
} as never),
|
||||
).toEqual({
|
||||
apiKey: "bearer-token",
|
||||
source: "Claude CLI native auth",
|
||||
mode: "token",
|
||||
expiresAt: 123,
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves claude-cli apiKeyHelper synthetic auth without exposing helper output", async () => {
|
||||
readClaudeCliCredentialsForRuntimeMock.mockReset();
|
||||
readClaudeCliCredentialsForRuntimeMock.mockReturnValue({
|
||||
type: "api_key_helper",
|
||||
provider: "anthropic",
|
||||
helperHash: "helper-hash",
|
||||
});
|
||||
|
||||
it("resolves claude-cli with a non-secret native auth marker", async () => {
|
||||
const provider = await registerSingleProviderPlugin(anthropicPlugin);
|
||||
|
||||
const runtimeAuth = provider.resolveSyntheticAuth?.({
|
||||
@@ -1439,21 +1414,14 @@ describe("anthropic provider replay hooks", () => {
|
||||
provider: "claude-cli",
|
||||
} as never);
|
||||
for (const auth of [runtimeAuth, discoveryAuth]) {
|
||||
expect(auth?.apiKey).toBe(CLAUDE_CLI_API_KEY_HELPER_AUTH_MARKER);
|
||||
expect(auth?.source).toBe("Claude CLI apiKeyHelper");
|
||||
expect(auth?.mode).toBe("api-key");
|
||||
expect(auth?.apiKey).toBe(CLAUDE_CLI_NATIVE_AUTH_MARKER);
|
||||
expect(auth?.source).toBe("Claude CLI native auth");
|
||||
expect(auth?.mode).toBe("oauth");
|
||||
}
|
||||
});
|
||||
|
||||
it("stores a claude-cli auth profile during anthropic cli migration", async () => {
|
||||
readClaudeCliCredentialsForSetupMock.mockReset();
|
||||
readClaudeCliCredentialsForSetupMock.mockReturnValue({
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: "setup-access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: 123,
|
||||
});
|
||||
it("does not copy native Claude auth during anthropic cli migration", async () => {
|
||||
probeClaudeCliAuthStatusMock.mockReturnValue({ status: "available" });
|
||||
|
||||
const provider = await registerSingleProviderPlugin(anthropicPlugin);
|
||||
const cliAuth = provider.auth.find((entry) => entry.id === "cli");
|
||||
@@ -1466,18 +1434,7 @@ describe("anthropic provider replay hooks", () => {
|
||||
config: {},
|
||||
} as never);
|
||||
|
||||
expect(result?.profiles).toEqual([
|
||||
{
|
||||
profileId: "anthropic:claude-cli",
|
||||
credential: {
|
||||
type: "oauth",
|
||||
provider: "claude-cli",
|
||||
access: "setup-access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: 123,
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(result?.profiles).toEqual([]);
|
||||
});
|
||||
});
|
||||
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
|
||||
|
||||
@@ -258,7 +258,7 @@
|
||||
},
|
||||
"cliBackends": ["claude-cli"],
|
||||
"syntheticAuthRefs": ["claude-cli"],
|
||||
"nonSecretAuthMarkers": ["openclaw:claude-cli-api-key-helper"],
|
||||
"nonSecretAuthMarkers": ["openclaw:claude-cli-native-auth"],
|
||||
"setup": {
|
||||
"providers": [
|
||||
{
|
||||
|
||||
@@ -3,41 +3,16 @@
|
||||
* synthetic auth for catalog/runtime discovery without full Anthropic registration.
|
||||
*/
|
||||
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { readClaudeCliCredentialsForRuntime } from "./cli-auth-seam.js";
|
||||
import { CLAUDE_CLI_API_KEY_HELPER_AUTH_MARKER } from "./cli-constants.js";
|
||||
import { CLAUDE_CLI_NATIVE_AUTH_MARKER } from "./cli-constants.js";
|
||||
|
||||
const CLAUDE_CLI_BACKEND_ID = "claude-cli";
|
||||
|
||||
export function resolveClaudeCliSyntheticAuth() {
|
||||
const credential = readClaudeCliCredentialsForRuntime();
|
||||
if (!credential) {
|
||||
return undefined;
|
||||
}
|
||||
switch (credential.type) {
|
||||
case "oauth":
|
||||
return {
|
||||
apiKey: credential.access,
|
||||
source: "Claude CLI native auth",
|
||||
mode: "oauth" as const,
|
||||
expiresAt: credential.expires,
|
||||
};
|
||||
case "token":
|
||||
return {
|
||||
apiKey: credential.token,
|
||||
source: "Claude CLI native auth",
|
||||
mode: "token" as const,
|
||||
expiresAt: credential.expires,
|
||||
};
|
||||
case "api_key_helper": {
|
||||
const marker = CLAUDE_CLI_API_KEY_HELPER_AUTH_MARKER;
|
||||
return {
|
||||
apiKey: marker,
|
||||
source: "Claude CLI apiKeyHelper",
|
||||
mode: "api-key" as const,
|
||||
};
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
return {
|
||||
apiKey: CLAUDE_CLI_NATIVE_AUTH_MARKER,
|
||||
source: "Claude CLI native auth",
|
||||
mode: "oauth" as const,
|
||||
};
|
||||
}
|
||||
|
||||
const anthropicProviderDiscovery: ProviderPlugin = {
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
applyAuthProfileConfig,
|
||||
type AuthProfileStore,
|
||||
buildTokenProfileId,
|
||||
CLAUDE_CLI_PROFILE_ID,
|
||||
createProviderApiKeyAuthMethod,
|
||||
listProfilesForProvider,
|
||||
type OpenClawConfig as ProviderAuthConfig,
|
||||
@@ -128,7 +129,7 @@ const ANTHROPIC_SONNET_46_MODEL_ID = "claude-sonnet-4-6";
|
||||
const ANTHROPIC_SONNET_46_DOT_MODEL_ID = "claude-sonnet-4.6";
|
||||
const ANTHROPIC_SETUP_TOKEN_NOTE_LINES = [
|
||||
"Anthropic setup-token auth is supported in OpenClaw.",
|
||||
"OpenClaw prefers Claude CLI reuse when it is available on the host.",
|
||||
"OpenClaw prefers the native Claude CLI runtime when it is available on the host.",
|
||||
"Anthropic staff told us this OpenClaw path is allowed again.",
|
||||
`If you want a direct API billing path instead, use ${formatCliCommand("openclaw models auth login --provider anthropic --method api-key --set-default")} or ${formatCliCommand("openclaw models auth login --provider anthropic --method cli --set-default")}.`,
|
||||
] as const;
|
||||
@@ -968,8 +969,10 @@ function buildAnthropicAuthDoctorHint(params: {
|
||||
}
|
||||
|
||||
async function runAnthropicCliMigration(ctx: ProviderAuthContext): Promise<ProviderAuthResult> {
|
||||
const credential = claudeCliAuth.readClaudeCliCredentialsForSetup();
|
||||
if (!credential) {
|
||||
const authStatus = claudeCliAuth.probeClaudeCliAuthStatus(
|
||||
resolveAnthropicCliAuthProbe(ctx.env ?? process.env),
|
||||
);
|
||||
if (authStatus.status !== "available") {
|
||||
throw new Error(
|
||||
[
|
||||
"Claude CLI is not authenticated on this host.",
|
||||
@@ -977,7 +980,7 @@ async function runAnthropicCliMigration(ctx: ProviderAuthContext): Promise<Provi
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
return buildAnthropicCliMigrationResult(ctx.config, credential);
|
||||
return buildAnthropicCliMigrationResult(ctx.config);
|
||||
}
|
||||
|
||||
async function runAnthropicCliMigrationNonInteractive(ctx: {
|
||||
@@ -985,13 +988,15 @@ async function runAnthropicCliMigrationNonInteractive(ctx: {
|
||||
runtime: ProviderAuthContext["runtime"];
|
||||
agentDir?: string;
|
||||
}): Promise<ProviderAuthContext["config"] | null> {
|
||||
const credentialResult = claudeCliAuth.readClaudeCliCredentialsForSetupNonInteractive();
|
||||
if (credentialResult.status !== "available") {
|
||||
const authStatus = claudeCliAuth.probeClaudeCliAuthStatus(
|
||||
resolveAnthropicCliAuthProbe(process.env),
|
||||
);
|
||||
if (authStatus.status !== "available") {
|
||||
const error =
|
||||
credentialResult.status === "unreadable"
|
||||
authStatus.status === "unreadable"
|
||||
? [
|
||||
'Auth choice "anthropic-cli" found Claude CLI credentials on this host, but they could not be read non-interactively.',
|
||||
"Re-run this command without --non-interactive, or use --auth-choice setup-token / --anthropic-api-key <key>.",
|
||||
'Auth choice "anthropic-cli" could not verify the installed Claude CLI login.',
|
||||
`Run ${formatCliCommand("claude auth status")}, then retry.`,
|
||||
]
|
||||
: [
|
||||
'Auth choice "anthropic-cli" requires Claude CLI auth on this host.',
|
||||
@@ -1002,7 +1007,7 @@ async function runAnthropicCliMigrationNonInteractive(ctx: {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = buildAnthropicCliMigrationResult(ctx.config, credentialResult.credential);
|
||||
const result = buildAnthropicCliMigrationResult(ctx.config);
|
||||
const currentDefaults = ctx.config.agents?.defaults;
|
||||
const currentModel = currentDefaults?.model;
|
||||
const currentFallbacks =
|
||||
@@ -1034,6 +1039,18 @@ async function runAnthropicCliMigrationNonInteractive(ctx: {
|
||||
};
|
||||
}
|
||||
|
||||
function resolveAnthropicCliAuthProbe(env: NodeJS.ProcessEnv): {
|
||||
command: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
} {
|
||||
const backend = buildAnthropicCliBackend().config;
|
||||
const probeEnv = { ...env, ...backend.env };
|
||||
for (const name of backend.clearEnv ?? []) {
|
||||
delete probeEnv[name];
|
||||
}
|
||||
return { command: backend.command, env: probeEnv };
|
||||
}
|
||||
|
||||
/** Build the full Anthropic provider descriptor used by runtime registration. */
|
||||
export function buildAnthropicProvider(): ProviderPlugin {
|
||||
const providerId = "anthropic";
|
||||
@@ -1041,6 +1058,7 @@ export function buildAnthropicProvider(): ProviderPlugin {
|
||||
return {
|
||||
id: providerId,
|
||||
label: "Anthropic",
|
||||
deprecatedProfileIds: [CLAUDE_CLI_PROFILE_ID],
|
||||
docsPath: "/providers/models",
|
||||
hookAliases: [CLAUDE_CLI_BACKEND_ID],
|
||||
envVars: ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"],
|
||||
|
||||
@@ -1,22 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { fetchAnthropicUsage, resolveAnthropicUsageAuth } from "./usage.js";
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/provider-auth", async (importActual) => {
|
||||
const actual = await importActual<typeof import("openclaw/plugin-sdk/provider-auth")>();
|
||||
return {
|
||||
...actual,
|
||||
readClaudeCliCredentialsCached: vi.fn(() => ({
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: "cli-access",
|
||||
refresh: "cli-refresh",
|
||||
expires: Date.now() + 3_600_000,
|
||||
subscriptionType: "max",
|
||||
rateLimitTier: "default_max_20x",
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
function requestUrl(input: string | URL | Request): URL {
|
||||
return new URL(input instanceof Request ? input.url : input);
|
||||
}
|
||||
@@ -184,10 +168,8 @@ describe("Anthropic provider usage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the synced claude-cli OAuth profile when anthropic has none", async () => {
|
||||
const resolveOAuthToken = vi.fn(async (params?: { provider?: string }) =>
|
||||
params?.provider === "claude-cli" ? { token: "claude-cli-token" } : null,
|
||||
);
|
||||
it("does not refresh the native Claude login for usage polling", async () => {
|
||||
const resolveOAuthToken = vi.fn(async () => null);
|
||||
const result = await resolveAnthropicUsageAuth({
|
||||
config: {},
|
||||
env: {},
|
||||
@@ -195,12 +177,14 @@ describe("Anthropic provider usage", () => {
|
||||
resolveApiKeyFromConfigAndStore: () => undefined,
|
||||
resolveOAuthToken,
|
||||
});
|
||||
expect(result).toEqual({ token: "claude-cli-token" });
|
||||
expect(resolveOAuthToken).toHaveBeenNthCalledWith(1);
|
||||
expect(resolveOAuthToken).toHaveBeenNthCalledWith(2, { provider: "claude-cli" });
|
||||
expect(result).toEqual({ handled: true });
|
||||
expect(resolveOAuthToken).toHaveBeenCalledOnce();
|
||||
expect(resolveOAuthToken).toHaveBeenCalledWith({
|
||||
excludeProfileIds: ["anthropic:claude-cli"],
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers plan metadata from the resolved auth profile over CLI reads", async () => {
|
||||
it("uses plan metadata from the resolved auth profile", async () => {
|
||||
const fetchFn = vi.fn(
|
||||
async () => new Response(JSON.stringify({ five_hour: { utilization: 10 } }), { status: 200 }),
|
||||
);
|
||||
@@ -217,7 +201,7 @@ describe("Anthropic provider usage", () => {
|
||||
expect(snapshot.plan).toBe("Pro");
|
||||
});
|
||||
|
||||
it("labels OAuth usage snapshots with the local Claude CLI plan", async () => {
|
||||
it("does not read Claude CLI auth to label OAuth usage", async () => {
|
||||
const fetchFn = vi.fn(
|
||||
async () =>
|
||||
new Response(
|
||||
@@ -236,7 +220,7 @@ describe("Anthropic provider usage", () => {
|
||||
timeoutMs: 5000,
|
||||
fetchFn,
|
||||
});
|
||||
expect(snapshot.plan).toBe("Max (20x)");
|
||||
expect(snapshot.plan).toBeUndefined();
|
||||
expect(snapshot.windows).toHaveLength(2);
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import type {
|
||||
ProviderResolvedUsageAuth,
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import {
|
||||
readClaudeCliCredentialsCached,
|
||||
CLAUDE_CLI_PROFILE_ID,
|
||||
validateAnthropicSetupToken,
|
||||
} from "openclaw/plugin-sdk/provider-auth";
|
||||
import {
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
resolveProviderUsageDisplayName,
|
||||
type ProviderUsageSnapshot,
|
||||
} from "openclaw/plugin-sdk/provider-usage";
|
||||
import { CLAUDE_CLI_BACKEND_ID } from "./cli-constants.js";
|
||||
|
||||
const ANTHROPIC_COST_URL = "https://api.anthropic.com/v1/organizations/cost_report";
|
||||
const ANTHROPIC_MESSAGES_USAGE_URL =
|
||||
@@ -253,22 +252,13 @@ export async function resolveAnthropicUsageAuth(
|
||||
return { token: encodeAdminToken(storedAdminKey) };
|
||||
}
|
||||
|
||||
const oauthToken = await ctx.resolveOAuthToken();
|
||||
const oauthToken = await ctx.resolveOAuthToken({
|
||||
excludeProfileIds: [CLAUDE_CLI_PROFILE_ID],
|
||||
});
|
||||
if (oauthToken) {
|
||||
return oauthToken;
|
||||
}
|
||||
|
||||
// Claude CLI-only setups have their keychain login synced under the
|
||||
// claude-cli profile, not anthropic; without this fallback those setups
|
||||
// never surface subscription usage windows. Usage snapshots are keyed per
|
||||
// provider, so when a native anthropic OAuth account and a different Claude
|
||||
// Code account coexist, the native account wins and both auth rows display
|
||||
// its quota. Per-profile usage attribution is tracked in #102807.
|
||||
const claudeCliToken = await ctx.resolveOAuthToken({ provider: CLAUDE_CLI_BACKEND_ID });
|
||||
if (claudeCliToken) {
|
||||
return claudeCliToken;
|
||||
}
|
||||
|
||||
const apiKey = ctx.resolveApiKeyFromConfigAndStore();
|
||||
const adminKey = normalizeAdminKey(apiKey);
|
||||
if (adminKey) {
|
||||
@@ -277,6 +267,9 @@ export async function resolveAnthropicUsageAuth(
|
||||
if (apiKey && validateAnthropicSetupToken(apiKey) === undefined) {
|
||||
return { token: apiKey };
|
||||
}
|
||||
|
||||
// Claude owns its native refresh-token family. Do not resolve a copied
|
||||
// claude-cli profile here: generic OAuth refresh invalidates Claude's login.
|
||||
return { handled: true };
|
||||
}
|
||||
|
||||
@@ -294,26 +287,8 @@ function formatClaudePlanLabel(
|
||||
return tier ? `${label} (${tier})` : label;
|
||||
}
|
||||
|
||||
// Best-effort plan label. Preferred source is plan metadata on the resolved
|
||||
// auth profile (captured when the external CLI login was synced — the only
|
||||
// prompt-free source on keychain-backed macOS installs). Fallback reads the
|
||||
// Claude CLI credential file without keychain prompts for file-based logins.
|
||||
// When multiple Claude accounts are in play the CLI login may differ from the
|
||||
// profile that fetched usage; a mislabeled plan chip is acceptable, a second
|
||||
// network call is not.
|
||||
function resolveClaudePlanLabel(ctx: ProviderFetchUsageSnapshotContext): string | undefined {
|
||||
const fromAuth = formatClaudePlanLabel(ctx.subscriptionType, ctx.rateLimitTier);
|
||||
if (fromAuth) {
|
||||
return fromAuth;
|
||||
}
|
||||
const credential = readClaudeCliCredentialsCached({
|
||||
allowKeychainPrompt: false,
|
||||
ttlMs: 5 * 60_000,
|
||||
});
|
||||
if (!credential || credential.type !== "oauth") {
|
||||
return undefined;
|
||||
}
|
||||
return formatClaudePlanLabel(credential.subscriptionType, credential.rateLimitTier);
|
||||
return formatClaudePlanLabel(ctx.subscriptionType, ctx.rateLimitTier);
|
||||
}
|
||||
|
||||
export async function fetchAnthropicUsage(
|
||||
@@ -331,8 +306,7 @@ export async function fetchAnthropicUsage(
|
||||
if (snapshot.error) {
|
||||
return snapshot;
|
||||
}
|
||||
// Identity is captured on the credential (profile store or the CLI-sync
|
||||
// read), so a fetch-time ambient config read can never mislabel an account.
|
||||
// Identity comes from the selected credential, so ambient config cannot mislabel an account.
|
||||
const accountEmail = ctx.email;
|
||||
// Plan labels stay window-gated: a windowless response has no plan quota to
|
||||
// label, while the account identity is still worth surfacing.
|
||||
|
||||
@@ -18,7 +18,6 @@ const { readCodexCliCredentialsCachedMock, resolveProviderIdForAuthMock } = vi.h
|
||||
}));
|
||||
|
||||
vi.mock("./cli-credentials.js", () => ({
|
||||
readClaudeCliCredentialsCached: () => null,
|
||||
readCodexCliCredentialsCached: readCodexCliCredentialsCachedMock,
|
||||
readMiniMaxCliCredentialsCached: () => null,
|
||||
resetCliCredentialCachesForTest: () => undefined,
|
||||
|
||||
@@ -5,12 +5,8 @@
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AuthProfileStore, OAuthCredential } from "./auth-profiles/types.js";
|
||||
import type { ClaudeCliCredential } from "./cli-credentials.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
readClaudeCliCredentialsCached: vi.fn<(options?: unknown) => ClaudeCliCredential | null>(
|
||||
() => null,
|
||||
),
|
||||
readCodexCliCredentialsCached: vi.fn<(options?: unknown) => OAuthCredential | null>(() => null),
|
||||
readMiniMaxCliCredentialsCached: vi.fn<(options?: unknown) => OAuthCredential | null>(() => null),
|
||||
}));
|
||||
@@ -20,7 +16,6 @@ let resolveExternalCliAuthProfiles: typeof import("./auth-profiles/external-cli-
|
||||
let hasUsableOAuthCredential: typeof import("./auth-profiles/credential-state.js").hasUsableOAuthCredential;
|
||||
let shouldBootstrapFromExternalCliCredential: typeof import("./auth-profiles/oauth-shared.js").shouldBootstrapFromExternalCliCredential;
|
||||
let shouldReplaceStoredOAuthCredential: typeof import("./auth-profiles/oauth-shared.js").shouldReplaceStoredOAuthCredential;
|
||||
let CLAUDE_CLI_PROFILE_ID: typeof import("./auth-profiles/constants.js").CLAUDE_CLI_PROFILE_ID;
|
||||
let OPENAI_CODEX_DEFAULT_PROFILE_ID: typeof import("./auth-profiles/constants.js").OPENAI_CODEX_DEFAULT_PROFILE_ID;
|
||||
let MINIMAX_CLI_PROFILE_ID: typeof import("./auth-profiles/constants.js").MINIMAX_CLI_PROFILE_ID;
|
||||
|
||||
@@ -65,24 +60,6 @@ function expectSingleProfileCredential(
|
||||
return credential as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function expectSingleProfile(
|
||||
profiles: ReturnType<typeof resolveExternalCliAuthProfiles>,
|
||||
profileId: string,
|
||||
) {
|
||||
expect(profiles).toStrictEqual([
|
||||
{
|
||||
credential: expect.any(Object),
|
||||
persistence: profileId === OPENAI_CODEX_DEFAULT_PROFILE_ID ? "runtime-only" : "persisted",
|
||||
profileId,
|
||||
},
|
||||
]);
|
||||
const profile = profiles[0];
|
||||
if (!profile?.credential) {
|
||||
throw new Error(`Expected credential for profile ${profileId}`);
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
|
||||
function expectCredentialFields(
|
||||
credential: Record<string, unknown> | undefined,
|
||||
expected: Record<string, unknown>,
|
||||
@@ -110,11 +87,9 @@ describe("external cli oauth resolution", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.doMock("./cli-credentials.js", () => ({
|
||||
readClaudeCliCredentialsCached: mocks.readClaudeCliCredentialsCached,
|
||||
readCodexCliCredentialsCached: mocks.readCodexCliCredentialsCached,
|
||||
readMiniMaxCliCredentialsCached: mocks.readMiniMaxCliCredentialsCached,
|
||||
}));
|
||||
mocks.readClaudeCliCredentialsCached.mockReset().mockReturnValue(null);
|
||||
mocks.readCodexCliCredentialsCached.mockReset().mockReturnValue(null);
|
||||
mocks.readMiniMaxCliCredentialsCached.mockReset().mockReturnValue(null);
|
||||
({ readExternalCliBootstrapCredential, resolveExternalCliAuthProfiles } =
|
||||
@@ -122,7 +97,7 @@ describe("external cli oauth resolution", () => {
|
||||
({ hasUsableOAuthCredential } = await import("./auth-profiles/credential-state.js"));
|
||||
({ shouldBootstrapFromExternalCliCredential, shouldReplaceStoredOAuthCredential } =
|
||||
await import("./auth-profiles/oauth-shared.js"));
|
||||
({ CLAUDE_CLI_PROFILE_ID, OPENAI_CODEX_DEFAULT_PROFILE_ID, MINIMAX_CLI_PROFILE_ID } =
|
||||
({ OPENAI_CODEX_DEFAULT_PROFILE_ID, MINIMAX_CLI_PROFILE_ID } =
|
||||
await import("./auth-profiles/constants.js"));
|
||||
});
|
||||
|
||||
@@ -419,70 +394,6 @@ describe("external cli oauth resolution", () => {
|
||||
expect(credential).toBeNull();
|
||||
});
|
||||
|
||||
it("normalizes Claude CLI oauth credentials into the managed Claude profile", () => {
|
||||
mocks.readClaudeCliCredentialsCached.mockReturnValue({
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: "claude-cli-access",
|
||||
refresh: "claude-cli-refresh",
|
||||
expires: Date.now() + 5 * 24 * 60 * 60_000,
|
||||
});
|
||||
|
||||
const profiles = resolveExternalCliAuthProfiles(makeStore(), {
|
||||
providerIds: ["claude-cli"],
|
||||
});
|
||||
|
||||
const profile = expectSingleProfile(profiles, CLAUDE_CLI_PROFILE_ID);
|
||||
expect(profile?.persistence).toBe("persisted");
|
||||
expectCredentialFields(profile?.credential as Record<string, unknown>, {
|
||||
type: "oauth",
|
||||
provider: "claude-cli",
|
||||
access: "claude-cli-access",
|
||||
refresh: "claude-cli-refresh",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps provider discovery when requested profiles belong to another provider", () => {
|
||||
const expires = Date.now() + 5 * 24 * 60 * 60_000;
|
||||
const claudeCredential: ClaudeCliCredential = {
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: "claude-cli-access",
|
||||
refresh: "claude-cli-refresh",
|
||||
expires,
|
||||
};
|
||||
const codexCredential = makeOAuthCredential({
|
||||
provider: "openai",
|
||||
access: "codex-cli-access",
|
||||
refresh: "codex-cli-refresh",
|
||||
expires,
|
||||
});
|
||||
mocks.readClaudeCliCredentialsCached.mockReturnValue(claudeCredential);
|
||||
mocks.readCodexCliCredentialsCached.mockReturnValue(codexCredential);
|
||||
|
||||
const profiles = resolveExternalCliAuthProfiles(makeStore(), {
|
||||
providerIds: ["claude-cli", "openai"],
|
||||
profileIds: [CLAUDE_CLI_PROFILE_ID],
|
||||
allowKeychainPrompt: false,
|
||||
});
|
||||
|
||||
expect(profiles).toStrictEqual([
|
||||
{
|
||||
profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID,
|
||||
credential: codexCredential,
|
||||
persistence: "runtime-only",
|
||||
},
|
||||
{
|
||||
profileId: CLAUDE_CLI_PROFILE_ID,
|
||||
credential: { ...claudeCredential, provider: "claude-cli" },
|
||||
persistence: "persisted",
|
||||
},
|
||||
]);
|
||||
expectReaderPolicyCall(mocks.readCodexCliCredentialsCached);
|
||||
expectReaderPolicyCall(mocks.readClaudeCliCredentialsCached);
|
||||
expect(mocks.readMiniMaxCliCredentialsCached).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips external cli readers outside the scoped provider set", () => {
|
||||
const profiles = resolveExternalCliAuthProfiles(makeStore(), {
|
||||
providerIds: ["opencode-go"],
|
||||
@@ -490,100 +401,6 @@ describe("external cli oauth resolution", () => {
|
||||
|
||||
expect(profiles).toStrictEqual([]);
|
||||
expect(mocks.readCodexCliCredentialsCached).not.toHaveBeenCalled();
|
||||
expect(mocks.readClaudeCliCredentialsCached).not.toHaveBeenCalled();
|
||||
expect(mocks.readMiniMaxCliCredentialsCached).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not scan missing external CLI profiles without an explicit scope", () => {
|
||||
mocks.readClaudeCliCredentialsCached.mockReturnValue({
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: "claude-cli-access",
|
||||
refresh: "claude-cli-refresh",
|
||||
expires: Date.now() + 5 * 24 * 60 * 60_000,
|
||||
});
|
||||
|
||||
const profiles = resolveExternalCliAuthProfiles(makeStore());
|
||||
|
||||
expect(profiles).toStrictEqual([]);
|
||||
expect(mocks.readClaudeCliCredentialsCached).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refreshes a stored external CLI profile without an explicit scope", () => {
|
||||
mocks.readClaudeCliCredentialsCached.mockReturnValue({
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: "claude-cli-fresh-access",
|
||||
refresh: "claude-cli-fresh-refresh",
|
||||
expires: Date.now() + 5 * 24 * 60 * 60_000,
|
||||
});
|
||||
|
||||
const profiles = resolveExternalCliAuthProfiles(
|
||||
makeStore(CLAUDE_CLI_PROFILE_ID, {
|
||||
type: "oauth",
|
||||
provider: "claude-cli",
|
||||
access: "claude-cli-stale-access",
|
||||
refresh: "claude-cli-stale-refresh",
|
||||
expires: Date.now() - 5_000,
|
||||
}),
|
||||
);
|
||||
|
||||
const profile = expectSingleProfile(profiles, CLAUDE_CLI_PROFILE_ID);
|
||||
expect(profile?.persistence).toBe("persisted");
|
||||
expectCredentialFields(profile?.credential as Record<string, unknown>, {
|
||||
provider: "claude-cli",
|
||||
access: "claude-cli-fresh-access",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not reread external CLI credentials for a usable stored managed profile", () => {
|
||||
mocks.readClaudeCliCredentialsCached.mockReturnValue({
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: "external-access",
|
||||
refresh: "external-refresh",
|
||||
expires: Date.now() + 5 * 24 * 60 * 60_000,
|
||||
});
|
||||
|
||||
const profiles = resolveExternalCliAuthProfiles(
|
||||
makeStore(CLAUDE_CLI_PROFILE_ID, {
|
||||
type: "oauth",
|
||||
provider: "claude-cli",
|
||||
access: "usable-local-access",
|
||||
refresh: "usable-local-refresh",
|
||||
expires: Date.now() + 10 * 60_000,
|
||||
// Identity-complete steady state; profiles missing the email get one
|
||||
// bounded backfill read (external-cli-sync.email-backfill.test.ts).
|
||||
email: "stored@example.com",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(profiles).toStrictEqual([]);
|
||||
expect(mocks.readClaudeCliCredentialsCached).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes non-prompting keychain policy to scoped Claude CLI credential reads", () => {
|
||||
mocks.readClaudeCliCredentialsCached.mockReturnValue({
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: "claude-cli-access",
|
||||
refresh: "claude-cli-refresh",
|
||||
expires: Date.now() + 5 * 24 * 60 * 60_000,
|
||||
});
|
||||
|
||||
const profiles = resolveExternalCliAuthProfiles(makeStore(), {
|
||||
providerIds: ["claude-cli"],
|
||||
allowKeychainPrompt: false,
|
||||
});
|
||||
|
||||
const profile = expectSingleProfile(profiles, CLAUDE_CLI_PROFILE_ID);
|
||||
expect(profile?.persistence).toBe("persisted");
|
||||
expectCredentialFields(profile?.credential as Record<string, unknown>, {
|
||||
type: "oauth",
|
||||
provider: "claude-cli",
|
||||
});
|
||||
expectReaderPolicyCall(mocks.readClaudeCliCredentialsCached);
|
||||
expect(mocks.readCodexCliCredentialsCached).not.toHaveBeenCalled();
|
||||
expect(mocks.readMiniMaxCliCredentialsCached).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -609,41 +426,9 @@ describe("external cli oauth resolution", () => {
|
||||
},
|
||||
);
|
||||
expectReaderPolicyCall(mocks.readCodexCliCredentialsCached);
|
||||
expect(mocks.readClaudeCliCredentialsCached).not.toHaveBeenCalled();
|
||||
expect(mocks.readMiniMaxCliCredentialsCached).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores Claude CLI token credentials", () => {
|
||||
mocks.readClaudeCliCredentialsCached.mockReturnValue({
|
||||
type: "token",
|
||||
provider: "anthropic",
|
||||
token: "claude-cli-token",
|
||||
expires: Date.now() + 5 * 24 * 60 * 60_000,
|
||||
});
|
||||
|
||||
const profiles = resolveExternalCliAuthProfiles(makeStore(), {
|
||||
providerIds: ["claude-cli"],
|
||||
});
|
||||
|
||||
expect(profiles).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("ignores Claude CLI apiKeyHelper credentials for OAuth profile sync", () => {
|
||||
mocks.readClaudeCliCredentialsCached.mockReturnValue({
|
||||
type: "api_key_helper",
|
||||
provider: "anthropic",
|
||||
helperHash: "helper-hash",
|
||||
});
|
||||
|
||||
const profiles = resolveExternalCliAuthProfiles(makeStore(), {
|
||||
providerIds: ["claude-cli"],
|
||||
allowKeychainPrompt: false,
|
||||
});
|
||||
|
||||
expect(profiles).toStrictEqual([]);
|
||||
expectReaderPolicyCall(mocks.readClaudeCliCredentialsCached);
|
||||
});
|
||||
|
||||
it("resolves fresher minimax external oauth profiles as runtime overlays", () => {
|
||||
mocks.readMiniMaxCliCredentialsCached.mockReturnValue(
|
||||
makeOAuthCredential({
|
||||
|
||||
@@ -10,7 +10,6 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js";
|
||||
|
||||
vi.mock("./cli-credentials.js", () => ({
|
||||
readClaudeCliCredentialsCached: () => null,
|
||||
readCodexCliCredentialsCached: () => null,
|
||||
readMiniMaxCliCredentialsCached: () => null,
|
||||
}));
|
||||
|
||||
@@ -8,12 +8,7 @@ import type { ProviderExternalAuthProfile } from "../../plugins/provider-externa
|
||||
import { resolveExternalAuthProfilesWithPlugins } from "../../plugins/provider-runtime.js";
|
||||
import { isAmbientCredentialAllowedByProviderAuthPin } from "./ambient-auth.js";
|
||||
import { cloneAuthProfileStore } from "./clone.js";
|
||||
import { CLAUDE_CLI_PROFILE_ID, MINIMAX_CLI_PROFILE_ID } from "./constants.js";
|
||||
import {
|
||||
isUsablePersistedExternalCliProfileCredential,
|
||||
listConfiguredExternalCliProfileMetadataIds,
|
||||
listExternalCliProfileMetadataIds,
|
||||
} from "./external-cli-profile-metadata.js";
|
||||
import { MINIMAX_CLI_PROFILE_ID } from "./constants.js";
|
||||
import * as externalCliSync from "./external-cli-sync.js";
|
||||
import {
|
||||
areOAuthCredentialsEquivalent,
|
||||
@@ -115,46 +110,13 @@ function resolveExternalAuthProfiles(params: {
|
||||
store: params.store,
|
||||
},
|
||||
});
|
||||
const configuredProfileIds = listConfiguredExternalCliProfileMetadataIds(
|
||||
params.externalCli?.config?.auth?.profiles,
|
||||
);
|
||||
const externalCli = configuredProfileIds.length
|
||||
? {
|
||||
...params.externalCli,
|
||||
externalCliProfileIds: [
|
||||
...(params.externalCli?.externalCliProfileIds ?? []),
|
||||
...configuredProfileIds,
|
||||
],
|
||||
}
|
||||
: params.externalCli;
|
||||
const externalCli = params.externalCli;
|
||||
const resolved = resolveExternalCliAuthProfileMap({ ...params, externalCli });
|
||||
const runtimeExternalCliProfileIds = new Set(
|
||||
[...resolved.values()]
|
||||
.filter((profile) => profile.persistence !== "persisted")
|
||||
.map((profile) => profile.profileId),
|
||||
);
|
||||
// A persisted Claude CLI profile may be usable and identity-complete, in which
|
||||
// case its resolver intentionally avoids rereading the CLI and emits no overlay.
|
||||
// Its canonical profile slot still establishes refresh ownership
|
||||
// after a process restart, when runtime-only provenance is no longer available.
|
||||
for (const profileId of listExternalCliProfileMetadataIds()) {
|
||||
const credential = params.store.profiles[profileId];
|
||||
const hasUsablePersistedCliCredential = isUsablePersistedExternalCliProfileCredential(
|
||||
profileId,
|
||||
credential,
|
||||
);
|
||||
if (
|
||||
(resolved.has(profileId) || hasUsablePersistedCliCredential) &&
|
||||
externalCliSync.isExternalCliAuthProfileInScope({
|
||||
store: params.store,
|
||||
profileId,
|
||||
providerIds: externalCli?.externalCliProviderIds,
|
||||
profileIds: externalCli?.externalCliProfileIds,
|
||||
})
|
||||
) {
|
||||
runtimeExternalCliProfileIds.add(profileId);
|
||||
}
|
||||
}
|
||||
const pluginProfileIds = new Set<string>();
|
||||
const explicitProfileIds = resolveExplicitProfileIds(params.externalCli?.externalCliProfileIds);
|
||||
for (const rawProfile of profiles) {
|
||||
@@ -246,16 +208,10 @@ function hasPersistableExternalCliSyncCandidate(
|
||||
if (params?.externalCliProviderIds || params?.externalCliProfileIds) {
|
||||
return true;
|
||||
}
|
||||
// Keep the existing Claude and MiniMax steady-state sync trigger independent
|
||||
// from the narrower legacy Claude metadata migration/recovery registry.
|
||||
for (const profileId of [CLAUDE_CLI_PROFILE_ID, MINIMAX_CLI_PROFILE_ID]) {
|
||||
// MiniMax keeps its persisted external profile fresh without an explicit scope.
|
||||
for (const profileId of [MINIMAX_CLI_PROFILE_ID]) {
|
||||
const credential = store.profiles[profileId];
|
||||
if (
|
||||
credential?.type === "oauth" ||
|
||||
listConfiguredExternalCliProfileMetadataIds(params?.config?.auth?.profiles).includes(
|
||||
profileId,
|
||||
)
|
||||
) {
|
||||
if (credential?.type === "oauth") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -321,21 +277,10 @@ export function syncPersistedExternalCliAuthProfiles(
|
||||
if (!hasPersistableExternalCliSyncCandidate(store, params)) {
|
||||
return store;
|
||||
}
|
||||
const configuredProfileIds = listConfiguredExternalCliProfileMetadataIds(
|
||||
params?.config?.auth?.profiles,
|
||||
);
|
||||
const persistedProfiles = resolveAllowedExternalCliAuthProfiles({
|
||||
store,
|
||||
env: params?.env,
|
||||
externalCli: configuredProfileIds.length
|
||||
? {
|
||||
...params,
|
||||
externalCliProfileIds: [
|
||||
...(params?.externalCliProfileIds ?? []),
|
||||
...configuredProfileIds,
|
||||
],
|
||||
}
|
||||
: params,
|
||||
externalCli: params,
|
||||
}).filter((profile) => profile.persistence === "persisted");
|
||||
if (persistedProfiles.length === 0) {
|
||||
return store;
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
/** Canonical metadata for the legacy built-in Claude CLI auth profile slot. */
|
||||
import type { AuthProfileConfig } from "../../config/types.auth.js";
|
||||
import { CLAUDE_CLI_PROFILE_ID } from "./constants.js";
|
||||
import { hasUsableOAuthCredential } from "./credential-state.js";
|
||||
import type { AuthProfileCredential } from "./types.js";
|
||||
|
||||
type ExternalCliProfileMetadata = Pick<AuthProfileConfig, "provider" | "mode">;
|
||||
|
||||
const EXTERNAL_CLI_PROFILE_METADATA = new Map<
|
||||
string,
|
||||
{
|
||||
provider: string;
|
||||
legacyProviders: readonly string[];
|
||||
}
|
||||
>([
|
||||
[CLAUDE_CLI_PROFILE_ID, { provider: "claude-cli", legacyProviders: ["anthropic", "claude-cli"] }],
|
||||
]);
|
||||
|
||||
export function listExternalCliProfileMetadataIds(): string[] {
|
||||
return [...EXTERNAL_CLI_PROFILE_METADATA.keys()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts only the known pre-OAuth metadata spelling for a built-in CLI slot.
|
||||
* Other configured profiles remain user-owned and must never be reclassified.
|
||||
*/
|
||||
export function normalizeExternalCliProfileMetadata(
|
||||
profileId: string,
|
||||
profile: AuthProfileConfig | undefined,
|
||||
): ExternalCliProfileMetadata | undefined {
|
||||
const definition = EXTERNAL_CLI_PROFILE_METADATA.get(profileId);
|
||||
if (!definition || !profile) {
|
||||
return undefined;
|
||||
}
|
||||
const provider = profile.provider.trim().toLowerCase();
|
||||
if (!definition.legacyProviders.includes(provider)) {
|
||||
return undefined;
|
||||
}
|
||||
if (profile.mode === "oauth") {
|
||||
return { provider: definition.provider, mode: "oauth" };
|
||||
}
|
||||
if (profile.mode === "token") {
|
||||
return { provider: definition.provider, mode: "oauth" };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function listConfiguredExternalCliProfileMetadataIds(
|
||||
profiles: Record<string, AuthProfileConfig> | undefined,
|
||||
): string[] {
|
||||
if (!profiles) {
|
||||
return [];
|
||||
}
|
||||
return listExternalCliProfileMetadataIds().filter((profileId) =>
|
||||
Boolean(normalizeExternalCliProfileMetadata(profileId, profiles[profileId])),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A persisted CLI credential can re-establish refresh ownership only when it
|
||||
* is current, bound to the expected CLI provider family, and identity-complete.
|
||||
*/
|
||||
export function isUsablePersistedExternalCliProfileCredential(
|
||||
profileId: string,
|
||||
credential: AuthProfileCredential | undefined,
|
||||
): boolean {
|
||||
const definition = EXTERNAL_CLI_PROFILE_METADATA.get(profileId);
|
||||
if (!definition || credential?.type !== "oauth") {
|
||||
return false;
|
||||
}
|
||||
const provider = credential.provider.trim().toLowerCase();
|
||||
return (
|
||||
definition.legacyProviders.includes(provider) &&
|
||||
hasUsableOAuthCredential(credential) &&
|
||||
Boolean(credential.accountId?.trim() || credential.email?.trim())
|
||||
);
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
/** Upgrade path: pre-identity claude-cli profiles gain the CLI account email. */
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { AuthProfileStore } from "./types.js";
|
||||
|
||||
const readClaudeCliCredentialsCachedMock = vi.fn();
|
||||
|
||||
vi.mock("../cli-credentials.js", async (importActual) => {
|
||||
const actual = await importActual<typeof import("../cli-credentials.js")>();
|
||||
return {
|
||||
...actual,
|
||||
readClaudeCliCredentialsCached: readClaudeCliCredentialsCachedMock,
|
||||
};
|
||||
});
|
||||
|
||||
const { resolveExternalCliAuthProfiles } = await import("./external-cli-sync.js");
|
||||
|
||||
function refreshFixture(): string {
|
||||
return ["stored", "refresh"].join("-");
|
||||
}
|
||||
|
||||
function storeWithClaudeProfile(): AuthProfileStore {
|
||||
return {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"anthropic:claude-cli": {
|
||||
type: "oauth",
|
||||
provider: "claude-cli",
|
||||
access: ["stored", "access"].join("-"),
|
||||
refresh: refreshFixture(),
|
||||
expires: Date.now() + 3_600_000,
|
||||
},
|
||||
},
|
||||
} as unknown as AuthProfileStore;
|
||||
}
|
||||
|
||||
describe("external cli sync email backfill", () => {
|
||||
it("backfills the email onto a usable stored profile from the same login", () => {
|
||||
readClaudeCliCredentialsCachedMock.mockReturnValue({
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: ["rotated", "access"].join("-"),
|
||||
refresh: refreshFixture(),
|
||||
expires: Date.now() + 3_600_000,
|
||||
email: "cli-login@example.com",
|
||||
});
|
||||
|
||||
const profiles = resolveExternalCliAuthProfiles(storeWithClaudeProfile());
|
||||
|
||||
const backfilledEmail = "cli-login@example.com";
|
||||
expect(profiles).toEqual([
|
||||
{
|
||||
profileId: "anthropic:claude-cli",
|
||||
credential: expect.objectContaining({ email: backfilledEmail }),
|
||||
persistence: "persisted",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not backfill from a different CLI login", () => {
|
||||
readClaudeCliCredentialsCachedMock.mockReturnValue({
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: ["other", "access"].join("-"),
|
||||
refresh: ["other", "refresh"].join("-"),
|
||||
expires: Date.now() + 3_600_000,
|
||||
email: "someone-else@example.com",
|
||||
});
|
||||
|
||||
expect(resolveExternalCliAuthProfiles(storeWithClaudeProfile())).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -5,12 +5,10 @@
|
||||
*/
|
||||
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
|
||||
import {
|
||||
readClaudeCliCredentialsCached,
|
||||
readCodexCliCredentialsCached,
|
||||
readMiniMaxCliCredentialsCached,
|
||||
} from "../cli-credentials.js";
|
||||
import {
|
||||
CLAUDE_CLI_PROFILE_ID,
|
||||
EXTERNAL_CLI_SYNC_TTL_MS,
|
||||
MINIMAX_CLI_PROFILE_ID,
|
||||
OPENAI_CODEX_DEFAULT_PROFILE_ID,
|
||||
@@ -51,13 +49,12 @@ type ExternalCliSyncProvider = {
|
||||
// CLI state must not replace or shadow it. Codex requires this to
|
||||
// avoid clobbering a locally refreshed token with stale CLI state.
|
||||
bootstrapOnly?: boolean;
|
||||
persistence?: ExternalCliResolvedProfile["persistence"];
|
||||
};
|
||||
|
||||
// Keep this gate aligned with the canonical identity-copy rule in oauth.ts.
|
||||
// Also the passthrough gate in cli-runner/prepare.ts: a live CLI login that
|
||||
// this sync would refuse to import must not authenticate a run either.
|
||||
// External CLI bootstrap must never replace a local profile with another identity.
|
||||
/** Return true when imported CLI credentials match an existing profile identity. */
|
||||
export function isSafeToUseExternalCliCredential(
|
||||
function isSafeToUseExternalCliCredential(
|
||||
existing: OAuthCredential | undefined,
|
||||
imported: OAuthCredential,
|
||||
): boolean {
|
||||
@@ -83,21 +80,6 @@ const EXTERNAL_CLI_SYNC_PROVIDERS: ExternalCliSyncProvider[] = [
|
||||
}),
|
||||
bootstrapOnly: true,
|
||||
},
|
||||
{
|
||||
profileId: CLAUDE_CLI_PROFILE_ID,
|
||||
provider: "claude-cli",
|
||||
aliases: ["anthropic"],
|
||||
readCredentials: (options) => {
|
||||
const credential = readClaudeCliCredentialsCached({
|
||||
ttlMs: EXTERNAL_CLI_SYNC_TTL_MS,
|
||||
allowKeychainPrompt: options?.allowKeychainPrompt,
|
||||
});
|
||||
if (credential?.type !== "oauth") {
|
||||
return null;
|
||||
}
|
||||
return { ...credential, provider: "claude-cli" };
|
||||
},
|
||||
},
|
||||
{
|
||||
profileId: MINIMAX_CLI_PROFILE_ID,
|
||||
provider: "minimax-portal",
|
||||
@@ -399,7 +381,11 @@ export function resolveExternalCliAuthProfiles(
|
||||
allowKeychainPrompt: options?.allowKeychainPrompt,
|
||||
});
|
||||
if (backfilled) {
|
||||
profiles.push({ profileId, credential: backfilled, persistence: "persisted" });
|
||||
profiles.push({
|
||||
profileId,
|
||||
credential: backfilled,
|
||||
persistence: providerConfig.persistence ?? "persisted",
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -462,7 +448,9 @@ export function resolveExternalCliAuthProfiles(
|
||||
profiles.push({
|
||||
profileId,
|
||||
credential: creds,
|
||||
persistence: providerConfig.bootstrapOnly ? "runtime-only" : "persisted",
|
||||
persistence:
|
||||
providerConfig.persistence ??
|
||||
(providerConfig.bootstrapOnly ? "runtime-only" : "persisted"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,15 +30,11 @@ const readCodexCliCredentialsCachedMock = vi.hoisted(() => {
|
||||
vi.resetModules();
|
||||
return vi.fn<(_options?: unknown) => OAuthCredential | null>(() => null);
|
||||
});
|
||||
const readClaudeCliCredentialsCachedMock = vi.hoisted(() =>
|
||||
vi.fn<(_options?: unknown) => OAuthCredential | null>(() => null),
|
||||
);
|
||||
const readMiniMaxCliCredentialsCachedMock = vi.hoisted(() =>
|
||||
vi.fn<(_options?: unknown) => OAuthCredential | null>(() => null),
|
||||
);
|
||||
|
||||
vi.mock("../cli-credentials.js", () => ({
|
||||
readClaudeCliCredentialsCached: readClaudeCliCredentialsCachedMock,
|
||||
readCodexCliCredentialsCached: readCodexCliCredentialsCachedMock,
|
||||
readMiniMaxCliCredentialsCached: readMiniMaxCliCredentialsCachedMock,
|
||||
}));
|
||||
@@ -76,8 +72,6 @@ describe("auth external oauth helpers", () => {
|
||||
resolveExternalAuthProfilesWithPluginsMock.mockReturnValue([]);
|
||||
readCodexCliCredentialsCachedMock.mockReset();
|
||||
readCodexCliCredentialsCachedMock.mockReturnValue(null);
|
||||
readClaudeCliCredentialsCachedMock.mockReset();
|
||||
readClaudeCliCredentialsCachedMock.mockReturnValue(null);
|
||||
readMiniMaxCliCredentialsCachedMock.mockReset();
|
||||
readMiniMaxCliCredentialsCachedMock.mockReturnValue(null);
|
||||
testing.setResolveExternalAuthProfilesForTest(resolveExternalAuthProfilesWithPluginsMock);
|
||||
@@ -175,84 +169,6 @@ describe("auth external oauth helpers", () => {
|
||||
expect(getRuntimeExternalCliProfileIds(loggedOut)).toEqual([]);
|
||||
});
|
||||
|
||||
it("marks a refreshed persisted Claude CLI profile as runtime CLI-owned", () => {
|
||||
const profileId = "anthropic:claude-cli";
|
||||
const refresh = "claude-cli-refresh";
|
||||
readClaudeCliCredentialsCachedMock.mockReturnValueOnce(
|
||||
createCredential({
|
||||
provider: "anthropic",
|
||||
access: "fresh-claude-access",
|
||||
refresh,
|
||||
expires: createUsableOAuthExpiry(),
|
||||
}),
|
||||
);
|
||||
|
||||
const prepared = overlayExternalAuthProfiles(
|
||||
createStore({
|
||||
[profileId]: createCredential({
|
||||
provider: "claude-cli",
|
||||
access: "expired-claude-access",
|
||||
refresh,
|
||||
expires: Date.now() - 60_000,
|
||||
}),
|
||||
}),
|
||||
{ externalCliProviderIds: ["claude-cli"] },
|
||||
);
|
||||
|
||||
expect(prepared.profiles[profileId]).toMatchObject({
|
||||
provider: "claude-cli",
|
||||
access: "fresh-claude-access",
|
||||
});
|
||||
expect(getRuntimeExternalCliProfileIds(prepared)).toEqual([profileId]);
|
||||
});
|
||||
|
||||
it("bootstraps a missing Claude CLI profile from an Anthropic provider scope", () => {
|
||||
const profileId = "anthropic:claude-cli";
|
||||
readClaudeCliCredentialsCachedMock.mockReturnValueOnce(
|
||||
createCredential({
|
||||
provider: "anthropic",
|
||||
access: "fresh-claude-access",
|
||||
refresh: "fresh-claude-refresh",
|
||||
expires: createUsableOAuthExpiry(),
|
||||
}),
|
||||
);
|
||||
|
||||
const prepared = overlayExternalAuthProfiles(createStore(), {
|
||||
externalCliProviderIds: ["anthropic"],
|
||||
});
|
||||
|
||||
expect(prepared.profiles[profileId]).toMatchObject({
|
||||
provider: "claude-cli",
|
||||
access: "fresh-claude-access",
|
||||
});
|
||||
expect(getRuntimeExternalCliProfileIds(prepared)).toEqual([profileId]);
|
||||
});
|
||||
|
||||
it("recovers legacy Claude metadata after restart when no cached profile was persisted", () => {
|
||||
const profileId = "anthropic:claude-cli";
|
||||
readClaudeCliCredentialsCachedMock.mockReturnValueOnce(
|
||||
createCredential({
|
||||
provider: "anthropic",
|
||||
access: "rotated-claude-access",
|
||||
refresh: "rotated-claude-refresh",
|
||||
expires: createUsableOAuthExpiry(),
|
||||
}),
|
||||
);
|
||||
|
||||
const restarted = overlayExternalAuthProfiles(createStore(), {
|
||||
config: {
|
||||
auth: { profiles: { [profileId]: { provider: "anthropic", mode: "token" } } },
|
||||
},
|
||||
});
|
||||
|
||||
expect(restarted.profiles[profileId]).toMatchObject({
|
||||
type: "oauth",
|
||||
provider: "claude-cli",
|
||||
access: "rotated-claude-access",
|
||||
});
|
||||
expect(getRuntimeExternalCliProfileIds(restarted)).toEqual([profileId]);
|
||||
});
|
||||
|
||||
it("does not reinterpret legacy MiniMax metadata as managed CLI ownership", () => {
|
||||
const profileId = "minimax-portal:minimax-cli";
|
||||
readMiniMaxCliCredentialsCachedMock.mockReturnValueOnce(
|
||||
@@ -333,95 +249,6 @@ describe("auth external oauth helpers", () => {
|
||||
expect(getRuntimeExternalCliProfileIds(prepared)).toEqual([]);
|
||||
});
|
||||
|
||||
it("recovers an identity-less persisted Claude profile after restart and access expiry", () => {
|
||||
const profileId = "anthropic:claude-cli";
|
||||
const config = {
|
||||
auth: { profiles: { [profileId]: { provider: "anthropic", mode: "token" as const } } },
|
||||
};
|
||||
const restarted = overlayExternalAuthProfiles(
|
||||
createStore({
|
||||
[profileId]: createCredential({
|
||||
provider: "claude-cli",
|
||||
access: "persisted-access",
|
||||
refresh: "persisted-refresh",
|
||||
expires: createUsableOAuthExpiry(),
|
||||
}),
|
||||
}),
|
||||
{ config },
|
||||
);
|
||||
|
||||
expect(getRuntimeExternalCliProfileIds(restarted)).toEqual([]);
|
||||
|
||||
readClaudeCliCredentialsCachedMock.mockReset().mockReturnValueOnce(
|
||||
createCredential({
|
||||
provider: "anthropic",
|
||||
access: "rotated-access",
|
||||
refresh: "rotated-refresh",
|
||||
expires: createUsableOAuthExpiry(),
|
||||
}),
|
||||
);
|
||||
const recovered = overlayExternalAuthProfiles(
|
||||
{
|
||||
...restarted,
|
||||
profiles: {
|
||||
...restarted.profiles,
|
||||
[profileId]: {
|
||||
...restarted.profiles[profileId],
|
||||
type: "oauth",
|
||||
provider: "claude-cli",
|
||||
access: "persisted-access",
|
||||
refresh: "persisted-refresh",
|
||||
expires: Date.now() - 60_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
{ config },
|
||||
);
|
||||
|
||||
expect(recovered.profiles[profileId]).toMatchObject({
|
||||
provider: "claude-cli",
|
||||
access: "rotated-access",
|
||||
refresh: "rotated-refresh",
|
||||
});
|
||||
expect(getRuntimeExternalCliProfileIds(recovered)).toEqual([profileId]);
|
||||
});
|
||||
|
||||
it("restores runtime CLI ownership for a steady-state persisted Claude profile", () => {
|
||||
const profileId = "anthropic:claude-cli";
|
||||
const prepared = overlayExternalAuthProfiles(
|
||||
createStore({
|
||||
[profileId]: createCredential({
|
||||
provider: "claude-cli",
|
||||
access: "usable-claude-access",
|
||||
refresh: "usable-claude-refresh",
|
||||
expires: createUsableOAuthExpiry(),
|
||||
email: "stored@example.com",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(readClaudeCliCredentialsCachedMock).not.toHaveBeenCalled();
|
||||
expect(getRuntimeExternalCliProfileIds(prepared)).toEqual([profileId]);
|
||||
});
|
||||
|
||||
it("does not retain CLI refresh ownership for an expired persisted Claude profile", () => {
|
||||
const profileId = "anthropic:claude-cli";
|
||||
const prepared = overlayExternalAuthProfiles(
|
||||
createStore({
|
||||
[profileId]: createCredential({
|
||||
provider: "claude-cli",
|
||||
access: "expired-claude-access",
|
||||
refresh: "expired-claude-refresh",
|
||||
expires: Date.now() - 60_000,
|
||||
email: "stored@example.com",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(readClaudeCliCredentialsCachedMock).toHaveBeenCalledTimes(1);
|
||||
expect(getRuntimeExternalCliProfileIds(prepared)).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves a plugin winner that collides with a built-in CLI profile id", () => {
|
||||
readCodexCliCredentialsCachedMock.mockReturnValue(
|
||||
createCredential({ access: "cli-access", refresh: "cli-refresh" }),
|
||||
|
||||
@@ -22,7 +22,6 @@ export function getOAuthProviderRuntimeMocks() {
|
||||
}
|
||||
|
||||
vi.mock("../cli-credentials.js", () => ({
|
||||
readClaudeCliCredentialsCached: () => null,
|
||||
readCodexCliCredentialsCached: () => null,
|
||||
readMiniMaxCliCredentialsCached: () => null,
|
||||
resetCliCredentialCachesForTest: () => undefined,
|
||||
@@ -53,7 +52,6 @@ vi.mock("./external-cli-sync.js", () => ({
|
||||
credential.access.trim().length > 0 &&
|
||||
Number.isFinite(credential.expires) &&
|
||||
credential.expires - now > 5 * 60 * 1000,
|
||||
isSafeToUseExternalCliCredential: () => true,
|
||||
readExternalCliBootstrapCredential: () => null,
|
||||
resolveExternalCliAuthProfiles: () => [],
|
||||
shouldBootstrapFromExternalCliCredential: () => false,
|
||||
|
||||
@@ -30,7 +30,6 @@ vi.mock("../../llm/oauth.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../cli-credentials.js", () => ({
|
||||
readClaudeCliCredentialsCached: () => null,
|
||||
readCodexCliCredentialsCached: () => null,
|
||||
readMiniMaxCliCredentialsCached: () => null,
|
||||
resetCliCredentialCachesForTest: () => undefined,
|
||||
|
||||
@@ -59,7 +59,6 @@ const {
|
||||
}));
|
||||
|
||||
vi.mock("../cli-credentials.js", () => ({
|
||||
readClaudeCliCredentialsCached: () => null,
|
||||
readCodexCliCredentialsCached: readCodexCliCredentialsCachedMock,
|
||||
readMiniMaxCliCredentialsCached: () => null,
|
||||
resetCliCredentialCachesForTest: () => undefined,
|
||||
@@ -89,6 +88,7 @@ vi.mock("../../plugins/provider-runtime.runtime.js", () => ({
|
||||
vi.mock("../../plugins/provider-runtime.js", () => ({
|
||||
buildProviderMissingAuthMessageWithPlugin: () => undefined,
|
||||
resolveExternalAuthProfilesWithPlugins: () => [],
|
||||
resolveProviderDeprecatedAuthProfileIds: () => [],
|
||||
resolveProviderSyntheticAuthWithPlugin: () => undefined,
|
||||
shouldDeferProviderSyntheticProfileAuthWithPlugin: () => false,
|
||||
}));
|
||||
@@ -233,6 +233,60 @@ describe("resolveApiKeyForProfile openai refresh fallback", () => {
|
||||
expect(refreshProviderOAuthCredentialWithPluginMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("never refreshes a retired Claude CLI token as a legacy-profile fallback", async () => {
|
||||
const legacyProfileId = "anthropic:default";
|
||||
const retiredProfileId = "anthropic:claude-cli";
|
||||
saveAuthProfileStore(
|
||||
{
|
||||
version: 1,
|
||||
profiles: {
|
||||
[legacyProfileId]: {
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: "expired-default-access",
|
||||
refresh: "expired-default-refresh",
|
||||
expires: Date.now() - 60_000,
|
||||
},
|
||||
[retiredProfileId]: {
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: "copied-native-access",
|
||||
refresh: "copied-native-refresh",
|
||||
expires: Date.now() - 60_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
agentDir,
|
||||
{ filterExternalAuthProfiles: false, syncExternalCli: false },
|
||||
);
|
||||
refreshProviderOAuthCredentialWithPluginMock
|
||||
.mockRejectedValueOnce(new Error("initial refresh failed"))
|
||||
.mockResolvedValueOnce({
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: "refreshed-copied-native-access",
|
||||
refresh: "refreshed-copied-native-refresh",
|
||||
expires: Date.now() + 60 * 60_000,
|
||||
});
|
||||
|
||||
await expect(
|
||||
resolveApiKeyForProfile({
|
||||
cfg: {
|
||||
auth: {
|
||||
profiles: {
|
||||
[legacyProfileId]: { provider: "anthropic", mode: "oauth" },
|
||||
[retiredProfileId]: { provider: "anthropic", mode: "oauth" },
|
||||
},
|
||||
},
|
||||
},
|
||||
store: ensureAuthProfileStore(agentDir),
|
||||
profileId: legacyProfileId,
|
||||
agentDir,
|
||||
}),
|
||||
).rejects.toThrow(/OAuth token refresh failed for anthropic/);
|
||||
expect(refreshProviderOAuthCredentialWithPluginMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("fails closed when provider refresh returns an unchanged expired credential", async () => {
|
||||
const profileId = "openai:default";
|
||||
saveAuthProfileStore(
|
||||
|
||||
@@ -8,14 +8,17 @@ import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { resolveAuthProfileSecretOwnerId } from "../../secrets/runtime-auth-profile-owner.js";
|
||||
import { setActiveDegradedSecretOwners } from "../../secrets/runtime-degraded-state.js";
|
||||
import { withEnvAsync } from "../../test-utils/env.js";
|
||||
import type { AuthProfileStore } from "./types.js";
|
||||
import type { AuthProfileStore, RuntimeAuthProfileStore } from "./types.js";
|
||||
|
||||
vi.hoisted(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
const resolveProviderOAuthCredentialWithPlugin = vi.hoisted(() =>
|
||||
vi.fn(async () => ({ status: "unhandled" as const })),
|
||||
);
|
||||
|
||||
vi.mock("../cli-credentials.js", () => ({
|
||||
readClaudeCliCredentialsCached: () => null,
|
||||
readCodexCliCredentialsCached: () => null,
|
||||
readMiniMaxCliCredentialsCached: () => null,
|
||||
resetCliCredentialCachesForTest: () => undefined,
|
||||
@@ -25,7 +28,7 @@ vi.mock("../../plugins/provider-runtime.runtime.js", () => ({
|
||||
buildProviderAuthDoctorHintWithPlugin: async () => undefined,
|
||||
formatProviderAuthProfileApiKeyWithPlugin: async (params: { context?: { access?: string } }) =>
|
||||
params.context?.access,
|
||||
resolveProviderOAuthCredentialWithPlugin: async () => ({ status: "unhandled" }),
|
||||
resolveProviderOAuthCredentialWithPlugin,
|
||||
}));
|
||||
|
||||
let resolveApiKeyForProfile: typeof import("./oauth.js").resolveApiKeyForProfile;
|
||||
@@ -120,6 +123,7 @@ async function expectResolvedApiKey(params: {
|
||||
beforeAll(loadOAuthModuleForTest);
|
||||
|
||||
beforeEach(() => {
|
||||
resolveProviderOAuthCredentialWithPlugin.mockClear();
|
||||
clearRuntimeAuthProfileStoreSnapshots();
|
||||
setActiveDegradedSecretOwners([]);
|
||||
// SecretRef cases consume the materialized store published by runtime activation.
|
||||
@@ -160,6 +164,61 @@ beforeEach(() => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveApiKeyForProfile retired external CLI profiles", () => {
|
||||
it("rejects a persisted Claude CLI token even when legacy metadata marks it external", async () => {
|
||||
const profileId = "anthropic:claude-cli";
|
||||
const store: RuntimeAuthProfileStore = {
|
||||
version: 1,
|
||||
profiles: {
|
||||
[profileId]: {
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: "copied-native-access",
|
||||
refresh: "copied-native-refresh",
|
||||
expires: Date.now() + 60 * 60_000,
|
||||
},
|
||||
},
|
||||
runtimePersistedProfileIds: [profileId],
|
||||
runtimeExternalCliProfileIds: [profileId],
|
||||
};
|
||||
|
||||
await expect(
|
||||
resolveApiKeyForProfile({
|
||||
cfg: cfgFor(profileId, "anthropic", "oauth"),
|
||||
store,
|
||||
profileId,
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
expect(resolveProviderOAuthCredentialWithPlugin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a runtime-only Claude CLI token without refreshing it", async () => {
|
||||
const profileId = "anthropic:claude-cli";
|
||||
const store: RuntimeAuthProfileStore = {
|
||||
version: 1,
|
||||
profiles: {
|
||||
[profileId]: {
|
||||
type: "oauth",
|
||||
provider: "claude-cli",
|
||||
access: "current-native-access",
|
||||
refresh: "current-native-refresh",
|
||||
expires: Date.now() + 60 * 60_000,
|
||||
},
|
||||
},
|
||||
runtimeExternalCliProfileIds: [profileId],
|
||||
};
|
||||
|
||||
await expect(
|
||||
resolveApiKeyForProfile({
|
||||
cfg: cfgFor(profileId, "claude-cli", "oauth"),
|
||||
store,
|
||||
profileId,
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
expect(resolveProviderOAuthCredentialWithPlugin).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
clearRuntimeAuthProfileStoreSnapshots();
|
||||
setActiveDegradedSecretOwners([]);
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
} from "../../secrets/runtime-degraded-state.js";
|
||||
import { normalizeOptionalSecretInput } from "../../utils/normalize-secret-input.js";
|
||||
import { resolveProviderIdForAuth } from "../provider-auth-aliases.js";
|
||||
import { authProfilesLog } from "./constants.js";
|
||||
import { authProfilesLog, CLAUDE_CLI_PROFILE_ID } from "./constants.js";
|
||||
import {
|
||||
evaluateStoredCredentialEligibility,
|
||||
resolveTokenExpiryState,
|
||||
@@ -257,6 +257,9 @@ async function tryResolveOAuthProfile(
|
||||
params: ResolveApiKeyForProfileParams,
|
||||
): Promise<ResolveApiKeyForProfileResult | null> {
|
||||
const { cfg, store, profileId } = params;
|
||||
if (isRetiredOAuthProfileId(profileId)) {
|
||||
return null;
|
||||
}
|
||||
const cred = store.profiles[profileId];
|
||||
if (!cred || cred.type !== "oauth") {
|
||||
return null;
|
||||
@@ -293,6 +296,10 @@ async function tryResolveOAuthProfile(
|
||||
});
|
||||
}
|
||||
|
||||
function isRetiredOAuthProfileId(profileId: string): boolean {
|
||||
return profileId === CLAUDE_CLI_PROFILE_ID;
|
||||
}
|
||||
|
||||
function authProfileSecretRefKey(
|
||||
profile: AuthProfileCredential,
|
||||
defaults: SecretDefaults | undefined,
|
||||
@@ -382,6 +389,11 @@ export async function resolveApiKeyForProfile(
|
||||
if (!storedProfile) {
|
||||
return null;
|
||||
}
|
||||
// Claude owns this native login slot. Legacy persisted copies must never
|
||||
// resolve, refresh, or leave OpenClaw as bearer tokens.
|
||||
if (isRetiredOAuthProfileId(profileId)) {
|
||||
return null;
|
||||
}
|
||||
const configForRefResolution = cfg ?? getRuntimeConfig();
|
||||
const refDefaults = configForRefResolution.secrets?.defaults;
|
||||
const runtimeProfile = resolveRuntimeAuthProfile({
|
||||
|
||||
@@ -26,6 +26,10 @@ import {
|
||||
setAuthProfileOrder,
|
||||
upsertAuthProfileWithLock,
|
||||
} from "./profiles.js";
|
||||
import {
|
||||
getRuntimeExternalCliProfileIds,
|
||||
getRuntimeLocalProfileIds,
|
||||
} from "./runtime-external-profile-references.js";
|
||||
import {
|
||||
clearRuntimeAuthProfileStoreSnapshots,
|
||||
getRuntimeAuthProfileStoreSnapshotCore as getInternalRuntimeAuthProfileStoreSnapshot,
|
||||
@@ -1429,34 +1433,36 @@ describe("promoteAuthProfileInOrder", () => {
|
||||
it("narrows provider removal to selected profiles", async () => {
|
||||
await withAuthProfileTestState("openclaw-auth-remove-selected-", async ({ agentDir }) => {
|
||||
fs.mkdirSync(agentDir, { recursive: true });
|
||||
saveAuthProfileStore(
|
||||
{
|
||||
version: AUTH_STORE_VERSION,
|
||||
profiles: {
|
||||
"openrouter:oauth": {
|
||||
type: "oauth",
|
||||
provider: "openrouter",
|
||||
access: "oauth-access",
|
||||
refresh: "oauth-refresh",
|
||||
expires: Date.now() + 60_000,
|
||||
},
|
||||
"openrouter:api-key": {
|
||||
type: "api_key",
|
||||
provider: "openrouter",
|
||||
key: "api-key",
|
||||
},
|
||||
const initialStore: RuntimeAuthProfileStore = {
|
||||
version: AUTH_STORE_VERSION,
|
||||
profiles: {
|
||||
"openrouter:oauth": {
|
||||
type: "oauth",
|
||||
provider: "openrouter",
|
||||
access: "oauth-access",
|
||||
refresh: "oauth-refresh",
|
||||
expires: Date.now() + 60_000,
|
||||
},
|
||||
order: { openrouter: ["openrouter:oauth", "openrouter:api-key"] },
|
||||
lastGood: { openrouter: "openrouter:oauth" },
|
||||
usageStats: {
|
||||
"openrouter:oauth": { lastUsed: 1 },
|
||||
"openrouter:api-key": { lastUsed: 2 },
|
||||
"openrouter:api-key": {
|
||||
type: "api_key",
|
||||
provider: "openrouter",
|
||||
key: "api-key",
|
||||
},
|
||||
},
|
||||
agentDir,
|
||||
);
|
||||
order: { openrouter: ["openrouter:oauth", "openrouter:api-key"] },
|
||||
lastGood: { openrouter: "openrouter:oauth" },
|
||||
usageStats: {
|
||||
"openrouter:oauth": { lastUsed: 1 },
|
||||
"openrouter:api-key": { lastUsed: 2 },
|
||||
},
|
||||
runtimePersistedProfileIds: ["openrouter:oauth", "openrouter:api-key"],
|
||||
runtimeLocalProfileIds: ["openrouter:oauth", "openrouter:api-key"],
|
||||
runtimeExternalProfileIds: ["openrouter:oauth", "openrouter:api-key"],
|
||||
runtimeExternalCliProfileIds: ["openrouter:oauth", "openrouter:api-key"],
|
||||
};
|
||||
saveAuthProfileStore(initialStore, agentDir);
|
||||
|
||||
await removeProviderAuthProfilesWithLock({
|
||||
const removedStore = await removeProviderAuthProfilesWithLock({
|
||||
agentDir,
|
||||
provider: "openrouter",
|
||||
profileIds: ["openrouter:oauth"],
|
||||
@@ -1469,6 +1475,49 @@ describe("promoteAuthProfileInOrder", () => {
|
||||
});
|
||||
expect(loadAuthProfileStoreForRuntime(agentDir).profiles["openrouter:oauth"]).toBeUndefined();
|
||||
expect(loadAuthProfileStoreForRuntime(agentDir).lastGood).toBeUndefined();
|
||||
expect(removedStore?.runtimePersistedProfileIds ?? []).not.toContain("openrouter:oauth");
|
||||
expect(removedStore ? getRuntimeLocalProfileIds(removedStore) : []).not.toContain(
|
||||
"openrouter:oauth",
|
||||
);
|
||||
expect(removedStore?.runtimeExternalProfileIds ?? []).not.toContain("openrouter:oauth");
|
||||
expect(removedStore ? getRuntimeExternalCliProfileIds(removedStore) : []).not.toContain(
|
||||
"openrouter:oauth",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not rewrite the store when selected profiles are absent", async () => {
|
||||
await withAuthProfileTestState("openclaw-auth-remove-noop-", async ({ agentDir }) => {
|
||||
fs.mkdirSync(agentDir, { recursive: true });
|
||||
const initialStore: AuthProfileStore = {
|
||||
version: AUTH_STORE_VERSION,
|
||||
profiles: {
|
||||
"openrouter:api-key": {
|
||||
type: "api_key",
|
||||
provider: "openrouter",
|
||||
key: "api-key",
|
||||
},
|
||||
},
|
||||
};
|
||||
saveAuthProfileStore(initialStore, agentDir);
|
||||
replaceRuntimeAuthProfileStoreSnapshots([
|
||||
{ agentDir, store: loadAuthProfileStoreForRuntime(agentDir) },
|
||||
]);
|
||||
const credentialRevision =
|
||||
getRuntimeAuthProfileStoreCredentialMutationToken(agentDir).revision;
|
||||
const stateRevision = getRuntimeAuthProfileStoreStateMutationToken(agentDir).revision;
|
||||
|
||||
await removeProviderAuthProfilesWithLock({
|
||||
agentDir,
|
||||
provider: "openrouter",
|
||||
profileIds: ["openrouter:missing"],
|
||||
});
|
||||
|
||||
expect(loadPersistedAuthProfileStore(agentDir)).toEqual(initialStore);
|
||||
expect(getRuntimeAuthProfileStoreCredentialMutationToken(agentDir).revision).toBe(
|
||||
credentialRevision,
|
||||
);
|
||||
expect(getRuntimeAuthProfileStoreStateMutationToken(agentDir).revision).toBe(stateRevision);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3,12 +3,20 @@
|
||||
* Updates profile order, last-good state, usage stats, and provider profile
|
||||
* records through locked or immediate store writes.
|
||||
*/
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
|
||||
import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization";
|
||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
import { resolveProviderIdForAuth } from "../provider-auth-aliases.js";
|
||||
import { normalizeAuthProfileCredential } from "./credential-normalize.js";
|
||||
import { dedupeProfileIds, listProfilesForProvider } from "./profile-list.js";
|
||||
import {
|
||||
getRuntimeExternalCliProfileIds,
|
||||
getRuntimeLocalProfileIds,
|
||||
removeRuntimeExternalProfileReferences,
|
||||
setRuntimeExternalCliProfileIds,
|
||||
setRuntimeLocalProfileIds,
|
||||
} from "./runtime-external-profile-references.js";
|
||||
import {
|
||||
ensureAuthProfileStoreForLocalUpdate,
|
||||
resolvePersistedAuthProfileOwnerAgentDir,
|
||||
@@ -256,45 +264,22 @@ export async function removeAuthProfilesWithLock(params: {
|
||||
return await updateAuthProfileStoreWithLock({
|
||||
agentDir: params.agentDir,
|
||||
updater: (store) => {
|
||||
let changed = false;
|
||||
for (const profileId of profileIds) {
|
||||
if (store.profiles[profileId]) {
|
||||
delete store.profiles[profileId];
|
||||
changed = true;
|
||||
}
|
||||
if (store.usageStats?.[profileId]) {
|
||||
delete store.usageStats[profileId];
|
||||
changed = true;
|
||||
}
|
||||
const next = removeRuntimeExternalProfileReferences({ store, profileIds });
|
||||
if (isDeepStrictEqual(store, next)) {
|
||||
return false;
|
||||
}
|
||||
for (const [provider, order] of Object.entries(store.order ?? {})) {
|
||||
const next = order.filter((profileId) => !profileIds.has(profileId));
|
||||
if (next.length === order.length) {
|
||||
continue;
|
||||
}
|
||||
changed = true;
|
||||
if (next.length > 0) {
|
||||
store.order![provider] = next;
|
||||
} else {
|
||||
delete store.order![provider];
|
||||
}
|
||||
}
|
||||
for (const [provider, profileId] of Object.entries(store.lastGood ?? {})) {
|
||||
if (profileIds.has(profileId)) {
|
||||
delete store.lastGood![provider];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (store.order && Object.keys(store.order).length === 0) {
|
||||
store.order = undefined;
|
||||
}
|
||||
if (store.lastGood && Object.keys(store.lastGood).length === 0) {
|
||||
store.lastGood = undefined;
|
||||
}
|
||||
if (store.usageStats && Object.keys(store.usageStats).length === 0) {
|
||||
store.usageStats = undefined;
|
||||
}
|
||||
return changed;
|
||||
Object.assign(store, {
|
||||
profiles: next.profiles,
|
||||
order: next.order,
|
||||
lastGood: next.lastGood,
|
||||
usageStats: next.usageStats,
|
||||
runtimePersistedProfileIds: next.runtimePersistedProfileIds,
|
||||
runtimeExternalProfileIds: next.runtimeExternalProfileIds,
|
||||
runtimeExternalProfileIdsAuthoritative: next.runtimeExternalProfileIdsAuthoritative,
|
||||
});
|
||||
setRuntimeLocalProfileIds(store, getRuntimeLocalProfileIds(next));
|
||||
setRuntimeExternalCliProfileIds(store, getRuntimeExternalCliProfileIds(next));
|
||||
return true;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -305,7 +290,7 @@ export async function removeAuthProfilesWithLock(params: {
|
||||
* store lets the profile reappear on the next status read and auth warmup.
|
||||
*/
|
||||
export async function removeAuthProfilesAcrossOwnerStores(params: {
|
||||
agentDir: string;
|
||||
agentDir?: string;
|
||||
profileIds: readonly string[];
|
||||
}): Promise<boolean> {
|
||||
const profilesByOwner = new Map<string | undefined, Set<string>>([
|
||||
|
||||
@@ -2,11 +2,9 @@ import { isDeepStrictEqual } from "node:util";
|
||||
import { cloneAuthProfileStore } from "./clone.js";
|
||||
import type { AuthProfileStore, RuntimeAuthProfileStore } from "./types.js";
|
||||
|
||||
type RuntimeExternalCliStore = AuthProfileStore &
|
||||
Pick<RuntimeAuthProfileStore, "runtimeExternalCliProfileIds">;
|
||||
|
||||
export function getRuntimeExternalCliProfileIds(store: AuthProfileStore): readonly string[] {
|
||||
return (store as RuntimeExternalCliStore).runtimeExternalCliProfileIds ?? [];
|
||||
const runtimeStore: RuntimeAuthProfileStore = store;
|
||||
return runtimeStore.runtimeExternalCliProfileIds ?? [];
|
||||
}
|
||||
|
||||
export function setRuntimeExternalCliProfileIds(
|
||||
@@ -14,8 +12,22 @@ export function setRuntimeExternalCliProfileIds(
|
||||
profileIds: Iterable<string>,
|
||||
): void {
|
||||
const ids = [...new Set(profileIds)].filter((profileId) => store.profiles[profileId]).toSorted();
|
||||
(store as RuntimeExternalCliStore).runtimeExternalCliProfileIds =
|
||||
ids.length > 0 ? ids : undefined;
|
||||
const runtimeStore: RuntimeAuthProfileStore = store;
|
||||
runtimeStore.runtimeExternalCliProfileIds = ids.length > 0 ? ids : undefined;
|
||||
}
|
||||
|
||||
export function getRuntimeLocalProfileIds(store: AuthProfileStore): readonly string[] {
|
||||
const runtimeStore: RuntimeAuthProfileStore = store;
|
||||
return runtimeStore.runtimeLocalProfileIds ?? [];
|
||||
}
|
||||
|
||||
export function setRuntimeLocalProfileIds(
|
||||
store: AuthProfileStore,
|
||||
profileIds: Iterable<string>,
|
||||
): void {
|
||||
const ids = [...new Set(profileIds)].filter((profileId) => store.profiles[profileId]).toSorted();
|
||||
const runtimeStore: RuntimeAuthProfileStore = store;
|
||||
runtimeStore.runtimeLocalProfileIds = ids.length > 0 ? ids : undefined;
|
||||
}
|
||||
|
||||
export function removeRuntimeExternalProfileReferences(params: {
|
||||
@@ -61,6 +73,10 @@ export function removeRuntimeExternalProfileReferences(params: {
|
||||
if (next.runtimePersistedProfileIds?.length === 0) {
|
||||
next.runtimePersistedProfileIds = undefined;
|
||||
}
|
||||
setRuntimeLocalProfileIds(
|
||||
next,
|
||||
getRuntimeLocalProfileIds(next).filter((profileId) => !params.profileIds.has(profileId)),
|
||||
);
|
||||
next.runtimeExternalProfileIds = next.runtimeExternalProfileIds?.filter(
|
||||
(profileId) => !params.profileIds.has(profileId),
|
||||
);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import "./cli-auth-epoch.js";
|
||||
|
||||
type CliAuthEpochDeps = {
|
||||
readClaudeCliCredentialsCached: typeof import("./cli-credentials.js").readClaudeCliCredentialsCached;
|
||||
readCodexCliCredentialsCached: typeof import("./cli-credentials.js").readCodexCliCredentialsCached;
|
||||
readGeminiCliCredentialsCached: typeof import("./cli-credentials.js").readGeminiCliCredentialsCached;
|
||||
ensureAuthProfileStore: typeof import("./auth-profiles/store.js").ensureAuthProfileStore;
|
||||
|
||||
@@ -35,7 +35,6 @@ describe("resolveCliAuthEpoch", () => {
|
||||
|
||||
it("returns undefined when no local or auth-profile credentials exist", async () => {
|
||||
setCliAuthEpochTestDeps({
|
||||
readClaudeCliCredentialsCached: () => null,
|
||||
readCodexCliCredentialsCached: () => null,
|
||||
readGeminiCliCredentialsCached: () => null,
|
||||
loadAuthProfileStoreForRuntime: () => ({
|
||||
@@ -192,60 +191,6 @@ describe("resolveCliAuthEpoch", () => {
|
||||
expect(renamed).not.toBe(primary);
|
||||
});
|
||||
|
||||
it("keeps identity-less claude cli oauth epochs stable across token changes", async () => {
|
||||
let access = "access-a";
|
||||
let refresh = "refresh-a";
|
||||
let expires = 1;
|
||||
setCliAuthEpochTestDeps({
|
||||
readClaudeCliCredentialsCached: () => ({
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access,
|
||||
refresh,
|
||||
expires,
|
||||
}),
|
||||
});
|
||||
|
||||
const first = await resolveCliAuthEpoch({ provider: "claude-cli" });
|
||||
access = "access-b";
|
||||
refresh = "refresh-b";
|
||||
expires = 2;
|
||||
const second = await resolveCliAuthEpoch({ provider: "claude-cli" });
|
||||
|
||||
expectCliAuthEpoch(first);
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
|
||||
it("uses stricter binding semantics for identity-less CLI OAuth", async () => {
|
||||
let access = "access-a";
|
||||
let refresh = "refresh-a";
|
||||
setCliAuthEpochTestDeps({
|
||||
readClaudeCliCredentialsCached: () => ({
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access,
|
||||
refresh,
|
||||
expires: 1,
|
||||
}),
|
||||
});
|
||||
|
||||
const reusableEpoch = await resolveCliAuthEpoch({ provider: "claude-cli" });
|
||||
const firstBinding = resolveCliAuthBindingFingerprint({
|
||||
provider: "claude-cli",
|
||||
config: {},
|
||||
});
|
||||
access = "access-b";
|
||||
refresh = "refresh-b";
|
||||
const reusableEpochAfterRefresh = await resolveCliAuthEpoch({ provider: "claude-cli" });
|
||||
const secondBinding = resolveCliAuthBindingFingerprint({
|
||||
provider: "claude-cli",
|
||||
config: {},
|
||||
});
|
||||
|
||||
expect(reusableEpochAfterRefresh).toBe(reusableEpoch);
|
||||
expect(secondBinding).not.toBe(firstBinding);
|
||||
});
|
||||
|
||||
it("keeps strict CLI bindings stable for a known OAuth principal", () => {
|
||||
let access = "access-a";
|
||||
let refresh = "refresh-a";
|
||||
@@ -363,102 +308,6 @@ describe("resolveCliAuthEpoch", () => {
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
|
||||
it("keeps claude cli token epochs stable across token rotation", async () => {
|
||||
let token = "token-a";
|
||||
setCliAuthEpochTestDeps({
|
||||
readClaudeCliCredentialsCached: () => ({
|
||||
type: "token",
|
||||
provider: "anthropic",
|
||||
token,
|
||||
expires: 1,
|
||||
}),
|
||||
});
|
||||
|
||||
const first = await resolveCliAuthEpoch({ provider: "claude-cli" });
|
||||
token = "token-b";
|
||||
const second = await resolveCliAuthEpoch({ provider: "claude-cli" });
|
||||
|
||||
expectCliAuthEpoch(first);
|
||||
// Static-token rotation is an authorized credential refresh, not an
|
||||
// identity change. After #74312 the hash is identity-only for both
|
||||
// OAuth and token branches, so rotation does not invalidate the epoch.
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
|
||||
it("matches claude cli token and oauth epochs so partial keychain reads do not flip", async () => {
|
||||
setCliAuthEpochTestDeps({
|
||||
readClaudeCliCredentialsCached: () => ({
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: "access",
|
||||
refresh: "refresh",
|
||||
expires: 1,
|
||||
}),
|
||||
});
|
||||
const oauthEpoch = await resolveCliAuthEpoch({ provider: "claude-cli" });
|
||||
|
||||
setCliAuthEpochTestDeps({
|
||||
readClaudeCliCredentialsCached: () => ({
|
||||
type: "token",
|
||||
provider: "anthropic",
|
||||
token: "access",
|
||||
expires: 1,
|
||||
}),
|
||||
});
|
||||
const tokenEpoch = await resolveCliAuthEpoch({ provider: "claude-cli" });
|
||||
|
||||
expectCliAuthEpoch(oauthEpoch);
|
||||
expectCliAuthEpoch(tokenEpoch);
|
||||
// The macOS Claude keychain rewrite is not atomic. A transient read with
|
||||
// `refreshToken` missing falls into the parser's token branch; the OAuth
|
||||
// and token encodings must produce the same hash so the auth-epoch does
|
||||
// not flip during a token rotation. Regression for #74312.
|
||||
expect(tokenEpoch).toBe(oauthEpoch);
|
||||
});
|
||||
|
||||
it("changes the Claude CLI auth epoch when apiKeyHelper configuration changes", async () => {
|
||||
let helperHash = "helper-hash-a";
|
||||
setCliAuthEpochTestDeps({
|
||||
readClaudeCliCredentialsCached: () => ({
|
||||
type: "api_key_helper",
|
||||
provider: "anthropic",
|
||||
helperHash,
|
||||
}),
|
||||
});
|
||||
|
||||
const first = await resolveCliAuthEpoch({ provider: "claude-cli" });
|
||||
helperHash = "helper-hash-b";
|
||||
const second = await resolveCliAuthEpoch({ provider: "claude-cli" });
|
||||
|
||||
expectCliAuthEpoch(first);
|
||||
expectCliAuthEpoch(second);
|
||||
expect(second).not.toBe(first);
|
||||
});
|
||||
|
||||
it("drops the claude cli epoch when the credential read is absent", async () => {
|
||||
setCliAuthEpochTestDeps({
|
||||
readClaudeCliCredentialsCached: () => ({
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: "access",
|
||||
refresh: "refresh",
|
||||
expires: 1,
|
||||
}),
|
||||
});
|
||||
const successfulRead = await resolveCliAuthEpoch({ provider: "claude-cli" });
|
||||
|
||||
// A null read can mean the credential was removed or logout left no
|
||||
// readable auth state. Keep that absence visible so reusable sessions do
|
||||
// not survive a true auth-state loss.
|
||||
setCliAuthEpochTestDeps({
|
||||
readClaudeCliCredentialsCached: () => null,
|
||||
});
|
||||
const nullRead = await resolveCliAuthEpoch({ provider: "claude-cli" });
|
||||
|
||||
expectCliAuthEpoch(successfulRead);
|
||||
expect(nullRead).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps gemini cli oauth epochs stable through token rotation and flips on account change", async () => {
|
||||
let access = "gemini-access-a";
|
||||
let refresh = "gemini-refresh-a";
|
||||
@@ -1020,7 +869,6 @@ describe("resolveCliAuthEpoch", () => {
|
||||
|
||||
it("attests an opaque CLI backend owner without reading credential material", async () => {
|
||||
setCliAuthEpochTestDeps({
|
||||
readClaudeCliCredentialsCached: () => null,
|
||||
ensureAuthProfileStore: () => ({ version: 1, profiles: {} }),
|
||||
});
|
||||
|
||||
@@ -1165,4 +1013,3 @@ describe("resolveCliAuthEpoch", () => {
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
|
||||
|
||||
@@ -9,10 +9,8 @@ import { ensureAuthProfileStore, loadAuthProfileStoreForRuntime } from "./auth-p
|
||||
import type { AuthProfileCredential, AuthProfileStore } from "./auth-profiles/types.js";
|
||||
import { resolveCliBackendConfig } from "./cli-backends.js";
|
||||
import {
|
||||
readClaudeCliCredentialsCached,
|
||||
readCodexCliCredentialsCached,
|
||||
readGeminiCliCredentialsCached,
|
||||
type ClaudeCliCredential,
|
||||
type CodexCliCredential,
|
||||
type GeminiCliCredential,
|
||||
} from "./cli-credentials.js";
|
||||
@@ -29,7 +27,6 @@ import {
|
||||
import type { ResolvedProviderAuth } from "./model-auth-runtime-shared.js";
|
||||
|
||||
type CliAuthEpochDeps = {
|
||||
readClaudeCliCredentialsCached: typeof readClaudeCliCredentialsCached;
|
||||
readCodexCliCredentialsCached: typeof readCodexCliCredentialsCached;
|
||||
readGeminiCliCredentialsCached: typeof readGeminiCliCredentialsCached;
|
||||
ensureAuthProfileStore: typeof ensureAuthProfileStore;
|
||||
@@ -37,7 +34,6 @@ type CliAuthEpochDeps = {
|
||||
};
|
||||
|
||||
const defaultCliAuthEpochDeps: CliAuthEpochDeps = {
|
||||
readClaudeCliCredentialsCached,
|
||||
readCodexCliCredentialsCached,
|
||||
readGeminiCliCredentialsCached,
|
||||
ensureAuthProfileStore,
|
||||
@@ -99,25 +95,6 @@ function encodeOAuthIdentity(credential: {
|
||||
]);
|
||||
}
|
||||
|
||||
function encodeClaudeCredential(credential: ClaudeCliCredential): string {
|
||||
if (credential.type === "api_key_helper") {
|
||||
return JSON.stringify([credential.type, credential.provider, credential.helperHash]);
|
||||
}
|
||||
// Identity-only hashing for Claude CLI-managed credentials.
|
||||
// The Claude CLI keychain rewrite is not atomic: a token rotation can
|
||||
// briefly produce a partial read where `refreshToken` is missing, and the
|
||||
// parser falls back to a token-shaped credential. With the previous
|
||||
// token-inclusive hash, that transient race flipped the auth-epoch and
|
||||
// forced a session reset on every rotation. Routing these branches through
|
||||
// `encodeOAuthIdentity` collapses partial reads and rotations onto the same
|
||||
// provider-keyed identity hash. Helper auth stays distinct because changing
|
||||
// its configured command can switch accounts. Fixes #74312.
|
||||
return encodeOAuthIdentity({
|
||||
type: "oauth",
|
||||
provider: credential.provider,
|
||||
});
|
||||
}
|
||||
|
||||
function encodeCodexCredential(credential: CodexCliCredential): string {
|
||||
return encodeOAuthIdentity(credential);
|
||||
}
|
||||
@@ -194,16 +171,6 @@ function encodeAuthProfileEpochPart(
|
||||
|
||||
function getLocalCliCredentialFingerprint(provider: string): string | undefined {
|
||||
switch (provider) {
|
||||
case "claude-cli": {
|
||||
const credential = cliAuthEpochDeps.readClaudeCliCredentialsCached({
|
||||
ttlMs: 5000,
|
||||
allowKeychainPrompt: false,
|
||||
});
|
||||
// Keep true credential absence absent so logout/removal invalidates
|
||||
// reusable sessions. The 5s credential cache still masks transient
|
||||
// null reads immediately after a successful read.
|
||||
return credential ? hashCliAuthEpochPart(encodeClaudeCredential(credential)) : undefined;
|
||||
}
|
||||
case "codex-cli": {
|
||||
const credential = cliAuthEpochDeps.readCodexCliCredentialsCached({
|
||||
ttlMs: 5000,
|
||||
@@ -224,15 +191,6 @@ function getLocalCliCredentialFingerprint(provider: string): string | undefined
|
||||
|
||||
function getLocalCliCredential(provider: string): AuthProfileCredential | undefined {
|
||||
switch (provider) {
|
||||
case "claude-cli": {
|
||||
const auth = cliAuthEpochDeps.readClaudeCliCredentialsCached({
|
||||
ttlMs: 0,
|
||||
allowKeychainPrompt: false,
|
||||
});
|
||||
// Helper auth has no persisted secret/profile shape; its opaque command
|
||||
// fingerprint is already carried by getLocalCliCredentialFingerprint.
|
||||
return auth?.type === "api_key_helper" ? undefined : (auth ?? undefined);
|
||||
}
|
||||
case "codex-cli":
|
||||
return (
|
||||
cliAuthEpochDeps.readCodexCliCredentialsCached({
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
|
||||
import { readClaudeCliCredentialsCached } from "./cli-credentials.js";
|
||||
|
||||
const execSyncMock = vi.fn();
|
||||
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
|
||||
afterEach(() => {
|
||||
execSyncMock.mockReset();
|
||||
});
|
||||
|
||||
function readNonInteractiveClaudeCredential(platform: NodeJS.Platform) {
|
||||
let unreadable = false;
|
||||
const credential = readClaudeCliCredentialsCached({
|
||||
platform,
|
||||
homeDir: tempDirs.make("openclaw-claude-non-interactive-"),
|
||||
execSync: execSyncMock,
|
||||
allowKeychainPrompt: false,
|
||||
tryKeychainWithoutPrompt: true,
|
||||
ttlMs: 0,
|
||||
onStoredCredentialUnreadable: () => {
|
||||
unreadable = true;
|
||||
},
|
||||
});
|
||||
return { credential, unreadable };
|
||||
}
|
||||
|
||||
function mockReadableKeychainCredential() {
|
||||
execSyncMock.mockReturnValue(
|
||||
JSON.stringify({
|
||||
claudeAiOauth: {
|
||||
accessToken: "test-access",
|
||||
refreshToken: "test-refresh",
|
||||
expiresAt: Date.now() + 60_000,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
it("reads an already-authorized Claude keychain credential during non-interactive setup", () => {
|
||||
mockReadableKeychainCredential();
|
||||
|
||||
expect(readNonInteractiveClaudeCredential("darwin")).toEqual({
|
||||
credential: expect.objectContaining({
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
refresh: "test-refresh",
|
||||
}),
|
||||
unreadable: false,
|
||||
});
|
||||
expect(execSyncMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining(" -w"),
|
||||
expect.objectContaining({ timeout: 2_000 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a present but unreadable Claude keychain credential", () => {
|
||||
execSyncMock.mockImplementation((command: string) => {
|
||||
if (command.includes(" -w")) {
|
||||
throw new Error("User interaction is not allowed");
|
||||
}
|
||||
return "keychain metadata";
|
||||
});
|
||||
|
||||
expect(readNonInteractiveClaudeCredential("darwin")).toEqual({
|
||||
credential: null,
|
||||
unreadable: true,
|
||||
});
|
||||
expect(execSyncMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.not.stringContaining(" -w"),
|
||||
expect.objectContaining({ timeout: 2_000 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports missing Claude CLI auth when neither keychain nor file credentials exist", () => {
|
||||
execSyncMock.mockImplementation(() => {
|
||||
throw new Error("item not found");
|
||||
});
|
||||
|
||||
expect(readNonInteractiveClaudeCredential("darwin")).toEqual({
|
||||
credential: null,
|
||||
unreadable: false,
|
||||
});
|
||||
expect(execSyncMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("keeps non-darwin non-interactive Claude auth on the file path", () => {
|
||||
mockReadableKeychainCredential();
|
||||
|
||||
expect(readNonInteractiveClaudeCredential("linux")).toEqual({
|
||||
credential: null,
|
||||
unreadable: false,
|
||||
});
|
||||
expect(execSyncMock).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1,35 +0,0 @@
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
const CLAUDE_CLI_KEYCHAIN_SERVICE = "Claude Code-credentials";
|
||||
export const CLAUDE_CLI_KEYCHAIN_TIMEOUT_MS = 2_000;
|
||||
|
||||
type ExecSyncFn = typeof execSync;
|
||||
|
||||
export function readClaudeCliKeychainPayload(
|
||||
execSyncImpl: ExecSyncFn = execSync,
|
||||
timeout = 5000,
|
||||
): Record<string, unknown> | null {
|
||||
try {
|
||||
const result = execSyncImpl(
|
||||
`security find-generic-password -s "${CLAUDE_CLI_KEYCHAIN_SERVICE}" -w`,
|
||||
{ encoding: "utf8", timeout, stdio: ["pipe", "pipe", "pipe"] },
|
||||
);
|
||||
const parsed = JSON.parse(result.trim());
|
||||
return parsed && typeof parsed === "object" ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function hasClaudeCliKeychainItem(execSyncImpl: ExecSyncFn = execSync): boolean {
|
||||
try {
|
||||
execSyncImpl(`security find-generic-password -s "${CLAUDE_CLI_KEYCHAIN_SERVICE}"`, {
|
||||
encoding: "utf8",
|
||||
timeout: CLAUDE_CLI_KEYCHAIN_TIMEOUT_MS,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -3,27 +3,15 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
|
||||
|
||||
const execSyncMock = vi.fn();
|
||||
const CLI_CREDENTIALS_CACHE_TTL_MS = 15 * 60 * 1000;
|
||||
let readClaudeCliCredentialsCached: typeof import("./cli-credentials.js").readClaudeCliCredentialsCached;
|
||||
let readCodexCliActiveApiKey: typeof import("./cli-credentials.js").readCodexCliActiveApiKey;
|
||||
let readCodexCliCredentialsCached: typeof import("./cli-credentials.js").readCodexCliCredentialsCached;
|
||||
let readGeminiCliCredentialsCached: typeof import("./cli-credentials.js").readGeminiCliCredentialsCached;
|
||||
let readMiniMaxCliCredentialsCached: typeof import("./cli-credentials.js").readMiniMaxCliCredentialsCached;
|
||||
let readCodexAuth: typeof import("./cli-auth.test-support.js").readCodexAuth;
|
||||
let resetCliAuthCaches: typeof import("./cli-auth.test-support.js").resetCliAuthCaches;
|
||||
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
|
||||
async function readCachedClaudeCliCredentials(allowKeychainPrompt: boolean) {
|
||||
return readClaudeCliCredentialsCached({
|
||||
allowKeychainPrompt,
|
||||
ttlMs: CLI_CREDENTIALS_CACHE_TTL_MS,
|
||||
platform: "darwin",
|
||||
execSync: execSyncMock,
|
||||
});
|
||||
}
|
||||
|
||||
function createJwtWithExp(expSeconds: number): string {
|
||||
// Signature verification is out of scope; expiration extraction only needs a
|
||||
@@ -33,20 +21,6 @@ function createJwtWithExp(expSeconds: number): string {
|
||||
return `${encode({ alg: "RS256", typ: "JWT" })}.${encode({ exp: expSeconds })}.signature`;
|
||||
}
|
||||
|
||||
function mockClaudeCliCredentialRead() {
|
||||
execSyncMock.mockImplementation(() =>
|
||||
JSON.stringify({
|
||||
claudeAiOauth: {
|
||||
accessToken: `token-${Date.now()}`,
|
||||
refreshToken: "cached-refresh",
|
||||
expiresAt: Date.now() + 60_000,
|
||||
subscriptionType: "max",
|
||||
rateLimitTier: "default_max_20x",
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function expectFields(value: unknown, expected: Record<string, unknown>): void {
|
||||
// Keeps large credential objects readable while still asserting exact fields
|
||||
// relevant to the branch under test.
|
||||
@@ -62,7 +36,6 @@ function expectFields(value: unknown, expected: Record<string, unknown>): void {
|
||||
describe("cli credentials", () => {
|
||||
beforeAll(async () => {
|
||||
({
|
||||
readClaudeCliCredentialsCached,
|
||||
readCodexCliActiveApiKey,
|
||||
readCodexCliCredentialsCached,
|
||||
readGeminiCliCredentialsCached,
|
||||
@@ -93,16 +66,6 @@ describe("cli credentials", () => {
|
||||
delete process.env.CODEX_HOME;
|
||||
try {
|
||||
const files = [
|
||||
{
|
||||
filePath: path.join(osHome, ".claude", ".credentials.json"),
|
||||
value: {
|
||||
claudeAiOauth: {
|
||||
accessToken: "claude-access",
|
||||
refreshToken: "claude-refresh",
|
||||
expiresAt: expires,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
filePath: path.join(osHome, ".codex", "auth.json"),
|
||||
value: {
|
||||
@@ -134,16 +97,6 @@ describe("cli credentials", () => {
|
||||
fs.writeFileSync(file.filePath, JSON.stringify(file.value), "utf8");
|
||||
}
|
||||
const decoys = [
|
||||
{
|
||||
filePath: path.join(openClawHome, ".claude", ".credentials.json"),
|
||||
value: {
|
||||
claudeAiOauth: {
|
||||
accessToken: "decoy-claude-access",
|
||||
refreshToken: "decoy-claude-refresh",
|
||||
expiresAt: expires,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
filePath: path.join(openClawHome, ".codex", "auth.json"),
|
||||
value: {
|
||||
@@ -175,14 +128,6 @@ describe("cli credentials", () => {
|
||||
fs.writeFileSync(file.filePath, JSON.stringify(file.value), "utf8");
|
||||
}
|
||||
|
||||
expectFields(
|
||||
readClaudeCliCredentialsCached({
|
||||
allowKeychainPrompt: false,
|
||||
platform: "linux",
|
||||
ttlMs: 0,
|
||||
}),
|
||||
{ access: "claude-access", refresh: "claude-refresh" },
|
||||
);
|
||||
expectFields(
|
||||
readCodexCliCredentialsCached({
|
||||
allowKeychainPrompt: false,
|
||||
@@ -205,258 +150,6 @@ describe("cli credentials", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "caches Claude Code CLI credentials within the TTL window",
|
||||
allowKeychainPromptSecondRead: true,
|
||||
advanceMs: 0,
|
||||
expectedCalls: 1,
|
||||
expectSameObject: true,
|
||||
},
|
||||
{
|
||||
name: "refreshes Claude Code CLI credentials after the TTL window",
|
||||
allowKeychainPromptSecondRead: true,
|
||||
advanceMs: CLI_CREDENTIALS_CACHE_TTL_MS + 1,
|
||||
expectedCalls: 2,
|
||||
expectSameObject: false,
|
||||
},
|
||||
] as const)(
|
||||
"$name",
|
||||
async ({ allowKeychainPromptSecondRead, advanceMs, expectedCalls, expectSameObject }) => {
|
||||
mockClaudeCliCredentialRead();
|
||||
vi.setSystemTime(new Date("2025-01-01T00:00:00Z"));
|
||||
|
||||
const first = await readCachedClaudeCliCredentials(true);
|
||||
if (advanceMs > 0) {
|
||||
vi.advanceTimersByTime(advanceMs);
|
||||
}
|
||||
const second = await readCachedClaudeCliCredentials(allowKeychainPromptSecondRead);
|
||||
|
||||
if (!first || !second) {
|
||||
throw new Error("expected cached Claude CLI credentials to be available");
|
||||
}
|
||||
expectFields(first, {
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: "token-1735689600000",
|
||||
refresh: "cached-refresh",
|
||||
subscriptionType: "max",
|
||||
rateLimitTier: "default_max_20x",
|
||||
});
|
||||
expectFields(second, {
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: expectSameObject ? "token-1735689600000" : "token-1735690500001",
|
||||
refresh: "cached-refresh",
|
||||
});
|
||||
if (expectSameObject) {
|
||||
expect(second).toEqual(first);
|
||||
} else {
|
||||
expect(second).not.toEqual(first);
|
||||
}
|
||||
expect(execSyncMock).toHaveBeenCalledTimes(expectedCalls);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not let no-keychain Claude cache misses poison keychain reads", () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-claude-cache-"));
|
||||
vi.setSystemTime(new Date("2025-01-01T00:00:00Z"));
|
||||
|
||||
const withoutKeychain = readClaudeCliCredentialsCached({
|
||||
allowKeychainPrompt: false,
|
||||
ttlMs: CLI_CREDENTIALS_CACHE_TTL_MS,
|
||||
platform: "darwin",
|
||||
homeDir: tempDir,
|
||||
execSync: execSyncMock,
|
||||
});
|
||||
|
||||
expect(withoutKeychain).toBeNull();
|
||||
expect(execSyncMock).not.toHaveBeenCalled();
|
||||
|
||||
mockClaudeCliCredentialRead();
|
||||
const withKeychain = readClaudeCliCredentialsCached({
|
||||
allowKeychainPrompt: true,
|
||||
ttlMs: CLI_CREDENTIALS_CACHE_TTL_MS,
|
||||
platform: "darwin",
|
||||
homeDir: tempDir,
|
||||
execSync: execSyncMock,
|
||||
});
|
||||
|
||||
expectFields(withKeychain, {
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
refresh: "cached-refresh",
|
||||
});
|
||||
expect(execSyncMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
function claudeAccessFixture(): string {
|
||||
return ["claude", "access"].join("-");
|
||||
}
|
||||
|
||||
function claudeRefreshFixture(): string {
|
||||
return ["claude", "refresh"].join("-");
|
||||
}
|
||||
|
||||
it("attaches the CLI config account email to Claude credentials", () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-claude-email-"));
|
||||
const expires = Date.parse("2036-04-25T12:00:00Z");
|
||||
fs.mkdirSync(path.join(tempDir, ".claude"), { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, ".claude", ".credentials.json"),
|
||||
JSON.stringify({
|
||||
claudeAiOauth: {
|
||||
accessToken: claudeAccessFixture(),
|
||||
refreshToken: claudeRefreshFixture(),
|
||||
expiresAt: expires,
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, ".claude.json"),
|
||||
JSON.stringify({ oauthAccount: { emailAddress: "cli-login@example.com" } }),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const cliLogin = readClaudeCliCredentialsCached({
|
||||
allowKeychainPrompt: false,
|
||||
ttlMs: 0,
|
||||
platform: "darwin",
|
||||
homeDir: tempDir,
|
||||
execSync: execSyncMock,
|
||||
});
|
||||
|
||||
expectFields(cliLogin, {
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: claudeAccessFixture(),
|
||||
email: "cli-login@example.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves Claude credentials email-less without the CLI config file", () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-claude-email-"));
|
||||
const expires = Date.parse("2036-04-25T12:00:00Z");
|
||||
fs.mkdirSync(path.join(tempDir, ".claude"), { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, ".claude", ".credentials.json"),
|
||||
JSON.stringify({
|
||||
claudeAiOauth: {
|
||||
accessToken: claudeAccessFixture(),
|
||||
refreshToken: claudeRefreshFixture(),
|
||||
expiresAt: expires,
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const cliLogin = readClaudeCliCredentialsCached({
|
||||
allowKeychainPrompt: false,
|
||||
ttlMs: 0,
|
||||
platform: "darwin",
|
||||
homeDir: tempDir,
|
||||
execSync: execSyncMock,
|
||||
});
|
||||
|
||||
expectFields(cliLogin, { type: "oauth", provider: "anthropic", access: claudeAccessFixture() });
|
||||
expect(cliLogin && "email" in cliLogin ? cliLogin.email : undefined).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps runtime-style no-prompt Claude reads on the file credential path", () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-claude-cache-"));
|
||||
vi.setSystemTime(new Date("2025-01-01T00:00:00Z"));
|
||||
mockClaudeCliCredentialRead();
|
||||
|
||||
const withKeychain = readClaudeCliCredentialsCached({
|
||||
allowKeychainPrompt: true,
|
||||
ttlMs: CLI_CREDENTIALS_CACHE_TTL_MS,
|
||||
platform: "darwin",
|
||||
homeDir: tempDir,
|
||||
execSync: execSyncMock,
|
||||
});
|
||||
const withoutPrompt = readClaudeCliCredentialsCached({
|
||||
allowKeychainPrompt: false,
|
||||
ttlMs: CLI_CREDENTIALS_CACHE_TTL_MS,
|
||||
platform: "darwin",
|
||||
homeDir: tempDir,
|
||||
execSync: execSyncMock,
|
||||
});
|
||||
|
||||
expectFields(withKeychain, {
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
refresh: "cached-refresh",
|
||||
});
|
||||
expect(withoutPrompt).toBeNull();
|
||||
expect(execSyncMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("recognizes Claude Code user apiKeyHelper settings as CLI-managed auth", () => {
|
||||
const tempDir = tempDirs.make("openclaw-claude-settings-");
|
||||
const settingsDir = path.join(tempDir, ".claude");
|
||||
fs.mkdirSync(settingsDir, { recursive: true });
|
||||
|
||||
const options = {
|
||||
allowKeychainPrompt: false,
|
||||
ttlMs: CLI_CREDENTIALS_CACHE_TTL_MS,
|
||||
platform: "linux" as const,
|
||||
homeDir: tempDir,
|
||||
execSync: execSyncMock,
|
||||
};
|
||||
expect(readClaudeCliCredentialsCached(options)).toBeNull();
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(settingsDir, "settings.json"),
|
||||
JSON.stringify({ apiKeyHelper: "test-api-key-helper" }),
|
||||
);
|
||||
|
||||
const result = readClaudeCliCredentialsCached(options);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: "api_key_helper",
|
||||
provider: "anthropic",
|
||||
helperHash: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
});
|
||||
expect(execSyncMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prefers Claude Code user apiKeyHelper settings over stored Claude credentials", () => {
|
||||
const tempDir = tempDirs.make("openclaw-claude-helper-first-");
|
||||
const settingsDir = path.join(tempDir, ".claude");
|
||||
fs.mkdirSync(settingsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(settingsDir, "settings.json"),
|
||||
JSON.stringify({ apiKeyHelper: "test-api-key-helper" }),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(settingsDir, ".credentials.json"),
|
||||
JSON.stringify({
|
||||
claudeAiOauth: {
|
||||
accessToken: "test-access-token",
|
||||
refreshToken: "test-refresh-token",
|
||||
expiresAt: Date.parse("2099-01-01T00:00:00Z"),
|
||||
},
|
||||
}),
|
||||
);
|
||||
mockClaudeCliCredentialRead();
|
||||
|
||||
const result = readClaudeCliCredentialsCached({
|
||||
allowKeychainPrompt: true,
|
||||
ttlMs: CLI_CREDENTIALS_CACHE_TTL_MS,
|
||||
platform: "darwin",
|
||||
homeDir: tempDir,
|
||||
execSync: execSyncMock,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
type: "api_key_helper",
|
||||
provider: "anthropic",
|
||||
helperHash: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
});
|
||||
expect(execSyncMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reads Codex credentials from keychain when available", () => {
|
||||
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-codex-"));
|
||||
process.env.CODEX_HOME = tempHome;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Reads and refreshes credentials stored by external CLI runtimes such as
|
||||
* Claude Code, Codex, Gemini, and MiniMax.
|
||||
* Codex, Gemini, and MiniMax.
|
||||
*/
|
||||
import { execSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
@@ -12,18 +12,8 @@ import {
|
||||
} from "@openclaw/normalization-core/number-coercion";
|
||||
import { resolveOsHomeRelativePath } from "../infra/home-dir.js";
|
||||
import { loadJsonFileThroughSymlink } from "../infra/json-file.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import type { OAuthProvider } from "./auth-profiles/types.js";
|
||||
import {
|
||||
CLAUDE_CLI_KEYCHAIN_TIMEOUT_MS,
|
||||
hasClaudeCliKeychainItem,
|
||||
readClaudeCliKeychainPayload,
|
||||
} from "./cli-credentials.claude-keychain.js";
|
||||
|
||||
const log = createSubsystemLogger("agents/auth-profiles");
|
||||
|
||||
const CLAUDE_CLI_CREDENTIALS_RELATIVE_PATH = ".claude/.credentials.json";
|
||||
const CLAUDE_CLI_USER_SETTINGS_RELATIVE_PATH = ".claude/settings.json";
|
||||
const CODEX_CLI_AUTH_FILENAME = "auth.json";
|
||||
const MINIMAX_CLI_CREDENTIALS_RELATIVE_PATH = ".minimax/oauth_creds.json";
|
||||
const GEMINI_CLI_CREDENTIALS_RELATIVE_PATH = ".gemini/oauth_creds.json";
|
||||
@@ -36,46 +26,17 @@ type CachedValue<T> = {
|
||||
sourceFingerprint?: number | string | null;
|
||||
};
|
||||
|
||||
let claudeCliCache: CachedValue<ClaudeCliCredential> | null = null;
|
||||
let codexCliCache: CachedValue<CodexCliCredential> | null = null;
|
||||
let minimaxCliCache: CachedValue<MiniMaxCliCredential> | null = null;
|
||||
let geminiCliCache: CachedValue<GeminiCliCredential> | null = null;
|
||||
|
||||
/** Clears in-memory CLI credential caches for isolated tests. */
|
||||
function resetCliCredentialCachesForTest(): void {
|
||||
claudeCliCache = null;
|
||||
codexCliCache = null;
|
||||
minimaxCliCache = null;
|
||||
geminiCliCache = null;
|
||||
}
|
||||
|
||||
/** Credential shape parsed from Claude Code CLI storage. */
|
||||
export type ClaudeCliCredential =
|
||||
| {
|
||||
type: "oauth";
|
||||
provider: "anthropic";
|
||||
access: string;
|
||||
refresh: string;
|
||||
expires: number;
|
||||
subscriptionType?: string;
|
||||
rateLimitTier?: string;
|
||||
email?: string;
|
||||
}
|
||||
| {
|
||||
type: "token";
|
||||
provider: "anthropic";
|
||||
token: string;
|
||||
expires: number;
|
||||
subscriptionType?: string;
|
||||
rateLimitTier?: string;
|
||||
email?: string;
|
||||
}
|
||||
| {
|
||||
type: "api_key_helper";
|
||||
provider: "anthropic";
|
||||
helperHash: string;
|
||||
};
|
||||
|
||||
/** Credential shape parsed from Codex CLI storage. */
|
||||
export type CodexCliCredential = {
|
||||
type: "oauth";
|
||||
@@ -116,66 +77,6 @@ export type GeminiCliCredential = {
|
||||
|
||||
type ExecSyncFn = typeof execSync;
|
||||
|
||||
function resolveClaudeCliCredentialsPath(homeDir?: string) {
|
||||
const baseDir = resolveOsHomeRelativePath(homeDir ?? "~");
|
||||
return path.join(baseDir, CLAUDE_CLI_CREDENTIALS_RELATIVE_PATH);
|
||||
}
|
||||
|
||||
function resolveClaudeCliUserSettingsPath(homeDir?: string) {
|
||||
// Managed Claude CLI launches clear CLAUDE_CONFIG_DIR, so auth discovery
|
||||
// inspects the canonical user settings tree that the child will use.
|
||||
const baseDir = resolveOsHomeRelativePath(homeDir ?? "~");
|
||||
return path.join(baseDir, CLAUDE_CLI_USER_SETTINGS_RELATIVE_PATH);
|
||||
}
|
||||
|
||||
function parseClaudeCliOauthCredential(claudeOauth: unknown): ClaudeCliCredential | null {
|
||||
if (!claudeOauth || typeof claudeOauth !== "object") {
|
||||
return null;
|
||||
}
|
||||
const data = claudeOauth as Record<string, unknown>;
|
||||
const accessToken = data.accessToken;
|
||||
const refreshToken = data.refreshToken;
|
||||
const expiresAt = data.expiresAt;
|
||||
// Plan metadata (e.g. subscriptionType "max", rateLimitTier "default_max_20x")
|
||||
// lets usage surfaces label subscription windows without another API call.
|
||||
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 planFields = {
|
||||
...(subscriptionType ? { subscriptionType } : {}),
|
||||
...(rateLimitTier ? { rateLimitTier } : {}),
|
||||
};
|
||||
|
||||
if (typeof accessToken !== "string" || !accessToken) {
|
||||
return null;
|
||||
}
|
||||
if (typeof expiresAt !== "number" || !Number.isFinite(expiresAt) || expiresAt <= 0) {
|
||||
return null;
|
||||
}
|
||||
if (typeof refreshToken === "string" && refreshToken) {
|
||||
return {
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: accessToken,
|
||||
refresh: refreshToken,
|
||||
expires: expiresAt,
|
||||
...planFields,
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: "token",
|
||||
provider: "anthropic",
|
||||
token: accessToken,
|
||||
expires: expiresAt,
|
||||
...planFields,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveCodexCliHomePath(codexHome?: string, env: NodeJS.ProcessEnv = process.env) {
|
||||
const configured = codexHome ?? env.CODEX_HOME;
|
||||
// External CLI state belongs to the OS user, not OpenClaw's relocatable
|
||||
@@ -464,154 +365,6 @@ function readGeminiCliCredentials(options?: { homeDir?: string }): GeminiCliCred
|
||||
};
|
||||
}
|
||||
|
||||
function readClaudeCliUserApiKeyHelperCredential(homeDir?: string): ClaudeCliCredential | null {
|
||||
const raw = loadJsonFileThroughSymlink(resolveClaudeCliUserSettingsPath(homeDir));
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
return null;
|
||||
}
|
||||
const helper = (raw as Record<string, unknown>).apiKeyHelper;
|
||||
return typeof helper === "string" && helper.trim().length > 0
|
||||
? {
|
||||
type: "api_key_helper",
|
||||
provider: "anthropic",
|
||||
helperHash: createHash("sha256").update(helper.trim()).digest("hex"),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
// The CLI login flow writes the account identity to the config file next to
|
||||
// the credential store, so the pair describes one login. Capturing it here
|
||||
// keeps usage surfaces from re-reading ambient config at fetch time, where a
|
||||
// later account switch could mislabel another credential's quota.
|
||||
function readClaudeCliAccountEmail(homeDir?: string): string | undefined {
|
||||
const baseDir = resolveOsHomeRelativePath(homeDir ?? "~");
|
||||
const raw = loadJsonFileThroughSymlink(path.join(baseDir, ".claude.json"));
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const account = (raw as { oauthAccount?: unknown }).oauthAccount;
|
||||
if (!account || typeof account !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const email = (account as { emailAddress?: unknown }).emailAddress;
|
||||
return typeof email === "string" && email.trim() ? email.trim() : undefined;
|
||||
}
|
||||
|
||||
function withClaudeAccountEmail(
|
||||
cliLogin: ClaudeCliCredential | null,
|
||||
homeDir?: string,
|
||||
): ClaudeCliCredential | null {
|
||||
if (!cliLogin) {
|
||||
return null;
|
||||
}
|
||||
if (cliLogin.type === "api_key_helper") {
|
||||
return cliLogin;
|
||||
}
|
||||
const email = readClaudeCliAccountEmail(homeDir);
|
||||
return email ? { ...cliLogin, email } : cliLogin;
|
||||
}
|
||||
|
||||
/** Reads Claude CLI credentials in Claude Code's credential precedence order. */
|
||||
function readClaudeCliCredentials(options?: {
|
||||
allowKeychainPrompt?: boolean;
|
||||
tryKeychainWithoutPrompt?: boolean;
|
||||
onStoredCredentialUnreadable?: () => void;
|
||||
platform?: NodeJS.Platform;
|
||||
homeDir?: string;
|
||||
execSync?: ExecSyncFn;
|
||||
}): ClaudeCliCredential | null {
|
||||
const helperAuth = readClaudeCliUserApiKeyHelperCredential(options?.homeDir);
|
||||
if (helperAuth) {
|
||||
return helperAuth;
|
||||
}
|
||||
|
||||
const platform = options?.platform ?? process.platform;
|
||||
const tryKeychain =
|
||||
platform === "darwin" &&
|
||||
(options?.allowKeychainPrompt !== false || options?.tryKeychainWithoutPrompt === true);
|
||||
if (tryKeychain) {
|
||||
const keychainPayload = readClaudeCliKeychainPayload(
|
||||
options?.execSync,
|
||||
options?.tryKeychainWithoutPrompt ? CLAUDE_CLI_KEYCHAIN_TIMEOUT_MS : undefined,
|
||||
);
|
||||
const keychainCreds = parseClaudeCliOauthCredential(keychainPayload?.claudeAiOauth);
|
||||
if (keychainCreds) {
|
||||
log.info("read anthropic credentials from claude cli keychain", {
|
||||
type: keychainCreds.type,
|
||||
});
|
||||
return withClaudeAccountEmail(keychainCreds, options?.homeDir);
|
||||
}
|
||||
}
|
||||
|
||||
const credPath = resolveClaudeCliCredentialsPath(options?.homeDir);
|
||||
const raw = loadJsonFileThroughSymlink(credPath);
|
||||
const fileCredential =
|
||||
raw && typeof raw === "object"
|
||||
? withClaudeAccountEmail(
|
||||
parseClaudeCliOauthCredential((raw as Record<string, unknown>).claudeAiOauth),
|
||||
options?.homeDir,
|
||||
)
|
||||
: null;
|
||||
if (fileCredential) {
|
||||
return fileCredential;
|
||||
}
|
||||
if (
|
||||
options?.tryKeychainWithoutPrompt &&
|
||||
(fs.existsSync(credPath) ||
|
||||
(platform === "darwin" && hasClaudeCliKeychainItem(options.execSync)))
|
||||
) {
|
||||
options.onStoredCredentialUnreadable?.();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
type ClaudeCliCredentialReadOptions = {
|
||||
allowKeychainPrompt?: boolean;
|
||||
tryKeychainWithoutPrompt?: boolean;
|
||||
onStoredCredentialUnreadable?: () => void;
|
||||
ttlMs?: number;
|
||||
platform?: NodeJS.Platform;
|
||||
homeDir?: string;
|
||||
execSync?: ExecSyncFn;
|
||||
};
|
||||
|
||||
/** @deprecated Anthropic provider-owned CLI credential helper; do not use from third-party plugins. */
|
||||
export function readClaudeCliCredentialsCached(
|
||||
options?: ClaudeCliCredentialReadOptions,
|
||||
): ClaudeCliCredential | null {
|
||||
const platform = options?.platform ?? process.platform;
|
||||
const ttlMs = options?.ttlMs ?? 0;
|
||||
const credentialsPath = resolveClaudeCliCredentialsPath(options?.homeDir);
|
||||
const settingsPath = resolveClaudeCliUserSettingsPath(options?.homeDir);
|
||||
const keychainIntent =
|
||||
platform !== "darwin"
|
||||
? "file"
|
||||
: options?.tryKeychainWithoutPrompt
|
||||
? "keychain-bounded"
|
||||
: options?.allowKeychainPrompt !== false
|
||||
? "keychain"
|
||||
: "file";
|
||||
return readCachedCliCredential({
|
||||
ttlMs,
|
||||
cache: claudeCliCache,
|
||||
cacheKey: `${credentialsPath}:${keychainIntent}`,
|
||||
read: () =>
|
||||
readClaudeCliCredentials({
|
||||
allowKeychainPrompt: options?.allowKeychainPrompt,
|
||||
tryKeychainWithoutPrompt: options?.tryKeychainWithoutPrompt,
|
||||
onStoredCredentialUnreadable: options?.onStoredCredentialUnreadable,
|
||||
platform,
|
||||
homeDir: options?.homeDir,
|
||||
execSync: options?.execSync,
|
||||
}),
|
||||
setCache: (next) => {
|
||||
claudeCliCache = next;
|
||||
},
|
||||
readSourceFingerprint: () =>
|
||||
`${readFileMtimeMs(credentialsPath) ?? "missing"}:${readFileMtimeMs(settingsPath) ?? "missing"}`,
|
||||
});
|
||||
}
|
||||
|
||||
function formatCodexApiKeyForLoginStatus(key: string): string {
|
||||
return key.length <= 13 ? "***" : `${key.slice(0, 8)}***${key.slice(-5)}`;
|
||||
}
|
||||
|
||||
@@ -8,15 +8,15 @@ export type BundledCliBackendAuthPolicy = {
|
||||
strictSelectedProfile: boolean;
|
||||
/** Owner responsible for refreshing selected OAuth credentials before execution. */
|
||||
oauthRefreshOwner: "core" | "cli";
|
||||
/** Provider whose imported OAuth profiles use identity-verified native passthrough. */
|
||||
nativePassthroughProviderId?: string;
|
||||
/** Retired OAuth profile identities that the native runtime owns instead. */
|
||||
nativeAuthProfileIds?: readonly string[];
|
||||
};
|
||||
|
||||
const BUNDLED_CLI_BACKEND_AUTH_POLICIES = {
|
||||
"claude-cli": {
|
||||
strictSelectedProfile: true,
|
||||
oauthRefreshOwner: "core",
|
||||
nativePassthroughProviderId: "claude-cli",
|
||||
nativeAuthProfileIds: ["anthropic:claude-cli"],
|
||||
},
|
||||
"google-gemini-cli": {
|
||||
strictSelectedProfile: false,
|
||||
|
||||
@@ -37,7 +37,6 @@ import {
|
||||
createTestAdmittedRunContext,
|
||||
createTestPreparedRunAdmission,
|
||||
} from "../admitted-run-context.test-support.js";
|
||||
import { readExternalCliBootstrapCredential as readExternalCliBootstrapCredentialImpl } from "../auth-profiles/external-cli-sync.js";
|
||||
import { resolveApiKeyForProfile as resolveApiKeyForProfileImpl } from "../auth-profiles/oauth.js";
|
||||
import {
|
||||
loadAuthProfileStoreWithoutExternalProfiles,
|
||||
@@ -457,7 +456,6 @@ describe("prepareCliRunContext", () => {
|
||||
cleanup: vi.fn(async () => undefined),
|
||||
})),
|
||||
getCliLiveSessionGeneration: vi.fn(() => undefined),
|
||||
readExternalCliBootstrapCredential: readExternalCliBootstrapCredentialImpl,
|
||||
resolveApiKeyForProfile: resolveApiKeyForProfileImpl,
|
||||
// Keep preparation off the real plugin-metadata snapshot; catalog-driven
|
||||
// cases inject their own rows.
|
||||
@@ -968,148 +966,77 @@ describe("prepareCliRunContext", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("runs an imported Claude CLI login natively without forwarding a credential", async () => {
|
||||
const { dir } = fixture.session;
|
||||
const agentDir = path.join(dir, "agents", "main", "agent");
|
||||
const authProfileId = "anthropic:claude-cli";
|
||||
const prepareExecution = vi.fn(async () => undefined);
|
||||
fs.mkdirSync(agentDir, { recursive: true });
|
||||
saveAuthProfileStore(
|
||||
{
|
||||
version: 1,
|
||||
profiles: {
|
||||
[authProfileId]: {
|
||||
type: "oauth",
|
||||
provider: "claude-cli",
|
||||
access: "expired-imported-access",
|
||||
refresh: "imported-refresh",
|
||||
expires: Date.now() - 60_000,
|
||||
email: "owner@example.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
agentDir,
|
||||
);
|
||||
setCliBackendForPrepareTest({ prepareExecution, authEpochMode: "profile-only" });
|
||||
const resolveApiKeyForProfile = vi.fn<typeof resolveApiKeyForProfileImpl>(async () => null);
|
||||
setCliRunnerPrepareTestDeps({
|
||||
resolveApiKeyForProfile,
|
||||
// Live login is expired too: expiry is Claude's to repair via its own
|
||||
// refresh token, so passthrough must not gate on it.
|
||||
readExternalCliBootstrapCredential: vi.fn(() => ({
|
||||
it.each([
|
||||
{
|
||||
label: "OAuth under the Claude CLI provider",
|
||||
credential: {
|
||||
type: "oauth" as const,
|
||||
provider: "claude-cli",
|
||||
access: "live-native-access",
|
||||
refresh: "live-native-refresh",
|
||||
expires: Date.now() - 30_000,
|
||||
access: "expired-imported-access",
|
||||
refresh: "imported-refresh",
|
||||
expires: Date.now() - 60_000,
|
||||
email: "owner@example.com",
|
||||
})),
|
||||
});
|
||||
|
||||
await fixture.prepare({
|
||||
sessionKey: "agent:main:main",
|
||||
agentDir,
|
||||
provider: "claude-cli",
|
||||
model: "sonnet",
|
||||
authProfileId,
|
||||
config: {},
|
||||
});
|
||||
|
||||
expect(prepareExecution).toHaveBeenCalledTimes(1);
|
||||
expect(prepareExecution).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ authProfileId, authCredential: undefined }),
|
||||
);
|
||||
expect(resolveApiKeyForProfile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails closed when the imported Claude CLI login is gone from the host", async () => {
|
||||
const { dir } = fixture.session;
|
||||
const agentDir = path.join(dir, "agents", "main", "agent");
|
||||
const authProfileId = "anthropic:claude-cli";
|
||||
const prepareExecution = vi.fn(async () => undefined);
|
||||
fs.mkdirSync(agentDir, { recursive: true });
|
||||
saveAuthProfileStore(
|
||||
{
|
||||
version: 1,
|
||||
profiles: {
|
||||
[authProfileId]: {
|
||||
type: "oauth",
|
||||
provider: "claude-cli",
|
||||
access: "expired-imported-access",
|
||||
refresh: "imported-refresh",
|
||||
expires: Date.now() - 60_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
agentDir,
|
||||
);
|
||||
setCliBackendForPrepareTest({ prepareExecution, authEpochMode: "profile-only" });
|
||||
setCliRunnerPrepareTestDeps({
|
||||
readExternalCliBootstrapCredential: vi.fn(() => null),
|
||||
});
|
||||
|
||||
const preparation = fixture.prepare({
|
||||
sessionKey: "agent:main:main",
|
||||
agentDir,
|
||||
provider: "claude-cli",
|
||||
model: "sonnet",
|
||||
authProfileId,
|
||||
config: {},
|
||||
});
|
||||
await expect(preparation).rejects.toThrow("no reusable Claude CLI login is available");
|
||||
await expect(preparation).rejects.toThrow("claude auth login");
|
||||
expect(prepareExecution).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails closed when the host Claude CLI login is a different account", async () => {
|
||||
const { dir } = fixture.session;
|
||||
const agentDir = path.join(dir, "agents", "main", "agent");
|
||||
const authProfileId = "anthropic:claude-cli";
|
||||
const prepareExecution = vi.fn(async () => undefined);
|
||||
fs.mkdirSync(agentDir, { recursive: true });
|
||||
saveAuthProfileStore(
|
||||
{
|
||||
version: 1,
|
||||
profiles: {
|
||||
[authProfileId]: {
|
||||
type: "oauth",
|
||||
provider: "claude-cli",
|
||||
access: "imported-access",
|
||||
refresh: "imported-refresh",
|
||||
expires: Date.now() + 60 * 60_000,
|
||||
accountId: "acct-selected",
|
||||
email: "owner@example.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
agentDir,
|
||||
);
|
||||
setCliBackendForPrepareTest({ prepareExecution, authEpochMode: "profile-only" });
|
||||
setCliRunnerPrepareTestDeps({
|
||||
readExternalCliBootstrapCredential: vi.fn(() => ({
|
||||
},
|
||||
{
|
||||
label: "OAuth under the historical Anthropic provider",
|
||||
credential: {
|
||||
type: "oauth" as const,
|
||||
provider: "claude-cli",
|
||||
access: "other-account-access",
|
||||
refresh: "other-account-refresh",
|
||||
expires: Date.now() + 60 * 60_000,
|
||||
accountId: "acct-other",
|
||||
email: "other@example.com",
|
||||
})),
|
||||
});
|
||||
provider: "anthropic",
|
||||
access: "expired-imported-access",
|
||||
refresh: "imported-refresh",
|
||||
expires: Date.now() - 60_000,
|
||||
email: "owner@example.com",
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "a historical token credential",
|
||||
credential: {
|
||||
type: "token" as const,
|
||||
provider: "anthropic",
|
||||
token: "imported-token",
|
||||
expires: Date.now() - 60_000,
|
||||
},
|
||||
},
|
||||
])(
|
||||
"runs an imported Claude CLI login stored as $label natively without forwarding a credential",
|
||||
async ({ credential }) => {
|
||||
const { dir } = fixture.session;
|
||||
const agentDir = path.join(dir, "agents", "main", "agent");
|
||||
const authProfileId = "anthropic:claude-cli";
|
||||
const prepareExecution = vi.fn(async () => undefined);
|
||||
fs.mkdirSync(agentDir, { recursive: true });
|
||||
saveAuthProfileStore(
|
||||
{
|
||||
version: 1,
|
||||
profiles: {
|
||||
[authProfileId]: credential,
|
||||
},
|
||||
},
|
||||
agentDir,
|
||||
);
|
||||
setCliBackendForPrepareTest({ prepareExecution, authEpochMode: "profile-only" });
|
||||
const resolveApiKeyForProfile = vi.fn<typeof resolveApiKeyForProfileImpl>(async () => null);
|
||||
setCliRunnerPrepareTestDeps({
|
||||
resolveApiKeyForProfile,
|
||||
});
|
||||
|
||||
const preparation = fixture.prepare({
|
||||
sessionKey: "agent:main:main",
|
||||
agentDir,
|
||||
provider: "claude-cli",
|
||||
model: "sonnet",
|
||||
authProfileId,
|
||||
config: {},
|
||||
});
|
||||
await expect(preparation).rejects.toThrow(
|
||||
"current Claude CLI login belongs to a different account",
|
||||
);
|
||||
expect(prepareExecution).not.toHaveBeenCalled();
|
||||
});
|
||||
await fixture.prepare({
|
||||
sessionKey: "agent:main:main",
|
||||
agentDir,
|
||||
provider: "claude-cli",
|
||||
model: "sonnet",
|
||||
authProfileId,
|
||||
config: {},
|
||||
});
|
||||
|
||||
expect(prepareExecution).toHaveBeenCalledTimes(1);
|
||||
expect(prepareExecution).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ authProfileId: undefined, authCredential: undefined }),
|
||||
);
|
||||
expect(resolveApiKeyForProfile).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("does not revive a selected managed credential when auth resolution returns null", async () => {
|
||||
const { dir } = fixture.session;
|
||||
|
||||
@@ -58,10 +58,6 @@ import { hasAgentRosterProperty, resolveAgentWorkspaceDir } from "../agent-scope
|
||||
import { resolveAgentDir, resolveSessionAgentIds } from "../agent-scope.js";
|
||||
import { hasUsableOAuthCredential } from "../auth-profiles/credential-state.js";
|
||||
import { externalCliDiscoveryForProviderAuth } from "../auth-profiles/external-cli-discovery.js";
|
||||
import {
|
||||
isSafeToUseExternalCliCredential,
|
||||
readExternalCliBootstrapCredential,
|
||||
} from "../auth-profiles/external-cli-sync.js";
|
||||
import { buildOAuthRefreshFailureLoginCommand } from "../auth-profiles/oauth-refresh-failure.js";
|
||||
import { resolveApiKeyForProfile } from "../auth-profiles/oauth.js";
|
||||
import { resolveAuthProfileOrder } from "../auth-profiles/order.js";
|
||||
@@ -199,7 +195,6 @@ const defaultPrepareDeps = {
|
||||
claudeCliSessionTranscriptHasContent,
|
||||
claudeCliSessionTranscriptHasOrphanedToolUse,
|
||||
getCliLiveSessionGeneration,
|
||||
readExternalCliBootstrapCredential,
|
||||
resolveApiKeyForProfile,
|
||||
loadManifestModelCatalog,
|
||||
};
|
||||
@@ -402,9 +397,7 @@ function shouldRefreshAuthProfileForExecution(params: {
|
||||
|
||||
type CliAuthProfileResolutionFailure =
|
||||
| { kind: "unmaterialized" }
|
||||
| { kind: "resolved-as-other"; resolvedProfileId: string }
|
||||
| { kind: "native-login-missing" }
|
||||
| { kind: "native-login-identity-mismatch" };
|
||||
| { kind: "resolved-as-other"; resolvedProfileId: string };
|
||||
|
||||
function describeCliAuthProfileResolutionFailure(
|
||||
profileId: string,
|
||||
@@ -413,10 +406,6 @@ function describeCliAuthProfileResolutionFailure(
|
||||
switch (failure.kind) {
|
||||
case "resolved-as-other":
|
||||
return `selected auth profile "${profileId}" resolved as "${failure.resolvedProfileId}"`;
|
||||
case "native-login-missing":
|
||||
return `selected auth profile "${profileId}" reuses the host's Claude CLI login, but no reusable Claude CLI login is available`;
|
||||
case "native-login-identity-mismatch":
|
||||
return `selected auth profile "${profileId}" reuses the host's Claude CLI login, but the current Claude CLI login belongs to a different account`;
|
||||
case "unmaterialized":
|
||||
return `could not materialize selected auth profile "${profileId}"`;
|
||||
}
|
||||
@@ -641,45 +630,15 @@ export async function prepareCliRunContext(
|
||||
authCredential = authStore.profiles[effectiveAuthProfileId];
|
||||
}
|
||||
}
|
||||
// Claude CLI-provider OAuth credentials exist only as imports of the host's
|
||||
// own `claude` login; Claude owns that single-use refresh-token family.
|
||||
// Forwarding a snapshot goes stale within hours and blocks the subprocess
|
||||
// from refreshing itself, so verify the live login matches the selected
|
||||
// identity and let Claude authenticate natively (it refreshes in place).
|
||||
const nativeClaudeCliCredential =
|
||||
backendAuthPolicy?.nativePassthroughProviderId !== undefined &&
|
||||
authCredential?.type === "oauth" &&
|
||||
authCredential.provider === backendAuthPolicy.nativePassthroughProviderId
|
||||
? authCredential
|
||||
: undefined;
|
||||
if (effectiveAuthProfileId && authStore && nativeClaudeCliCredential) {
|
||||
const authProfileId = effectiveAuthProfileId;
|
||||
const liveNativeLogin = prepareDeps.readExternalCliBootstrapCredential({
|
||||
store: authStore,
|
||||
profileId: authProfileId,
|
||||
credential: nativeClaudeCliCredential,
|
||||
});
|
||||
if (!liveNativeLogin) {
|
||||
throw buildCliAuthProfileResolutionError({
|
||||
backendId: backendResolved.id,
|
||||
profileId: authProfileId,
|
||||
provider: nativeClaudeCliCredential.provider,
|
||||
agentDir,
|
||||
failure: { kind: "native-login-missing" },
|
||||
});
|
||||
}
|
||||
if (!isSafeToUseExternalCliCredential(nativeClaudeCliCredential, liveNativeLogin)) {
|
||||
throw buildCliAuthProfileResolutionError({
|
||||
backendId: backendResolved.id,
|
||||
profileId: authProfileId,
|
||||
provider: nativeClaudeCliCredential.provider,
|
||||
agentDir,
|
||||
failure: { kind: "native-login-identity-mismatch" },
|
||||
});
|
||||
}
|
||||
// Spawn with no forwarded credential. The local-login auth epoch then keys
|
||||
// the session to the host account (identity-hashed, rotation-stable), and
|
||||
// the next store load re-adopts whatever Claude rotates.
|
||||
// Claude owns its native login and single-use refresh-token family. Never
|
||||
// preflight, refresh, or forward OpenClaw's snapshot; the installed Claude
|
||||
// process validates and refreshes its own current login.
|
||||
const usesNativeAuthProfile =
|
||||
backendAuthPolicy?.nativeAuthProfileIds !== undefined &&
|
||||
effectiveAuthProfileId !== undefined &&
|
||||
backendAuthPolicy.nativeAuthProfileIds.includes(effectiveAuthProfileId);
|
||||
if (usesNativeAuthProfile) {
|
||||
effectiveAuthProfileId = undefined;
|
||||
authCredential = undefined;
|
||||
} else if (
|
||||
effectiveAuthProfileId &&
|
||||
|
||||
@@ -13,7 +13,6 @@ const readCodexCliCredentialsCachedMock = vi.hoisted(() =>
|
||||
);
|
||||
|
||||
vi.mock("../../cli-credentials.js", () => ({
|
||||
readClaudeCliCredentialsCached: () => null,
|
||||
readCodexCliCredentialsCached: readCodexCliCredentialsCachedMock,
|
||||
readMiniMaxCliCredentialsCached: () => null,
|
||||
}));
|
||||
|
||||
@@ -19,7 +19,6 @@ import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot
|
||||
import { isValidSecretRef } from "../secrets/ref-contract.js";
|
||||
import type { PreparedAgentCredentialModes } from "./agent-auth-credential-modes.js";
|
||||
import { hasUsableOAuthCredential } from "./auth-profiles/credential-state.js";
|
||||
import { normalizeExternalCliProfileMetadata } from "./auth-profiles/external-cli-profile-metadata.js";
|
||||
import {
|
||||
listExternalCliSyncProviderIds,
|
||||
resolveExternalCliAuthProfiles,
|
||||
@@ -302,34 +301,7 @@ export function createModelAuthAvailabilityResolver(
|
||||
...external.map((profile) => profile.profileId),
|
||||
...getRuntimeExternalCliProfileIds(runtimeStore ?? store),
|
||||
]);
|
||||
// Runtime-owned CLI credentials are authoritative over legacy config metadata
|
||||
// that described their canonical profile slots before OAuth was imported.
|
||||
// Normalize only those exact marked profiles for read-only selection.
|
||||
let readOnlyAuthProfiles:
|
||||
| NonNullable<NonNullable<OpenClawConfig["auth"]>["profiles"]>
|
||||
| undefined;
|
||||
for (const profileId of externalCliRefreshProfileIds) {
|
||||
const credential = (runtimeStore ?? store).profiles[profileId];
|
||||
const configured = params.cfg.auth?.profiles?.[profileId];
|
||||
const canonicalMetadata = normalizeExternalCliProfileMetadata(profileId, configured);
|
||||
if (
|
||||
credential?.type !== "oauth" ||
|
||||
!configured ||
|
||||
!canonicalMetadata ||
|
||||
(configured.provider === canonicalMetadata.provider &&
|
||||
configured.mode === canonicalMetadata.mode)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
readOnlyAuthProfiles ??= { ...params.cfg.auth?.profiles };
|
||||
readOnlyAuthProfiles[profileId] = {
|
||||
...configured,
|
||||
...canonicalMetadata,
|
||||
};
|
||||
}
|
||||
const readOnlyAuthConfig = readOnlyAuthProfiles
|
||||
? { ...params.cfg, auth: { ...params.cfg.auth, profiles: readOnlyAuthProfiles } }
|
||||
: params.cfg;
|
||||
const readOnlyAuthConfig = params.cfg;
|
||||
const providerConfig = (provider: string) =>
|
||||
resolveMergedModelProviderConfig(params.cfg, provider);
|
||||
const prepareAuthTarget = (provider: string, ref: ModelAuthAvailabilityRef): AuthTarget => {
|
||||
|
||||
@@ -13,7 +13,6 @@ const mocks = vi.hoisted(() => ({
|
||||
() => null,
|
||||
),
|
||||
resolveEnvApiKey: vi.fn<() => { apiKey: string; source: string } | null>(() => null),
|
||||
readClaudeCliCredentialsCached: vi.fn<(options?: unknown) => unknown>(() => null),
|
||||
readCodexCliCredentialsCached: vi.fn<(options?: unknown) => unknown>(() => null),
|
||||
}));
|
||||
|
||||
@@ -32,7 +31,6 @@ vi.mock("./model-auth.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./cli-credentials.js", () => ({
|
||||
readClaudeCliCredentialsCached: mocks.readClaudeCliCredentialsCached,
|
||||
readCodexCliCredentialsCached: mocks.readCodexCliCredentialsCached,
|
||||
}));
|
||||
|
||||
@@ -50,8 +48,6 @@ describe("resolveModelAuthLabel", () => {
|
||||
mocks.resolveUsableCustomProviderApiKey.mockReturnValue(null);
|
||||
mocks.resolveEnvApiKey.mockReset();
|
||||
mocks.resolveEnvApiKey.mockReturnValue(null);
|
||||
mocks.readClaudeCliCredentialsCached.mockReset();
|
||||
mocks.readClaudeCliCredentialsCached.mockReturnValue(null);
|
||||
mocks.readCodexCliCredentialsCached.mockReset();
|
||||
mocks.readCodexCliCredentialsCached.mockReturnValue(null);
|
||||
});
|
||||
@@ -225,54 +221,18 @@ describe("resolveModelAuthLabel", () => {
|
||||
expect(mocks.resolveEnvApiKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows claude cli auth for claude-cli provider without auth profiles", () => {
|
||||
it("shows native Claude CLI auth without reading credential storage", () => {
|
||||
mocks.ensureAuthProfileStore.mockReturnValue({
|
||||
version: 1,
|
||||
profiles: {},
|
||||
} as never);
|
||||
mocks.resolveAuthProfileOrder.mockReturnValue([]);
|
||||
mocks.readClaudeCliCredentialsCached.mockReturnValue({
|
||||
type: "oauth",
|
||||
provider: "claude-cli",
|
||||
access: "token",
|
||||
refresh: "refresh",
|
||||
expires: Date.now() + 60_000,
|
||||
});
|
||||
|
||||
const label = resolveModelAuthLabel({
|
||||
provider: "claude-cli",
|
||||
cfg: {},
|
||||
});
|
||||
|
||||
expect(label).toBe("oauth (claude-cli)");
|
||||
expect(mocks.readClaudeCliCredentialsCached).toHaveBeenCalledWith({
|
||||
ttlMs: 5_000,
|
||||
allowKeychainPrompt: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("shows claude cli apiKeyHelper auth without calling it oauth", () => {
|
||||
mocks.ensureAuthProfileStore.mockReturnValue({
|
||||
version: 1,
|
||||
profiles: {},
|
||||
} as never);
|
||||
mocks.resolveAuthProfileOrder.mockReturnValue([]);
|
||||
mocks.readClaudeCliCredentialsCached.mockReturnValue({
|
||||
type: "api_key_helper",
|
||||
provider: "anthropic",
|
||||
helperHash: "helper-hash",
|
||||
});
|
||||
|
||||
const label = resolveModelAuthLabel({
|
||||
provider: "claude-cli",
|
||||
cfg: {},
|
||||
});
|
||||
|
||||
expect(label).toBe("api-key-helper (claude-cli)");
|
||||
expect(mocks.readClaudeCliCredentialsCached).toHaveBeenCalledWith({
|
||||
ttlMs: 5_000,
|
||||
allowKeychainPrompt: false,
|
||||
});
|
||||
expect(label).toBe("native (claude-cli)");
|
||||
});
|
||||
|
||||
it("can skip external auth profile overlays for status labels", () => {
|
||||
|
||||
@@ -13,10 +13,7 @@ import {
|
||||
resolveAuthProfileOrder,
|
||||
} from "./auth-profiles.js";
|
||||
import { isStoredCredentialCompatibleWithAuthProvider } from "./auth-profiles/order.js";
|
||||
import {
|
||||
readClaudeCliCredentialsCached,
|
||||
readCodexCliCredentialsCached,
|
||||
} from "./cli-credentials.js";
|
||||
import { readCodexCliCredentialsCached } from "./cli-credentials.js";
|
||||
import {
|
||||
resolveEnvApiKey,
|
||||
resolveProviderEntryApiKeyProfileReference,
|
||||
@@ -149,16 +146,7 @@ export function resolveModelAuthLabel(params: {
|
||||
return "oauth (codex-cli)";
|
||||
}
|
||||
if (providerKey === "claude-cli") {
|
||||
const auth = readClaudeCliCredentialsCached({
|
||||
ttlMs: 5_000,
|
||||
allowKeychainPrompt: false,
|
||||
});
|
||||
if (auth?.type === "api_key_helper") {
|
||||
return "api-key-helper (claude-cli)";
|
||||
}
|
||||
if (auth) {
|
||||
return "oauth (claude-cli)";
|
||||
}
|
||||
return "native (claude-cli)";
|
||||
}
|
||||
|
||||
const customKey = resolveUsableCustomProviderApiKey({
|
||||
|
||||
@@ -84,9 +84,7 @@ describe("model auth markers", () => {
|
||||
it("reads bundled plugin-owned non-secret markers from manifests", () => {
|
||||
withEnv(cleanPluginManifestEnv(), () => {
|
||||
expect(isNonSecretApiKeyMarker("codex-app-server")).toBe(true);
|
||||
expect(isNonSecretApiKeyMarker(["openclaw", "claude-cli-api-key-helper"].join(":"))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isNonSecretApiKeyMarker(["openclaw", "claude-cli-native-auth"].join(":"))).toBe(true);
|
||||
expect(isNonSecretApiKeyMarker("gcp-vertex-credentials")).toBe(true);
|
||||
expect(isNonSecretApiKeyMarker("lmstudio-local")).toBe(true);
|
||||
expect(isNonSecretApiKeyMarker("minimax-oauth")).toBe(true);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import {
|
||||
buildProviderMissingAuthMessageWithPlugin,
|
||||
resolveProviderDeprecatedAuthProfileIds,
|
||||
shouldDeferProviderSyntheticProfileAuthWithPlugin,
|
||||
} from "../plugins/provider-runtime.js";
|
||||
import { resolveOwningPluginIdsForProviderRef } from "../plugins/providers.js";
|
||||
@@ -38,6 +39,27 @@ export type ProviderCredentialPrecedence = "profile-first" | "env-first";
|
||||
|
||||
const log = createSubsystemLogger("model-auth");
|
||||
|
||||
function isAuthProfileRetired(params: {
|
||||
profileId: string;
|
||||
deprecatedProfileIds: ReadonlySet<string>;
|
||||
provider: string;
|
||||
store: AuthProfileStore;
|
||||
}): boolean {
|
||||
if (!params.deprecatedProfileIds.has(params.profileId)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function assertAuthProfileNotRetired(params: Parameters<typeof isAuthProfileRetired>[0]): void {
|
||||
if (!isAuthProfileRetired(params)) {
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
`Auth profile "${params.profileId}" is retired. Run ${formatCliCommand("openclaw doctor --fix")}.`,
|
||||
);
|
||||
}
|
||||
|
||||
function shouldDeferSyntheticProfileAuth(params: {
|
||||
cfg: OpenClawConfig | undefined;
|
||||
provider: string;
|
||||
@@ -96,6 +118,11 @@ export async function resolveApiKeyForProviderCore(params: {
|
||||
secretSentinels?: boolean;
|
||||
}): Promise<ResolvedProviderAuth> {
|
||||
const { provider, cfg, profileId, preferredProfile } = params;
|
||||
let deprecatedProfileIds: ReadonlySet<string> | undefined;
|
||||
const getDeprecatedProfileIds = () =>
|
||||
(deprecatedProfileIds ??= new Set(
|
||||
resolveProviderDeprecatedAuthProfileIds({ provider, config: cfg }),
|
||||
));
|
||||
const agentDir = params.agentDir?.trim() || (cfg ? resolveDefaultAgentDir(cfg) : undefined);
|
||||
// Pending credential files own this agent's auth route until Doctor commits
|
||||
// and archives them; do not fall through to env/config credentials.
|
||||
@@ -123,6 +150,12 @@ export async function resolveApiKeyForProviderCore(params: {
|
||||
return awsSdkProfileAuth;
|
||||
}
|
||||
const store = getScopedStore(profileId);
|
||||
assertAuthProfileNotRetired({
|
||||
profileId,
|
||||
deprecatedProfileIds: getDeprecatedProfileIds(),
|
||||
provider,
|
||||
store,
|
||||
});
|
||||
const configuredProfileType = store.profiles[profileId]?.type;
|
||||
if (configuredProfileType) {
|
||||
assertAuthModeAllowedForModel({
|
||||
@@ -269,6 +302,19 @@ export async function resolveApiKeyForProviderCore(params: {
|
||||
// Matched profile references are terminal so bad bindings cannot silently
|
||||
// fall through to a different credential or to the profile id as bearer text.
|
||||
const providerEntryStore = getScopedStore();
|
||||
const providerEntryReference = authConfig.resolveProviderEntryApiKeyProfileReference({
|
||||
cfg,
|
||||
provider,
|
||||
store: providerEntryStore,
|
||||
});
|
||||
if ("profileId" in providerEntryReference) {
|
||||
assertAuthProfileNotRetired({
|
||||
profileId: providerEntryReference.profileId,
|
||||
deprecatedProfileIds: getDeprecatedProfileIds(),
|
||||
provider,
|
||||
store: providerEntryStore,
|
||||
});
|
||||
}
|
||||
const providerEntryBinding = await authConfig.resolveProviderEntryApiKeyBinding({
|
||||
cfg,
|
||||
provider,
|
||||
@@ -371,7 +417,15 @@ export async function resolveApiKeyForProviderCore(params: {
|
||||
provider,
|
||||
preferredProfile,
|
||||
forModel: params.modelId,
|
||||
});
|
||||
}).filter(
|
||||
(candidateProfileId) =>
|
||||
!isAuthProfileRetired({
|
||||
profileId: candidateProfileId,
|
||||
deprecatedProfileIds: getDeprecatedProfileIds(),
|
||||
provider,
|
||||
store,
|
||||
}),
|
||||
);
|
||||
let deferredAuthProfileResult: ResolvedProviderAuth | null = null;
|
||||
let refreshFailure: OAuthRefreshFailureError | undefined;
|
||||
for (const candidate of order) {
|
||||
|
||||
@@ -15,9 +15,9 @@ import type {
|
||||
AuthProfileCredential,
|
||||
AuthProfileStore,
|
||||
OAuthCredential,
|
||||
RuntimeAuthProfileStore,
|
||||
} from "./auth-profiles/types.js";
|
||||
import { resolveInlineProviderApiKeyUsageId } from "./auth-profiles/usage.js";
|
||||
import type { ClaudeCliCredential } from "./cli-credentials.js";
|
||||
import {
|
||||
createRuntimeProviderAuthLookup,
|
||||
getApiKeyForModelCore,
|
||||
@@ -218,6 +218,8 @@ vi.mock("../plugins/provider-runtime.js", () => ({
|
||||
},
|
||||
formatProviderAuthProfileApiKeyWithPlugin: async () => undefined,
|
||||
refreshProviderOAuthCredentialWithPlugin: async () => null,
|
||||
resolveProviderDeprecatedAuthProfileIds: ({ provider }: { provider: string }) =>
|
||||
provider === "anthropic" || provider === "claude-cli" ? ["anthropic:claude-cli"] : [],
|
||||
resolveProviderSyntheticAuthWithPlugin: (params: {
|
||||
provider: string;
|
||||
context: { providerConfig?: { api?: string; baseUrl?: string; models?: unknown[] } };
|
||||
@@ -257,9 +259,6 @@ vi.mock("../plugins/providers.js", () => ({
|
||||
}));
|
||||
|
||||
const cliCredentialMocks = vi.hoisted(() => ({
|
||||
readClaudeCliCredentialsCached: vi.fn<(options?: unknown) => ClaudeCliCredential | null>(
|
||||
() => null,
|
||||
),
|
||||
readCodexCliCredentialsCached: vi.fn<(options?: unknown) => OAuthCredential | null>(() => null),
|
||||
readMiniMaxCliCredentialsCached: vi.fn<(options?: unknown) => OAuthCredential | null>(() => null),
|
||||
}));
|
||||
@@ -268,7 +267,6 @@ vi.mock("./cli-credentials.js", () => cliCredentialMocks);
|
||||
|
||||
beforeEach(() => {
|
||||
clearRuntimeAuthProfileStoreSnapshots();
|
||||
cliCredentialMocks.readClaudeCliCredentialsCached.mockReset().mockReturnValue(null);
|
||||
cliCredentialMocks.readCodexCliCredentialsCached.mockReset().mockReturnValue(null);
|
||||
cliCredentialMocks.readMiniMaxCliCredentialsCached.mockReset().mockReturnValue(null);
|
||||
});
|
||||
@@ -715,14 +713,6 @@ describe("getApiKeyForModelCore", () => {
|
||||
});
|
||||
|
||||
it("does not read unrelated external CLI credentials when resolving provider auth", async () => {
|
||||
cliCredentialMocks.readClaudeCliCredentialsCached.mockReturnValue({
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: "claude-cli-access",
|
||||
refresh: "claude-cli-refresh",
|
||||
expires: createUsableOAuthExpiry(),
|
||||
});
|
||||
|
||||
await withOpenClawTestState(
|
||||
{
|
||||
layout: "state-only",
|
||||
@@ -753,20 +743,11 @@ describe("getApiKeyForModelCore", () => {
|
||||
},
|
||||
);
|
||||
|
||||
expect(cliCredentialMocks.readClaudeCliCredentialsCached).not.toHaveBeenCalled();
|
||||
expect(cliCredentialMocks.readCodexCliCredentialsCached).toHaveBeenCalled();
|
||||
expect(cliCredentialMocks.readMiniMaxCliCredentialsCached).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reads Claude CLI credentials when the Claude CLI provider is resolved", async () => {
|
||||
cliCredentialMocks.readClaudeCliCredentialsCached.mockReturnValue({
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: "claude-cli-access",
|
||||
refresh: "claude-cli-refresh",
|
||||
expires: createUsableOAuthExpiry(),
|
||||
});
|
||||
|
||||
it("does not read Claude CLI credentials when the Claude CLI provider is resolved", async () => {
|
||||
await withOpenClawTestState(
|
||||
{
|
||||
layout: "state-only",
|
||||
@@ -774,18 +755,46 @@ describe("getApiKeyForModelCore", () => {
|
||||
agentEnv: "main",
|
||||
},
|
||||
async () => {
|
||||
const resolved = await resolveApiKeyForProviderCore({ provider: "claude-cli" });
|
||||
expect(resolved.apiKey).toBe("claude-cli-access");
|
||||
expect(resolved.profileId).toBe("anthropic:claude-cli");
|
||||
expect(resolved.source).toBe("profile:anthropic:claude-cli");
|
||||
expect(resolved.mode).toBe("oauth");
|
||||
const error = await resolveApiKeyForProviderCore({ provider: "claude-cli" }).catch(
|
||||
(caught: unknown) => caught,
|
||||
);
|
||||
expect(error).toMatchObject({
|
||||
code: "missing-provider-auth",
|
||||
provider: "claude-cli",
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const options = cliCredentialMocks.readClaudeCliCredentialsCached.mock.calls.at(0)?.[0] as
|
||||
| { allowKeychainPrompt?: boolean }
|
||||
| undefined;
|
||||
expect(options?.allowKeychainPrompt).toBe(false);
|
||||
it("keeps the native Claude CLI profile out of Anthropic SDK auth resolution", async () => {
|
||||
await withEnvAsync(
|
||||
{
|
||||
ANTHROPIC_API_KEY: "current-anthropic-key",
|
||||
ANTHROPIC_OAUTH_TOKEN: undefined,
|
||||
},
|
||||
async () => {
|
||||
const store: RuntimeAuthProfileStore = {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"anthropic:claude-cli": {
|
||||
type: "oauth",
|
||||
provider: "claude-cli",
|
||||
access: "copied-native-access",
|
||||
refresh: "copied-native-refresh",
|
||||
expires: createUsableOAuthExpiry(),
|
||||
},
|
||||
},
|
||||
runtimeExternalCliProfileIds: ["anthropic:claude-cli"],
|
||||
};
|
||||
const resolved = await resolveApiKeyForProviderCore({
|
||||
provider: "anthropic",
|
||||
store,
|
||||
});
|
||||
|
||||
expect(resolved.apiKey).toBe("current-anthropic-key");
|
||||
expect(resolved.source).toContain("ANTHROPIC_API_KEY");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when ZAI API key is missing", async () => {
|
||||
@@ -1919,6 +1928,38 @@ describe("getApiKeyForModelCore", () => {
|
||||
});
|
||||
|
||||
describe("resolveApiKeyForProviderCore — per-entry apiKey as profile ID reference", () => {
|
||||
it("rejects a retired profile reference before resolving its copied credential", async () => {
|
||||
await expect(
|
||||
resolveApiKeyForProviderCore({
|
||||
provider: "anthropic",
|
||||
cfg: {
|
||||
models: {
|
||||
providers: {
|
||||
anthropic: {
|
||||
api: "anthropic-messages",
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
apiKey: "anthropic:claude-cli",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
store: {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"anthropic:claude-cli": {
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: "copied-native-access",
|
||||
refresh: "copied-native-refresh",
|
||||
expires: createUsableOAuthExpiry(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(/anthropic:claude-cli.*retired.*doctor --fix/);
|
||||
});
|
||||
|
||||
it("resolves actual credential when per-entry apiKey matches a profile ID in the store", async () => {
|
||||
// Scenario from #67423: openrouter-minimax.apiKey = "openrouter:key-b"
|
||||
// should resolve the actual key from that profile, not use the string literally.
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
/** Migrates legacy provider-declared OAuth profile ids to current auth profile ids. */
|
||||
/** 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";
|
||||
import { repairOAuthProfileIdMismatch } from "../agents/auth-profiles/repair.js";
|
||||
import { ensureAuthProfileStore } from "../agents/auth-profiles/store.js";
|
||||
import { ensureAuthProfileStoreWithoutExternalProfiles } from "../agents/auth-profiles/store.js";
|
||||
import { applyProviderConfigDefaultsForConfig } from "../config/provider-policy.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import {
|
||||
configReferencesAuthProfile,
|
||||
removeAuthProfileConfig,
|
||||
} from "../plugins/provider-auth-helpers.js";
|
||||
import { listAuthProfileRepairCandidates } from "./doctor-auth-legacy-paths.js";
|
||||
import type { DoctorPrompter } from "./doctor-prompter.js";
|
||||
|
||||
async function loadProviderRuntime() {
|
||||
@@ -31,21 +38,60 @@ function sanitizePromptLabel(label: string | undefined): string | undefined {
|
||||
export async function maybeRepairLegacyOAuthProfileIds(
|
||||
cfg: OpenClawConfig,
|
||||
prompter: DoctorPrompter,
|
||||
): Promise<OpenClawConfig> {
|
||||
if (!hasConfigOAuthProfiles(cfg)) {
|
||||
return cfg;
|
||||
}
|
||||
const store = ensureAuthProfileStore();
|
||||
if (Object.keys(store.profiles).length === 0) {
|
||||
return cfg;
|
||||
}
|
||||
): Promise<LegacyOAuthProfileRepairResult> {
|
||||
let nextCfg = cfg;
|
||||
const retiredProfileCleanupPlans: RetiredAuthProfileCleanupPlan[] = [];
|
||||
const { resolvePluginProvidersCore } = await loadProviderRuntime();
|
||||
const providers = resolvePluginProvidersCore({
|
||||
config: cfg,
|
||||
env: process.env,
|
||||
mode: "setup",
|
||||
});
|
||||
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]),
|
||||
);
|
||||
if (profileStores.length === 0 && !configReferencesAuthProfile(nextCfg, profileId)) {
|
||||
continue;
|
||||
}
|
||||
const { note } = await loadNoteRuntime();
|
||||
note(
|
||||
`- Remove retired auth profile ${profileId}. The provider's native login remains unchanged.`,
|
||||
"Auth profiles",
|
||||
);
|
||||
const label = sanitizePromptLabel(provider.label) ?? provider.id;
|
||||
const apply = await prompter.confirm({
|
||||
message: `Remove retired ${label} auth profile now?`,
|
||||
initialValue: true,
|
||||
});
|
||||
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,
|
||||
});
|
||||
nextCfg = removeAuthProfileConfig(nextCfg, profileId);
|
||||
for (const candidate of profileStores) {
|
||||
retiredProfileCleanupPlans.push({
|
||||
agentDir: candidate.agentDir,
|
||||
profileIds: [profileId],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!hasConfigOAuthProfiles(nextCfg)) {
|
||||
return { config: nextCfg, retiredProfileCleanupPlans };
|
||||
}
|
||||
const store = ensureAuthProfileStoreWithoutExternalProfiles();
|
||||
if (Object.keys(store.profiles).length === 0) {
|
||||
return { config: nextCfg, retiredProfileCleanupPlans };
|
||||
}
|
||||
for (const provider of providers) {
|
||||
for (const repairSpec of provider.oauthProfileIdRepairs ?? []) {
|
||||
const repair = repairOAuthProfileIdMismatch({
|
||||
@@ -74,5 +120,15 @@ export async function maybeRepairLegacyOAuthProfileIds(
|
||||
nextCfg = repair.config;
|
||||
}
|
||||
}
|
||||
return nextCfg;
|
||||
return { config: nextCfg, retiredProfileCleanupPlans };
|
||||
}
|
||||
|
||||
export type RetiredAuthProfileCleanupPlan = {
|
||||
agentDir?: string;
|
||||
profileIds: readonly string[];
|
||||
};
|
||||
|
||||
export type LegacyOAuthProfileRepairResult = {
|
||||
config: OpenClawConfig;
|
||||
retiredProfileCleanupPlans: readonly RetiredAuthProfileCleanupPlan[];
|
||||
};
|
||||
|
||||
@@ -12,9 +12,19 @@ const resolvePluginProvidersMock = vi.fn<() => ProviderPlugin[]>(() => []);
|
||||
const authProfileStoreMock = vi.hoisted(() => ({
|
||||
store: { version: 1, profiles: {} } as AuthProfileStore,
|
||||
}));
|
||||
const candidateMocks = vi.hoisted(() => ({
|
||||
candidates: [{ agentDir: undefined, authPath: "/tmp/shared/openclaw-agent.sqlite" }] as Array<{
|
||||
agentDir?: string;
|
||||
authPath: string;
|
||||
}>,
|
||||
stores: new Map<string | undefined, AuthProfileStore>(),
|
||||
}));
|
||||
const repairMocks = vi.hoisted(() => ({
|
||||
repairOAuthProfileIdMismatch: vi.fn(),
|
||||
}));
|
||||
const providerPolicyMocks = vi.hoisted(() => ({
|
||||
applyConfigDefaults: vi.fn((params: { config: OpenClawConfig }) => params.config),
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/providers.runtime.js", () => ({
|
||||
resolvePluginProvidersCore: () => resolvePluginProvidersMock(),
|
||||
@@ -24,8 +34,25 @@ vi.mock("../agents/auth-profiles/repair.js", () => ({
|
||||
repairOAuthProfileIdMismatch: repairMocks.repairOAuthProfileIdMismatch,
|
||||
}));
|
||||
|
||||
vi.mock("../config/provider-policy.js", () => ({
|
||||
applyProviderConfigDefaultsForConfig: providerPolicyMocks.applyConfigDefaults,
|
||||
}));
|
||||
|
||||
vi.mock("../agents/auth-profiles/persisted.js", () => ({
|
||||
loadPersistedAuthProfileStore: (agentDir?: string) =>
|
||||
candidateMocks.stores.has(agentDir)
|
||||
? candidateMocks.stores.get(agentDir)
|
||||
: agentDir === undefined
|
||||
? authProfileStoreMock.store
|
||||
: undefined,
|
||||
}));
|
||||
|
||||
vi.mock("./doctor-auth-legacy-paths.js", () => ({
|
||||
listAuthProfileRepairCandidates: () => candidateMocks.candidates,
|
||||
}));
|
||||
|
||||
vi.mock("../agents/auth-profiles/store.js", () => ({
|
||||
ensureAuthProfileStore: () => authProfileStoreMock.store,
|
||||
ensureAuthProfileStoreWithoutExternalProfiles: () => authProfileStoreMock.store,
|
||||
}));
|
||||
|
||||
vi.mock("../../packages/terminal-core/src/note.js", () => ({
|
||||
@@ -72,22 +99,29 @@ beforeEach(() => {
|
||||
resolvePluginProvidersMock.mockReset();
|
||||
resolvePluginProvidersMock.mockReturnValue([]);
|
||||
authProfileStoreMock.store = { version: 1, profiles: {} };
|
||||
candidateMocks.candidates = [
|
||||
{ agentDir: undefined, authPath: "/tmp/shared/openclaw-agent.sqlite" },
|
||||
];
|
||||
candidateMocks.stores.clear();
|
||||
repairMocks.repairOAuthProfileIdMismatch.mockReset();
|
||||
repairMocks.repairOAuthProfileIdMismatch.mockReturnValue({
|
||||
config: {},
|
||||
changes: [],
|
||||
migrated: false,
|
||||
});
|
||||
providerPolicyMocks.applyConfigDefaults.mockReset();
|
||||
providerPolicyMocks.applyConfigDefaults.mockImplementation(({ config }) => config);
|
||||
});
|
||||
|
||||
describe("maybeRepairLegacyOAuthProfileIds", () => {
|
||||
it("skips provider loading when config has no legacy OAuth profiles", async () => {
|
||||
it("skips profile repair when config has no legacy OAuth profiles", async () => {
|
||||
const cfg = { channels: { telegram: { enabled: true } } } as OpenClawConfig;
|
||||
|
||||
const next = await maybeRepairLegacyOAuthProfileIds(cfg, makePrompter(true));
|
||||
const result = await maybeRepairLegacyOAuthProfileIds(cfg, makePrompter(true));
|
||||
|
||||
expect(next).toBe(cfg);
|
||||
expect(resolvePluginProvidersMock).not.toHaveBeenCalled();
|
||||
expect(result.config).toBe(cfg);
|
||||
expect(result.retiredProfileCleanupPlans).toEqual([]);
|
||||
expect(resolvePluginProvidersMock).toHaveBeenCalledOnce();
|
||||
expect(repairMocks.repairOAuthProfileIdMismatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -136,7 +170,7 @@ describe("maybeRepairLegacyOAuthProfileIds", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const next = await maybeRepairLegacyOAuthProfileIds(
|
||||
const { config: next } = await maybeRepairLegacyOAuthProfileIds(
|
||||
{
|
||||
auth: {
|
||||
profiles: {
|
||||
@@ -176,6 +210,156 @@ describe("maybeRepairLegacyOAuthProfileIds", () => {
|
||||
expect(auth.order?.anthropic).toEqual(["anthropic:user@example.com"]);
|
||||
});
|
||||
|
||||
it("removes a provider-declared retired auth profile and config references", async () => {
|
||||
authProfileStoreMock.store = {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"anthropic:claude-cli": {
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: "copied-native-access",
|
||||
refresh: "copied-native-refresh",
|
||||
expires: Date.now() + 60_000,
|
||||
},
|
||||
"anthropic:managed": {
|
||||
type: "api_key",
|
||||
provider: "anthropic",
|
||||
key: "managed-key",
|
||||
},
|
||||
},
|
||||
};
|
||||
resolvePluginProvidersMock.mockReturnValue([
|
||||
{
|
||||
id: "anthropic",
|
||||
label: "Anthropic",
|
||||
auth: [],
|
||||
deprecatedProfileIds: ["anthropic:claude-cli"],
|
||||
},
|
||||
]);
|
||||
providerPolicyMocks.applyConfigDefaults.mockImplementation(({ config }) => ({
|
||||
...config,
|
||||
agents: {
|
||||
...config.agents,
|
||||
defaults: {
|
||||
...config.agents?.defaults,
|
||||
models: {
|
||||
...config.agents?.defaults?.models,
|
||||
"anthropic/claude-sonnet-4-6": {
|
||||
agentRuntime: { id: "claude-cli" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const result = await maybeRepairLegacyOAuthProfileIds(
|
||||
{
|
||||
auth: {
|
||||
profiles: {
|
||||
"anthropic:claude-cli": { provider: "claude-cli", mode: "oauth" },
|
||||
"anthropic:managed": { provider: "anthropic", mode: "api_key" },
|
||||
},
|
||||
order: {
|
||||
anthropic: ["anthropic:claude-cli", "anthropic:managed"],
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "anthropic/claude-sonnet-4-6" },
|
||||
},
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
anthropic: {
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
apiKey: "anthropic:claude-cli",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
makePrompter(true),
|
||||
);
|
||||
|
||||
const next = result.config;
|
||||
expect(next.auth?.profiles).toEqual({
|
||||
"anthropic:managed": { provider: "anthropic", mode: "api_key" },
|
||||
});
|
||||
expect(next.auth?.order?.anthropic).toEqual(["anthropic:managed"]);
|
||||
expect(next.agents?.defaults?.models?.["anthropic/claude-sonnet-4-6"]?.agentRuntime).toEqual({
|
||||
id: "claude-cli",
|
||||
});
|
||||
expect(next.models?.providers?.anthropic?.apiKey).toBeUndefined();
|
||||
expect(result.retiredProfileCleanupPlans).toContainEqual({
|
||||
agentDir: undefined,
|
||||
profileIds: ["anthropic:claude-cli"],
|
||||
});
|
||||
});
|
||||
|
||||
it("removes a config-only provider entry reference to a retired profile", async () => {
|
||||
resolvePluginProvidersMock.mockReturnValue([
|
||||
{
|
||||
id: "anthropic",
|
||||
label: "Anthropic",
|
||||
auth: [],
|
||||
deprecatedProfileIds: ["anthropic:claude-cli"],
|
||||
},
|
||||
]);
|
||||
|
||||
const { config: next, retiredProfileCleanupPlans } = await maybeRepairLegacyOAuthProfileIds(
|
||||
{
|
||||
models: {
|
||||
providers: {
|
||||
anthropic: {
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
apiKey: "anthropic:claude-cli",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
makePrompter(true),
|
||||
);
|
||||
|
||||
expect(next.models?.providers?.anthropic?.apiKey).toBeUndefined();
|
||||
expect(retiredProfileCleanupPlans).toEqual([]);
|
||||
});
|
||||
|
||||
it("removes a retired profile from a secondary agent store", async () => {
|
||||
const secondaryAgentDir = "/tmp/state/agents/secondary/agent";
|
||||
candidateMocks.candidates = [
|
||||
{ agentDir: undefined, authPath: "/tmp/shared/openclaw-agent.sqlite" },
|
||||
{ agentDir: secondaryAgentDir, authPath: `${secondaryAgentDir}/openclaw-agent.sqlite` },
|
||||
];
|
||||
candidateMocks.stores.set(secondaryAgentDir, {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"anthropic:claude-cli": {
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
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({} as OpenClawConfig, makePrompter(true));
|
||||
|
||||
expect(result.retiredProfileCleanupPlans).toContainEqual({
|
||||
agentDir: secondaryAgentDir,
|
||||
profileIds: ["anthropic:claude-cli"],
|
||||
});
|
||||
});
|
||||
|
||||
it("strips provider-controlled terminal escapes from repair prompts", async () => {
|
||||
authProfileStoreMock.store = {
|
||||
version: 1,
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
resolveApiKeyForProfile,
|
||||
resolveProfileUnusableUntilForDisplay,
|
||||
} from "../agents/auth-profiles.js";
|
||||
import { CLAUDE_CLI_PROFILE_ID } from "../agents/auth-profiles/constants.js";
|
||||
import { formatAuthDoctorHint } from "../agents/auth-profiles/doctor.js";
|
||||
import {
|
||||
buildAuthProfileUnusableHint,
|
||||
@@ -42,7 +41,6 @@ import type { DoctorPrompter } from "./doctor-prompter.js";
|
||||
|
||||
const OPENAI_PROVIDER_ID = "openai";
|
||||
const LEGACY_CODEX_PROVIDER_ID = "openai-codex";
|
||||
const CLAUDE_CLI_PROVIDER_ID = "claude-cli";
|
||||
const CODEX_OAUTH_WARNING_TITLE = "Codex OAuth";
|
||||
const OPENAI_BASE_URL = "https://api.openai.com/v1";
|
||||
const LEGACY_CODEX_APIS = new Set(["openai-responses", "openai-completions"]);
|
||||
@@ -345,16 +343,6 @@ function isAuthProfileHealthIssue(profile: AuthHealthSummary["profiles"][number]
|
||||
if (profile.type === "api_key") {
|
||||
return profile.status === "missing";
|
||||
}
|
||||
// Claude CLI refreshes its short-lived access token when the process runs.
|
||||
// Warn once that external credential is unusable, not throughout its normal lifetime.
|
||||
if (
|
||||
profile.profileId === CLAUDE_CLI_PROFILE_ID &&
|
||||
profile.provider === CLAUDE_CLI_PROVIDER_ID &&
|
||||
profile.type === "oauth" &&
|
||||
profile.status === "expiring"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
(profile.type === "oauth" || profile.type === "token") &&
|
||||
(profile.status === "expired" || profile.status === "expiring" || profile.status === "missing")
|
||||
|
||||
@@ -3,8 +3,6 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { CLAUDE_CLI_PROFILE_ID } from "../agents/auth-profiles/constants.js";
|
||||
import type { AuthProfileStore } from "../agents/auth-profiles/types.js";
|
||||
import { resolveClaudeCliProjectDirForWorkspace } from "../agents/command/claude-cli-project-dir.js";
|
||||
import { noteClaudeCliHealth } from "./doctor-claude-cli.js";
|
||||
|
||||
@@ -21,17 +19,6 @@ vi.mock("../agents/agent-runtime-metadata.js", () => ({
|
||||
resolveModelAgentRuntimeMetadata: resolveModelAgentRuntimeMetadataMock,
|
||||
}));
|
||||
|
||||
vi.mock("../agents/auth-profiles/store.js", () => ({
|
||||
ensureAuthProfileStore: vi.fn(),
|
||||
}));
|
||||
|
||||
function createStore(profiles: AuthProfileStore["profiles"] = {}): AuthProfileStore {
|
||||
return {
|
||||
version: 1,
|
||||
profiles,
|
||||
};
|
||||
}
|
||||
|
||||
async function withTempHome<T>(
|
||||
run: (params: { homeDir: string; workspaceDir: string }) => Promise<T> | T,
|
||||
): Promise<T> {
|
||||
@@ -100,8 +87,6 @@ describe("noteClaudeCliHealth", () => {
|
||||
homeDir,
|
||||
workspaceDir,
|
||||
noteFn: vi.fn(),
|
||||
store: createStore(),
|
||||
readClaudeCliCredentials: () => null,
|
||||
resolveCommandPath,
|
||||
},
|
||||
);
|
||||
@@ -116,8 +101,6 @@ describe("noteClaudeCliHealth", () => {
|
||||
{},
|
||||
{
|
||||
noteFn,
|
||||
store: createStore(),
|
||||
readClaudeCliCredentials: () => null,
|
||||
},
|
||||
);
|
||||
expect(noteFn).not.toHaveBeenCalled();
|
||||
@@ -142,19 +125,7 @@ describe("noteClaudeCliHealth", () => {
|
||||
homeDir,
|
||||
workspaceDir,
|
||||
noteFn,
|
||||
store: createStore({
|
||||
[CLAUDE_CLI_PROFILE_ID]: {
|
||||
type: "oauth",
|
||||
provider: "claude-cli",
|
||||
access: "test-auth-token",
|
||||
refresh: "test-token-placeholder",
|
||||
expires: Date.now() + 60_000,
|
||||
},
|
||||
}),
|
||||
readClaudeCliCredentials: () => ({
|
||||
type: "oauth",
|
||||
expires: Date.now() + 60_000,
|
||||
}),
|
||||
isAuthenticated: () => true,
|
||||
resolveCommandPath: () => "/opt/homebrew/bin/claude",
|
||||
},
|
||||
);
|
||||
@@ -207,19 +178,7 @@ describe("noteClaudeCliHealth", () => {
|
||||
{
|
||||
homeDir,
|
||||
noteFn,
|
||||
store: createStore({
|
||||
[CLAUDE_CLI_PROFILE_ID]: {
|
||||
type: "oauth",
|
||||
provider: "claude-cli",
|
||||
access: "test-auth-token",
|
||||
refresh: "test-token-placeholder",
|
||||
expires: Date.now() + 60_000,
|
||||
},
|
||||
}),
|
||||
readClaudeCliCredentials: () => ({
|
||||
type: "oauth",
|
||||
expires: Date.now() + 60_000,
|
||||
}),
|
||||
isAuthenticated: () => true,
|
||||
resolveCommandPath: () => "/opt/homebrew/bin/claude",
|
||||
},
|
||||
);
|
||||
@@ -228,7 +187,7 @@ describe("noteClaudeCliHealth", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("explains the exact bad wiring when the claude-cli auth profile is missing", async () => {
|
||||
it("reports when Claude CLI owns no active login", async () => {
|
||||
await withTempHome(({ homeDir, workspaceDir }) => {
|
||||
const noteFn = vi.fn();
|
||||
noteClaudeCliHealth(
|
||||
@@ -244,26 +203,19 @@ describe("noteClaudeCliHealth", () => {
|
||||
homeDir,
|
||||
workspaceDir,
|
||||
noteFn,
|
||||
store: createStore(),
|
||||
readClaudeCliCredentials: () => ({
|
||||
type: "oauth",
|
||||
expires: Date.now() + 60_000,
|
||||
}),
|
||||
isAuthenticated: () => false,
|
||||
resolveCommandPath: () => "/opt/homebrew/bin/claude",
|
||||
},
|
||||
);
|
||||
|
||||
const body = noteBody(noteFn);
|
||||
expect(body).toContain(`OpenClaw auth profile: missing (${CLAUDE_CLI_PROFILE_ID})`);
|
||||
expect(body).toContain(
|
||||
"openclaw models auth login --provider anthropic --method cli --set-default",
|
||||
);
|
||||
expect(body).not.toContain("Headless Claude auth: OK");
|
||||
expect(body).not.toContain("not created yet");
|
||||
expect(body).toContain("Claude auth: not logged in.");
|
||||
expect(body).toContain("claude auth login");
|
||||
expect(body).not.toContain("openclaw models auth login");
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts Claude CLI apiKeyHelper without a stored auth profile", async () => {
|
||||
it("warns when the Claude binary is missing", async () => {
|
||||
await withTempHome(({ homeDir, workspaceDir }) => {
|
||||
const noteFn = vi.fn();
|
||||
noteClaudeCliHealth(
|
||||
@@ -279,44 +231,13 @@ describe("noteClaudeCliHealth", () => {
|
||||
homeDir,
|
||||
workspaceDir,
|
||||
noteFn,
|
||||
store: createStore(),
|
||||
readClaudeCliCredentials: () => ({
|
||||
type: "api_key_helper",
|
||||
}),
|
||||
resolveCommandPath: () => "/opt/homebrew/bin/claude",
|
||||
},
|
||||
);
|
||||
|
||||
expect(noteFn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("warns when Claude auth is not readable headlessly", async () => {
|
||||
await withTempHome(({ homeDir, workspaceDir }) => {
|
||||
const noteFn = vi.fn();
|
||||
noteClaudeCliHealth(
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "claude-cli/claude-sonnet-4-6" },
|
||||
},
|
||||
entries: { main: { default: true } },
|
||||
},
|
||||
},
|
||||
{
|
||||
homeDir,
|
||||
workspaceDir,
|
||||
noteFn,
|
||||
store: createStore(),
|
||||
readClaudeCliCredentials: () => null,
|
||||
resolveCommandPath: () => undefined,
|
||||
},
|
||||
);
|
||||
|
||||
const body = noteBody(noteFn);
|
||||
expect(body).toContain('Binary: command "claude" was not found on PATH.');
|
||||
expect(body).toContain("Headless Claude auth: unavailable without interactive prompting.");
|
||||
expect(body).toContain("claude auth login");
|
||||
expect(body).not.toContain("claude auth login");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -358,19 +279,7 @@ describe("noteClaudeCliHealth", () => {
|
||||
{
|
||||
homeDir,
|
||||
noteFn,
|
||||
store: createStore({
|
||||
[CLAUDE_CLI_PROFILE_ID]: {
|
||||
type: "oauth",
|
||||
provider: "claude-cli",
|
||||
access: "test-auth-token",
|
||||
refresh: "test-token-placeholder",
|
||||
expires: Date.now() + 60_000,
|
||||
},
|
||||
}),
|
||||
readClaudeCliCredentials: () => ({
|
||||
type: "oauth",
|
||||
expires: Date.now() + 60_000,
|
||||
}),
|
||||
isAuthenticated: () => true,
|
||||
resolveCommandPath: () => "/opt/homebrew/bin/claude",
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/** Doctor health note for Claude CLI binary, auth, and workspace/project directories. */
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import {
|
||||
normalizeOptionalLowercaseString,
|
||||
resolvePrimaryStringValue,
|
||||
@@ -11,16 +13,7 @@ import {
|
||||
resolveAgentWorkspaceDir,
|
||||
tryResolveDefaultAgentId,
|
||||
} from "../agents/agent-scope-config.js";
|
||||
import { CLAUDE_CLI_PROFILE_ID } from "../agents/auth-profiles/constants.js";
|
||||
import { resolveAuthStorePathForDisplay } from "../agents/auth-profiles/paths.js";
|
||||
import { ensureAuthProfileStore } from "../agents/auth-profiles/store.js";
|
||||
import type {
|
||||
AuthProfileStore,
|
||||
OAuthCredential,
|
||||
TokenCredential,
|
||||
} from "../agents/auth-profiles/types.js";
|
||||
import { resolveCliBackendConfig } from "../agents/cli-backends.js";
|
||||
import { readClaudeCliCredentialsCached } from "../agents/cli-credentials.js";
|
||||
import { resolveClaudeCliProjectDirForWorkspace } from "../agents/command/claude-cli-project-dir.js";
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
@@ -29,17 +22,29 @@ import { shortenHomePath } from "../utils.js";
|
||||
|
||||
const CLAUDE_CLI_PROVIDER = "claude-cli";
|
||||
|
||||
type ClaudeCliReadableCredential =
|
||||
| Pick<OAuthCredential, "type" | "expires">
|
||||
| Pick<TokenCredential, "type" | "expires">
|
||||
| { type: "api_key_helper" };
|
||||
|
||||
type ClaudeCliDirHealth = "present" | "missing" | "not_directory" | "unreadable" | "readonly";
|
||||
|
||||
function isClaudeCliAuthenticated(commandPath: string, env: NodeJS.ProcessEnv): boolean {
|
||||
const result = spawnSync(commandPath, ["auth", "status", "--json"], {
|
||||
encoding: "utf8",
|
||||
env,
|
||||
maxBuffer: 64 * 1024,
|
||||
timeout: 3_000,
|
||||
windowsHide: true,
|
||||
});
|
||||
if (result.error || result.status !== 0) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(result.stdout);
|
||||
return isRecord(parsed) && parsed.loggedIn === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function usesClaudeCliModelSelection(cfg: OpenClawConfig): boolean {
|
||||
const primary = resolvePrimaryStringValue(
|
||||
cfg.agents?.defaults?.model as string | { primary?: string; fallbacks?: string[] } | undefined,
|
||||
);
|
||||
const primary = resolvePrimaryStringValue(cfg.agents?.defaults?.model);
|
||||
if (normalizeOptionalLowercaseString(primary)?.startsWith(`${CLAUDE_CLI_PROVIDER}/`)) {
|
||||
return true;
|
||||
}
|
||||
@@ -170,8 +175,7 @@ function resolveClaudeCliWorkspaceTargets(params: {
|
||||
/**
|
||||
* Emits Claude CLI health diagnostics for every agent currently routed through the CLI backend.
|
||||
*
|
||||
* The optional deps let tests inject auth stores, PATH resolution, and workspace roots without
|
||||
* touching the user's real Claude credentials or filesystem.
|
||||
* The optional deps let tests inject the CLI status probe, PATH resolution, and workspace roots.
|
||||
*/
|
||||
export function noteClaudeCliHealth(
|
||||
cfg: OpenClawConfig,
|
||||
@@ -179,8 +183,7 @@ export function noteClaudeCliHealth(
|
||||
noteFn?: typeof note;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
homeDir?: string;
|
||||
store?: AuthProfileStore;
|
||||
readClaudeCliCredentials?: () => ClaudeCliReadableCredential | null;
|
||||
isAuthenticated?: (commandPath: string, env: NodeJS.ProcessEnv) => boolean;
|
||||
resolveCommandPath?: (command: string, env?: NodeJS.ProcessEnv) => string | undefined;
|
||||
workspaceDir?: string;
|
||||
},
|
||||
@@ -196,11 +199,6 @@ export function noteClaudeCliHealth(
|
||||
return;
|
||||
}
|
||||
|
||||
const store = deps?.store ?? ensureAuthProfileStore(undefined, { allowKeychainPrompt: false });
|
||||
const readClaudeCliCredentials =
|
||||
deps?.readClaudeCliCredentials ??
|
||||
(() => readClaudeCliCredentialsCached({ allowKeychainPrompt: false }));
|
||||
const credential = readClaudeCliCredentials();
|
||||
const backend = resolveCliBackendConfig(CLAUDE_CLI_PROVIDER, cfg);
|
||||
const command = backend?.config.command ?? "claude";
|
||||
const resolveCommandPath =
|
||||
@@ -208,8 +206,9 @@ export function noteClaudeCliHealth(
|
||||
((rawCommand: string, nextEnv?: NodeJS.ProcessEnv) =>
|
||||
resolveExecutablePath(rawCommand, { env: nextEnv }));
|
||||
const commandPath = resolveCommandPath(command, env);
|
||||
const authStorePath = resolveAuthStorePathForDisplay();
|
||||
const storedProfile = store.profiles[CLAUDE_CLI_PROFILE_ID];
|
||||
const authenticated = commandPath
|
||||
? (deps?.isAuthenticated ?? isClaudeCliAuthenticated)(commandPath, env)
|
||||
: false;
|
||||
const defaultAgentId = tryResolveDefaultAgentId(cfg);
|
||||
const showAgentLabels =
|
||||
workspaceTargets.length > 1 ||
|
||||
@@ -225,31 +224,9 @@ export function noteClaudeCliHealth(
|
||||
);
|
||||
}
|
||||
|
||||
if (!credential) {
|
||||
lines.push("- Headless Claude auth: unavailable without interactive prompting.");
|
||||
fixHints.push(
|
||||
`- Fix: run ${formatCliCommand("claude auth login")}, then ${formatCliCommand(
|
||||
"openclaw models auth login --provider anthropic --method cli --set-default",
|
||||
)}.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!storedProfile && credential?.type !== "api_key_helper") {
|
||||
lines.push(`- OpenClaw auth profile: missing (${CLAUDE_CLI_PROFILE_ID}) in ${authStorePath}.`);
|
||||
fixHints.push(
|
||||
`- Fix: run ${formatCliCommand(
|
||||
"openclaw models auth login --provider anthropic --method cli --set-default",
|
||||
)}.`,
|
||||
);
|
||||
} else if (storedProfile && storedProfile.provider !== CLAUDE_CLI_PROVIDER) {
|
||||
lines.push(
|
||||
`- OpenClaw auth profile: ${CLAUDE_CLI_PROFILE_ID} is wired to provider "${storedProfile.provider}" instead of "${CLAUDE_CLI_PROVIDER}".`,
|
||||
);
|
||||
fixHints.push(
|
||||
`- Fix: rerun ${formatCliCommand(
|
||||
"openclaw models auth login --provider anthropic --method cli --set-default",
|
||||
)} to rewrite the profile cleanly.`,
|
||||
);
|
||||
if (commandPath && !authenticated) {
|
||||
lines.push("- Claude auth: not logged in.");
|
||||
fixHints.push(`- Fix: run ${formatCliCommand("claude auth login")}.`);
|
||||
}
|
||||
|
||||
for (const target of workspaceTargets) {
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AuthProfileStore } from "../agents/auth-profiles/types.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
listCandidates: vi.fn(() => [
|
||||
{ agentDir: "/tmp/main", authPath: "/tmp/main/auth-profiles.json" },
|
||||
]),
|
||||
loadStore: vi.fn<() => AuthProfileStore | null>(() => null),
|
||||
resolveExternalCliAuthProfiles: vi.fn<() => unknown[]>(() => []),
|
||||
runTransaction: vi.fn((_agentDir, callback) => callback({})),
|
||||
saveStore: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./doctor-auth-legacy-paths.js", () => ({
|
||||
listAuthProfileRepairCandidates: mocks.listCandidates,
|
||||
}));
|
||||
vi.mock("../agents/auth-profiles/persisted.js", () => ({
|
||||
loadPersistedAuthProfileStore: mocks.loadStore,
|
||||
}));
|
||||
vi.mock("../agents/auth-profiles/external-cli-sync.js", () => ({
|
||||
resolveExternalCliAuthProfiles: mocks.resolveExternalCliAuthProfiles,
|
||||
}));
|
||||
vi.mock("../agents/auth-profiles/sqlite.js", () => ({
|
||||
runAuthProfileWriteTransaction: mocks.runTransaction,
|
||||
}));
|
||||
vi.mock("../agents/auth-profiles/store.js", () => ({ saveAuthProfileStore: mocks.saveStore }));
|
||||
|
||||
import { maybeMigrateExternalCliProfileMetadata } from "./doctor-external-cli-profiles.js";
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("external CLI auth profile doctor migration", () => {
|
||||
it("leaves legacy MiniMax metadata outside the Claude migration scope", () => {
|
||||
const profileId = "minimax-portal:minimax-cli";
|
||||
const cfg = {
|
||||
auth: { profiles: { [profileId]: { provider: "minimax", mode: "token" } } },
|
||||
} as OpenClawConfig;
|
||||
|
||||
const result = maybeMigrateExternalCliProfileMetadata({ cfg, env: {} });
|
||||
|
||||
expect(cfg.auth?.profiles?.[profileId]).toEqual({ provider: "minimax", mode: "token" });
|
||||
expect(mocks.resolveExternalCliAuthProfiles).not.toHaveBeenCalled();
|
||||
expect(mocks.saveStore).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ changes: [], warnings: [], configChanged: false });
|
||||
});
|
||||
|
||||
it("persists the CLI credential before canonicalizing legacy Claude metadata", () => {
|
||||
const profileId = "anthropic:claude-cli";
|
||||
mocks.resolveExternalCliAuthProfiles.mockReturnValueOnce([
|
||||
{
|
||||
profileId,
|
||||
persistence: "persisted",
|
||||
credential: {
|
||||
type: "oauth",
|
||||
provider: "claude-cli",
|
||||
access: "rotated-access",
|
||||
refresh: "rotated-refresh",
|
||||
expires: Date.now() + 30 * 60_000,
|
||||
email: "stored@example.com",
|
||||
},
|
||||
},
|
||||
]);
|
||||
const cfg = {
|
||||
auth: { profiles: { [profileId]: { provider: "anthropic", mode: "token" } } },
|
||||
} as OpenClawConfig;
|
||||
|
||||
const result = maybeMigrateExternalCliProfileMetadata({ cfg, env: {} });
|
||||
|
||||
expect(cfg.auth?.profiles?.[profileId]).toEqual({ provider: "claude-cli", mode: "oauth" });
|
||||
expect(mocks.resolveExternalCliAuthProfiles).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ profiles: {} }),
|
||||
expect.objectContaining({ profileIds: [profileId], allowKeychainPrompt: false }),
|
||||
);
|
||||
expect(mocks.saveStore).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
profiles: expect.objectContaining({
|
||||
[profileId]: expect.objectContaining({ type: "oauth" }),
|
||||
}),
|
||||
}),
|
||||
"/tmp/main",
|
||||
{ syncExternalCli: false },
|
||||
{},
|
||||
);
|
||||
expect(result).toMatchObject({ configChanged: true, warnings: [] });
|
||||
});
|
||||
|
||||
it("keeps legacy metadata when the imported CLI credential has no identity", () => {
|
||||
const profileId = "anthropic:claude-cli";
|
||||
mocks.resolveExternalCliAuthProfiles.mockReturnValueOnce([
|
||||
{
|
||||
profileId,
|
||||
persistence: "persisted",
|
||||
credential: {
|
||||
type: "oauth",
|
||||
provider: "claude-cli",
|
||||
access: "rotated-access",
|
||||
refresh: "rotated-refresh",
|
||||
expires: Date.now() + 30 * 60_000,
|
||||
},
|
||||
},
|
||||
]);
|
||||
const cfg = {
|
||||
auth: { profiles: { [profileId]: { provider: "anthropic", mode: "token" } } },
|
||||
} as OpenClawConfig;
|
||||
|
||||
const result = maybeMigrateExternalCliProfileMetadata({ cfg, env: {} });
|
||||
|
||||
expect(cfg.auth?.profiles?.[profileId]).toEqual({ provider: "anthropic", mode: "token" });
|
||||
expect(mocks.saveStore).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
profiles: expect.objectContaining({
|
||||
[profileId]: expect.objectContaining({ type: "oauth" }),
|
||||
}),
|
||||
}),
|
||||
"/tmp/main",
|
||||
{ syncExternalCli: false },
|
||||
{},
|
||||
);
|
||||
expect(result).toMatchObject({ configChanged: false });
|
||||
expect(result.warnings).toContain(
|
||||
"Kept legacy external CLI metadata for anthropic:claude-cli: identity-complete OAuth credentials were not saved for every auth profile store.",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps legacy metadata when no current CLI credential can be persisted", () => {
|
||||
const profileId = "anthropic:claude-cli";
|
||||
const cfg = {
|
||||
auth: { profiles: { [profileId]: { provider: "anthropic", mode: "token" } } },
|
||||
} as OpenClawConfig;
|
||||
|
||||
const result = maybeMigrateExternalCliProfileMetadata({ cfg, env: {} });
|
||||
|
||||
expect(cfg.auth?.profiles?.[profileId]).toEqual({ provider: "anthropic", mode: "token" });
|
||||
expect(mocks.saveStore).not.toHaveBeenCalled();
|
||||
expect(result).toMatchObject({ configChanged: false });
|
||||
expect(result.warnings).toContain(
|
||||
"Kept legacy external CLI metadata for anthropic:claude-cli: identity-complete OAuth credentials were not saved for every auth profile store.",
|
||||
);
|
||||
});
|
||||
|
||||
it("canonicalizes legacy metadata for an already-valid persisted CLI credential", () => {
|
||||
const profileId = "anthropic:claude-cli";
|
||||
mocks.loadStore.mockReturnValueOnce({
|
||||
version: 1,
|
||||
profiles: {
|
||||
[profileId]: {
|
||||
type: "oauth",
|
||||
provider: "claude-cli",
|
||||
access: "stored-access",
|
||||
refresh: "stored-refresh",
|
||||
expires: Date.now() + 30 * 60_000,
|
||||
email: "stored@example.com",
|
||||
},
|
||||
},
|
||||
});
|
||||
const cfg = {
|
||||
auth: { profiles: { [profileId]: { provider: "anthropic", mode: "token" } } },
|
||||
} as OpenClawConfig;
|
||||
|
||||
const result = maybeMigrateExternalCliProfileMetadata({ cfg, env: {} });
|
||||
|
||||
expect(cfg.auth?.profiles?.[profileId]).toEqual({ provider: "claude-cli", mode: "oauth" });
|
||||
expect(mocks.saveStore).not.toHaveBeenCalled();
|
||||
expect(result).toMatchObject({ configChanged: true, warnings: [] });
|
||||
});
|
||||
|
||||
it("keeps legacy metadata when the credential store write fails", () => {
|
||||
const profileId = "anthropic:claude-cli";
|
||||
mocks.resolveExternalCliAuthProfiles.mockReturnValueOnce([
|
||||
{
|
||||
profileId,
|
||||
persistence: "persisted",
|
||||
credential: {
|
||||
type: "oauth",
|
||||
provider: "claude-cli",
|
||||
access: "rotated-access",
|
||||
refresh: "rotated-refresh",
|
||||
expires: Date.now() + 60_000,
|
||||
},
|
||||
},
|
||||
]);
|
||||
mocks.runTransaction.mockImplementationOnce(() => {
|
||||
throw new Error("database unavailable");
|
||||
});
|
||||
const cfg = {
|
||||
auth: { profiles: { [profileId]: { provider: "anthropic", mode: "token" } } },
|
||||
} as OpenClawConfig;
|
||||
|
||||
const result = maybeMigrateExternalCliProfileMetadata({ cfg, env: {} });
|
||||
|
||||
expect(cfg.auth?.profiles?.[profileId]).toEqual({ provider: "anthropic", mode: "token" });
|
||||
expect(result).toMatchObject({ configChanged: false });
|
||||
expect(result.warnings).toContain(
|
||||
"Could not persist external CLI OAuth credentials for /tmp/main: database unavailable",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,125 +0,0 @@
|
||||
/** Doctor-owned migration for legacy external CLI profile metadata and credentials. */
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { AUTH_STORE_VERSION } from "../agents/auth-profiles/constants.js";
|
||||
import {
|
||||
isUsablePersistedExternalCliProfileCredential,
|
||||
listConfiguredExternalCliProfileMetadataIds,
|
||||
normalizeExternalCliProfileMetadata,
|
||||
} from "../agents/auth-profiles/external-cli-profile-metadata.js";
|
||||
import { resolveExternalCliAuthProfiles } from "../agents/auth-profiles/external-cli-sync.js";
|
||||
import { loadPersistedAuthProfileStore } from "../agents/auth-profiles/persisted.js";
|
||||
import { runAuthProfileWriteTransaction } from "../agents/auth-profiles/sqlite.js";
|
||||
import { saveAuthProfileStore } from "../agents/auth-profiles/store.js";
|
||||
import type { AuthProfileStore } from "../agents/auth-profiles/types.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { listAuthProfileRepairCandidates } from "./doctor-auth-legacy-paths.js";
|
||||
|
||||
type DoctorExternalCliProfileMigration = {
|
||||
changes: string[];
|
||||
warnings: string[];
|
||||
configChanged: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Doctor is the sole durable migration owner. Runtime recognizes this legacy
|
||||
* spelling only to keep a live install recoverable until this repair is run.
|
||||
*/
|
||||
export function maybeMigrateExternalCliProfileMetadata(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): DoctorExternalCliProfileMigration {
|
||||
const env = params.env ?? process.env;
|
||||
const profiles = params.cfg.auth?.profiles;
|
||||
const profileIds = listConfiguredExternalCliProfileMetadataIds(profiles);
|
||||
if (profileIds.length === 0 || !profiles) {
|
||||
return { changes: [], warnings: [], configChanged: false };
|
||||
}
|
||||
|
||||
const pendingMetadata = new Map(
|
||||
profileIds.flatMap((profileId) => {
|
||||
const canonical = normalizeExternalCliProfileMetadata(profileId, profiles[profileId]);
|
||||
return canonical ? [[profileId, canonical] as const] : [];
|
||||
}),
|
||||
);
|
||||
const migrationSucceeded = new Map(
|
||||
[...pendingMetadata.keys()].map((profileId) => [profileId, true]),
|
||||
);
|
||||
let candidateCount = 0;
|
||||
|
||||
const changes: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
for (const candidate of listAuthProfileRepairCandidates(params.cfg, env)) {
|
||||
candidateCount += 1;
|
||||
const existing: AuthProfileStore = loadPersistedAuthProfileStore(candidate.agentDir) ?? {
|
||||
version: AUTH_STORE_VERSION,
|
||||
profiles: {},
|
||||
};
|
||||
const imported = resolveExternalCliAuthProfiles(existing, {
|
||||
profileIds,
|
||||
allowKeychainPrompt: false,
|
||||
}).filter((profile) => profile.persistence === "persisted");
|
||||
const importedProfileIds = new Set(imported.map((profile) => profile.profileId));
|
||||
const importedCredentials = new Map(
|
||||
imported.map((profile) => [profile.profileId, profile.credential] as const),
|
||||
);
|
||||
for (const profileId of pendingMetadata.keys()) {
|
||||
if (
|
||||
!isUsablePersistedExternalCliProfileCredential(
|
||||
profileId,
|
||||
importedCredentials.get(profileId),
|
||||
) &&
|
||||
!isUsablePersistedExternalCliProfileCredential(profileId, existing.profiles[profileId])
|
||||
) {
|
||||
migrationSucceeded.set(profileId, false);
|
||||
}
|
||||
}
|
||||
const next = {
|
||||
...existing,
|
||||
profiles: {
|
||||
...existing.profiles,
|
||||
...Object.fromEntries(imported.map((profile) => [profile.profileId, profile.credential])),
|
||||
},
|
||||
};
|
||||
try {
|
||||
if (!isDeepStrictEqual(next, existing)) {
|
||||
runAuthProfileWriteTransaction(candidate.agentDir, (database) => {
|
||||
const authoritative =
|
||||
loadPersistedAuthProfileStore(candidate.agentDir, { database }) ??
|
||||
({ version: AUTH_STORE_VERSION, profiles: {} } as const);
|
||||
if (!isDeepStrictEqual(authoritative, existing)) {
|
||||
throw new Error("auth profile store changed during external CLI migration");
|
||||
}
|
||||
saveAuthProfileStore(next, candidate.agentDir, { syncExternalCli: false }, database);
|
||||
});
|
||||
changes.push(
|
||||
`Persisted external CLI OAuth credentials for ${candidate.agentDir ?? "main"}.`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
for (const profileId of importedProfileIds) {
|
||||
migrationSucceeded.set(profileId, false);
|
||||
}
|
||||
warnings.push(
|
||||
`Could not persist external CLI OAuth credentials for ${candidate.agentDir ?? "main"}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
let configChanged = false;
|
||||
for (const [profileId, canonical] of pendingMetadata) {
|
||||
if (candidateCount === 0 || !migrationSucceeded.get(profileId)) {
|
||||
warnings.push(
|
||||
`Kept legacy external CLI metadata for ${profileId}: identity-complete OAuth credentials were not saved for every auth profile store.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const current = profiles[profileId];
|
||||
if (current && (current.provider !== canonical.provider || current.mode !== canonical.mode)) {
|
||||
profiles[profileId] = { ...current, ...canonical };
|
||||
configChanged = true;
|
||||
}
|
||||
}
|
||||
if (configChanged) {
|
||||
changes.unshift("Migrated legacy external CLI auth.profiles metadata to OAuth.");
|
||||
}
|
||||
return { changes, warnings, configChanged };
|
||||
}
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
maybeRepairOpenAICodexAuthConfig,
|
||||
} from "../doctor-auth-flat-profiles.js";
|
||||
import { maybeRepairLegacyOAuthSidecarProfiles } from "../doctor-auth-oauth-sidecar.js";
|
||||
import { maybeMigrateExternalCliProfileMetadata } from "../doctor-external-cli-profiles.js";
|
||||
import { maybeRepairPluginOpenClawHostLinks } from "../doctor-plugin-host-links.js";
|
||||
import { maybeRepairStaleManagedNpmBundledPlugins } from "../doctor-plugin-registry.js";
|
||||
import { migrateLegacySkillWorkshopProposals } from "../doctor-skill-workshop-sqlite.js";
|
||||
@@ -339,21 +338,6 @@ export async function runDoctorRepairSequence(params: {
|
||||
env,
|
||||
});
|
||||
appendRepairNotes(staleOAuthShadowRepair);
|
||||
const externalCliProfileMigration = maybeMigrateExternalCliProfileMetadata({
|
||||
cfg: state.candidate,
|
||||
env,
|
||||
});
|
||||
if (externalCliProfileMigration.configChanged) {
|
||||
state = applyDoctorConfigMutation({
|
||||
state,
|
||||
mutation: {
|
||||
config: state.candidate,
|
||||
changes: ["External CLI OAuth migration updated auth.profiles."],
|
||||
},
|
||||
shouldRepair: true,
|
||||
});
|
||||
}
|
||||
appendRepairNotes(externalCliProfileMigration);
|
||||
const authProfileSqliteMigration = await maybeMigrateAuthProfileJsonStoresToSqlite({
|
||||
cfg: state.candidate,
|
||||
prompter: { confirmAutoFix: async () => true },
|
||||
@@ -379,7 +363,6 @@ export async function runDoctorRepairSequence(params: {
|
||||
const authProfilesRepaired =
|
||||
legacyOAuthSidecarRepair.changes.length > 0 ||
|
||||
staleOAuthShadowRepair.changes.length > 0 ||
|
||||
externalCliProfileMigration.changes.length > 0 ||
|
||||
authProfileSqliteMigration.changes.length > 0;
|
||||
|
||||
return {
|
||||
|
||||
@@ -141,7 +141,7 @@ describe("guided onboarding inference composition", () => {
|
||||
platform: "linux",
|
||||
deps: {
|
||||
probeLocalCommand,
|
||||
readClaudeCliCredentials: () => null,
|
||||
detectClaudeLoginState: async () => ({ credentials: false }),
|
||||
readCodexCliCredentials: () => null,
|
||||
readGeminiCliCredentials: () => null,
|
||||
randomInt: () => 0,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import os from "node:os";
|
||||
import {
|
||||
readClaudeCliCredentialsCached,
|
||||
readCodexCliCredentialsCached,
|
||||
resolveCodexCliHomePath,
|
||||
} from "../agents/cli-credentials.js";
|
||||
@@ -60,18 +59,6 @@ export function detectAmbientInferenceBackends(
|
||||
// fall through to another process user's home or prompt a keychain.
|
||||
const homedir = env === process.env ? os.homedir : () => "";
|
||||
const homeDir = resolveOsHomeDir(env, homedir);
|
||||
const claudeCredential = homeDir
|
||||
? readClaudeCliCredentialsCached({ homeDir, allowKeychainPrompt: false, ttlMs: 0 })
|
||||
: null;
|
||||
if (claudeCredential && claudeCredential.type !== "api_key_helper") {
|
||||
candidates.push({
|
||||
kind: "claude-cli",
|
||||
modelRef: CLAUDE_CLI_DEFAULT_MODEL_REF,
|
||||
label: "Claude Code",
|
||||
detail: "credential file found",
|
||||
credentials: true,
|
||||
});
|
||||
}
|
||||
try {
|
||||
const codexHome =
|
||||
homeDir || env.CODEX_HOME?.trim() ? resolveCodexCliHomePath(undefined, env) : undefined;
|
||||
|
||||
@@ -70,7 +70,7 @@ describe("detectInferenceBackends", () => {
|
||||
platform: "linux",
|
||||
deps: {
|
||||
probeLocalCommand: probeDeps({}),
|
||||
readClaudeCliCredentials: () => null,
|
||||
detectClaudeLoginState: async () => ({ credentials: false }),
|
||||
readCodexCliCredentials: () => null,
|
||||
},
|
||||
});
|
||||
@@ -88,7 +88,10 @@ describe("detectInferenceBackends", () => {
|
||||
timedOut: true,
|
||||
error: "timed out after 1500ms",
|
||||
}),
|
||||
readClaudeCliCredentials: () => ({ type: "oauth" }),
|
||||
detectClaudeLoginState: async () => ({
|
||||
credentials: true,
|
||||
authKind: "claude-subscription",
|
||||
}),
|
||||
readCodexCliCredentials: () => ({ type: "oauth" }),
|
||||
readGeminiCliCredentials: () => ({ type: "oauth" }),
|
||||
},
|
||||
@@ -109,7 +112,10 @@ describe("detectInferenceBackends", () => {
|
||||
platform: "linux",
|
||||
deps: {
|
||||
probeLocalCommand: probeDeps({ claude: true, codex: true, gemini: true }),
|
||||
readClaudeCliCredentials: () => ({ type: "oauth" }),
|
||||
detectClaudeLoginState: async () => ({
|
||||
credentials: true,
|
||||
authKind: "claude-subscription",
|
||||
}),
|
||||
readCodexCliCredentials: () => ({ type: "oauth" }),
|
||||
readGeminiCliCredentials: () => ({ type: "oauth" }),
|
||||
randomInt: () => 0,
|
||||
@@ -165,7 +171,7 @@ describe("detectInferenceBackends", () => {
|
||||
platform: "linux",
|
||||
deps: {
|
||||
probeLocalCommand: probeDeps({ claude: true }),
|
||||
readClaudeCliCredentials: () => ({ type: "api_key_helper" }),
|
||||
detectClaudeLoginState: async () => ({ credentials: true, authKind: "api-key" }),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -185,7 +191,7 @@ describe("detectInferenceBackends", () => {
|
||||
platform: "linux",
|
||||
deps: {
|
||||
probeLocalCommand: probeDeps({ claude: true }),
|
||||
readClaudeCliCredentials: () => null,
|
||||
detectClaudeLoginState: async () => ({ credentials: true, authKind: "api-key" }),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -194,35 +200,35 @@ describe("detectInferenceBackends", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["oauth", "token"])(
|
||||
"labels parsed Claude CLI %s credentials as a subscription",
|
||||
async (type) => {
|
||||
const candidates = await detectInferenceBackends({
|
||||
env: {},
|
||||
platform: "linux",
|
||||
deps: {
|
||||
probeLocalCommand: probeDeps({ claude: true }),
|
||||
readClaudeCliCredentials: () => ({ type }),
|
||||
},
|
||||
});
|
||||
|
||||
expect(candidates).toMatchObject([
|
||||
{
|
||||
kind: "claude-cli",
|
||||
it("labels a Claude CLI subscription reported by its status command", async () => {
|
||||
const candidates = await detectInferenceBackends({
|
||||
env: {},
|
||||
platform: "linux",
|
||||
deps: {
|
||||
probeLocalCommand: probeDeps({ claude: true }),
|
||||
detectClaudeLoginState: async () => ({
|
||||
credentials: true,
|
||||
detail: "logged in · Claude subscription",
|
||||
},
|
||||
]);
|
||||
},
|
||||
);
|
||||
authKind: "claude-subscription",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
it("keeps an Anthropic environment key ahead of unknown Claude credentials", async () => {
|
||||
expect(candidates).toMatchObject([
|
||||
{
|
||||
kind: "claude-cli",
|
||||
credentials: true,
|
||||
detail: "logged in · Claude subscription",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps an Anthropic environment key ahead of unknown Claude status", async () => {
|
||||
const candidates = await detectInferenceBackends({
|
||||
env: { ANTHROPIC_API_KEY: "sk-y" },
|
||||
platform: "darwin",
|
||||
deps: {
|
||||
probeLocalCommand: probeDeps({ claude: true }),
|
||||
readClaudeCliCredentials: () => null,
|
||||
detectClaudeLoginState: async () => ({ credentials: undefined }),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -258,7 +264,7 @@ describe("detectInferenceBackends", () => {
|
||||
platform: "linux",
|
||||
deps: {
|
||||
probeLocalCommand: probeDeps({ claude: true, codex: true, gemini: true }),
|
||||
readClaudeCliCredentials: () => null,
|
||||
detectClaudeLoginState: async () => ({ credentials: false }),
|
||||
readCodexCliCredentials: () => ({ type: "oauth" }),
|
||||
readGeminiCliCredentials: () => null,
|
||||
},
|
||||
@@ -288,7 +294,7 @@ describe("detectInferenceBackends", () => {
|
||||
platform: "linux",
|
||||
deps: {
|
||||
probeLocalCommand: probeDeps({}),
|
||||
readClaudeCliCredentials: () => null,
|
||||
detectClaudeLoginState: async () => ({ credentials: false }),
|
||||
readCodexCliCredentials: () => null,
|
||||
},
|
||||
});
|
||||
@@ -313,7 +319,7 @@ describe("detectInferenceBackends", () => {
|
||||
platform: "linux",
|
||||
deps: {
|
||||
probeLocalCommand: probeDeps({}),
|
||||
readClaudeCliCredentials: () => null,
|
||||
detectClaudeLoginState: async () => ({ credentials: false }),
|
||||
readCodexCliCredentials: () => null,
|
||||
},
|
||||
});
|
||||
@@ -329,7 +335,7 @@ describe("detectInferenceBackends", () => {
|
||||
platform: "linux",
|
||||
deps: {
|
||||
probeLocalCommand: probeDeps({ claude: true, codex: true }),
|
||||
readClaudeCliCredentials: () => null,
|
||||
detectClaudeLoginState: async () => ({ credentials: false }),
|
||||
readCodexCliCredentials: () => ({ type: "oauth" }),
|
||||
},
|
||||
});
|
||||
@@ -347,7 +353,7 @@ describe("detectInferenceBackends", () => {
|
||||
platform: "linux",
|
||||
deps: {
|
||||
probeLocalCommand: probeDeps({ claude: true, codex: true, gemini: true }),
|
||||
readClaudeCliCredentials: () => null,
|
||||
detectClaudeLoginState: async () => ({ credentials: false }),
|
||||
readCodexCliCredentials: () => null,
|
||||
readGeminiCliCredentials: () => null,
|
||||
},
|
||||
@@ -442,7 +448,10 @@ describe("detectInferenceBackends", () => {
|
||||
platform: "linux",
|
||||
deps: {
|
||||
probeLocalCommand: probeDeps({ claude: true, codex: true }),
|
||||
readClaudeCliCredentials: () => ({ type: "oauth" }),
|
||||
detectClaudeLoginState: async () => ({
|
||||
credentials: true,
|
||||
authKind: "claude-subscription",
|
||||
}),
|
||||
readCodexCliCredentials: () => ({ type: "oauth" }),
|
||||
randomInt: () => pick,
|
||||
},
|
||||
@@ -458,13 +467,13 @@ describe("detectInferenceBackends", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats missing file credentials as unknown on macOS (keychain may hold the login)", async () => {
|
||||
it("keeps an unverified Claude status unknown", async () => {
|
||||
const candidates = await detectInferenceBackends({
|
||||
env: {},
|
||||
platform: "darwin",
|
||||
deps: {
|
||||
probeLocalCommand: probeDeps({ claude: true }),
|
||||
readClaudeCliCredentials: () => null,
|
||||
detectClaudeLoginState: async () => ({ credentials: undefined }),
|
||||
readCodexCliCredentials: () => null,
|
||||
},
|
||||
});
|
||||
@@ -518,7 +527,7 @@ describe("detectInferenceBackends", () => {
|
||||
platform: "darwin",
|
||||
deps: {
|
||||
probeLocalCommand: probeDeps({ [appCli]: true }),
|
||||
readClaudeCliCredentials: () => null,
|
||||
detectClaudeLoginState: async () => ({ credentials: false }),
|
||||
readCodexCliCredentials: () => null,
|
||||
},
|
||||
});
|
||||
@@ -545,7 +554,7 @@ describe("detectInferenceBackends", () => {
|
||||
found: command === chatGPTCli || command === legacyCodexCli,
|
||||
};
|
||||
},
|
||||
readClaudeCliCredentials: () => null,
|
||||
detectClaudeLoginState: async () => ({ credentials: false }),
|
||||
readCodexCliCredentials: () => null,
|
||||
},
|
||||
});
|
||||
@@ -561,7 +570,7 @@ describe("detectInferenceBackends", () => {
|
||||
platform: "linux",
|
||||
deps: {
|
||||
probeLocalCommand: probeDeps({}),
|
||||
readClaudeCliCredentials: () => null,
|
||||
detectClaudeLoginState: async () => ({ credentials: false }),
|
||||
readCodexCliCredentials: () => null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -5,7 +5,6 @@ import path from "node:path";
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
import { resolveAgentConfig } from "../agents/agent-scope-config.js";
|
||||
import {
|
||||
readClaudeCliCredentialsCached,
|
||||
readCodexCliCredentialsCached,
|
||||
readGeminiCliCredentialsCached,
|
||||
} from "../agents/cli-credentials.js";
|
||||
@@ -41,7 +40,7 @@ export {
|
||||
|
||||
type DetectInferenceBackendsDeps = {
|
||||
probeLocalCommand?: typeof probeLocalCommand;
|
||||
readClaudeCliCredentials?: () => { type: string } | null;
|
||||
detectClaudeLoginState?: typeof detectClaudeLoginState;
|
||||
readCodexCliCredentials?: () => { type: string } | null;
|
||||
readGeminiCliCredentials?: () => { type: string } | null;
|
||||
detectCodexLoginState?: typeof detectCodexLoginState;
|
||||
@@ -101,19 +100,6 @@ function describeCliDetail(state: CliLoginState, loginHint: string): string {
|
||||
return "installed";
|
||||
}
|
||||
|
||||
function classifyClaudeCliAuth(
|
||||
credential: { type: string } | null,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): CliAuthKind | undefined {
|
||||
if (env.ANTHROPIC_API_KEY?.trim() || credential?.type === "api_key_helper") {
|
||||
return "api-key";
|
||||
}
|
||||
if (credential?.type === "oauth" || credential?.type === "token") {
|
||||
return "claude-subscription";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function describeGeminiCliDetail(credentials: boolean | undefined): string {
|
||||
return credentials === true
|
||||
? "installed; credentials found"
|
||||
@@ -139,6 +125,30 @@ async function classifyCodexLoginStatus(
|
||||
return { credentials: true };
|
||||
}
|
||||
|
||||
async function detectClaudeLoginState(
|
||||
probe: typeof probeLocalCommand,
|
||||
command: string,
|
||||
): Promise<CliLoginState> {
|
||||
const status = await probe(command, ["auth", "status", "--text"], { timeoutMs: 3_000 });
|
||||
if (status.timedOut) {
|
||||
return { credentials: undefined };
|
||||
}
|
||||
if (status.error) {
|
||||
return { credentials: false };
|
||||
}
|
||||
const method = status.version?.replace(/^Login method:\s*/iu, "").trim();
|
||||
return {
|
||||
credentials: true,
|
||||
...(method
|
||||
? {
|
||||
authKind: /api\s*key/iu.test(method)
|
||||
? ("api-key" as const)
|
||||
: ("claude-subscription" as const),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
// Deliberately boolean-shaped: this signature is reachable from the exported
|
||||
// detectInferenceBackends options type and therefore part of the plugin-sdk
|
||||
// agent-harness API contract. Widening it would bump the contract hash — the
|
||||
@@ -219,9 +229,6 @@ export async function detectInferenceBackends(
|
||||
const env = options.env ?? process.env;
|
||||
const platform = options.platform ?? process.platform;
|
||||
const probe = options.deps?.probeLocalCommand ?? probeLocalCommand;
|
||||
const readClaude =
|
||||
options.deps?.readClaudeCliCredentials ??
|
||||
(() => readClaudeCliCredentialsCached({ allowKeychainPrompt: false, ttlMs: 60_000 }));
|
||||
const readCodex =
|
||||
options.deps?.readCodexCliCredentials ??
|
||||
(() => readCodexCliCredentialsCached({ allowKeychainPrompt: false, ttlMs: 60_000 }));
|
||||
@@ -268,19 +275,14 @@ export async function detectInferenceBackends(
|
||||
const cliCandidates: InferenceBackendCandidate[] = [];
|
||||
const subscriptionPromotionEligibleCliKinds = new Set<InferenceBackendKind>();
|
||||
if (claudeProbe.found && !claudeProbe.timedOut) {
|
||||
const claudeCredential = readClaude();
|
||||
const credentials = detectCliCredentialState({
|
||||
probe: claudeProbe,
|
||||
hasStoredCredentials: claudeCredential !== null,
|
||||
platform,
|
||||
});
|
||||
if (credentials === true && claudeCredential?.type === "oauth") {
|
||||
const loginState = options.deps?.detectClaudeLoginState
|
||||
? await options.deps.detectClaudeLoginState(probe, claudeProbe.command)
|
||||
: await detectClaudeLoginState(probe, claudeProbe.command);
|
||||
const credentials = loginState.credentials;
|
||||
if (credentials === true && loginState.authKind === "claude-subscription") {
|
||||
subscriptionPromotionEligibleCliKinds.add("claude-cli");
|
||||
}
|
||||
const detail = describeCliDetail(
|
||||
{ credentials, authKind: classifyClaudeCliAuth(claudeCredential, env) },
|
||||
"run `claude auth login`",
|
||||
);
|
||||
const detail = describeCliDetail(loginState, "run `claude auth login`");
|
||||
cliCandidates.push({
|
||||
kind: "claude-cli",
|
||||
modelRef: CLAUDE_CLI_DEFAULT_MODEL_REF,
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { runWriteConfigHealth } from "./doctor-health-contribution-runners.config.js";
|
||||
import type { DoctorHealthFlowContext } from "./doctor-health-contribution-types.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
removeAuthProfilesAcrossOwnerStores: vi.fn(async () => true),
|
||||
replaceConfigFile: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("../agents/auth-profiles.js", () => ({
|
||||
removeAuthProfilesAcrossOwnerStores: mocks.removeAuthProfilesAcrossOwnerStores,
|
||||
}));
|
||||
|
||||
vi.mock("../config/config.js", () => ({
|
||||
replaceConfigFile: mocks.replaceConfigFile,
|
||||
}));
|
||||
|
||||
vi.mock("../config/logging.js", () => ({
|
||||
logConfigUpdated: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../commands/onboard-helpers.js", () => ({
|
||||
applyWizardMetadata: (cfg: OpenClawConfig) => cfg,
|
||||
}));
|
||||
|
||||
function createContext(): DoctorHealthFlowContext {
|
||||
const cfg = { gateway: { mode: "local" } } satisfies OpenClawConfig;
|
||||
return {
|
||||
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
|
||||
options: {},
|
||||
prompter: {} as DoctorHealthFlowContext["prompter"],
|
||||
configResult: {
|
||||
cfg,
|
||||
retiredAuthProfileCleanupPlans: [
|
||||
{ agentDir: "/tmp/openclaw/agents/main", profileIds: ["anthropic:claude-cli"] },
|
||||
],
|
||||
},
|
||||
cfg,
|
||||
cfgForPersistence: {},
|
||||
sourceConfigValid: true,
|
||||
configPath: "/tmp/openclaw.json",
|
||||
};
|
||||
}
|
||||
|
||||
describe("Doctor retired auth profile cleanup", () => {
|
||||
beforeEach(() => {
|
||||
mocks.removeAuthProfilesAcrossOwnerStores.mockClear().mockResolvedValue(true);
|
||||
mocks.replaceConfigFile.mockClear().mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("removes retired profiles only after the repaired config commits", async () => {
|
||||
await runWriteConfigHealth(createContext());
|
||||
|
||||
expect(mocks.replaceConfigFile).toHaveBeenCalledOnce();
|
||||
expect(mocks.removeAuthProfilesAcrossOwnerStores).toHaveBeenCalledWith({
|
||||
agentDir: "/tmp/openclaw/agents/main",
|
||||
profileIds: ["anthropic:claude-cli"],
|
||||
});
|
||||
expect(mocks.replaceConfigFile.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mocks.removeAuthProfilesAcrossOwnerStores.mock.invocationCallOrder[0]!,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps retired profiles when the repaired config write fails", async () => {
|
||||
mocks.replaceConfigFile.mockRejectedValueOnce(new Error("write failed"));
|
||||
|
||||
await expect(runWriteConfigHealth(createContext())).rejects.toThrow("write failed");
|
||||
|
||||
expect(mocks.removeAuthProfilesAcrossOwnerStores).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -67,7 +67,7 @@ export async function runWriteConfigHealth(
|
||||
allowConfigSizeDrop: ctx.configResult.shouldWriteConfig === true || updateDoctorRun,
|
||||
skipPluginValidation:
|
||||
ctx.configResult.skipPluginValidationOnWrite === true || updateDoctorRun,
|
||||
...(configResultWritePending && ctx.configResult.explicitSetPaths
|
||||
...(ctx.configResult.explicitSetPaths
|
||||
? { explicitSetPaths: ctx.configResult.explicitSetPaths }
|
||||
: {}),
|
||||
preservedLegacyRootKeys: ctx.configResult.preservedLegacyRootKeys,
|
||||
@@ -147,6 +147,16 @@ export async function runWriteConfigHealth(
|
||||
if (options.runPostWriteRepairs === false) {
|
||||
return;
|
||||
}
|
||||
const retiredAuthProfileCleanupPlans = ctx.configResult.retiredAuthProfileCleanupPlans;
|
||||
if (retiredAuthProfileCleanupPlans?.length) {
|
||||
const { removeAuthProfilesAcrossOwnerStores } = await import("../agents/auth-profiles.js");
|
||||
for (const plan of retiredAuthProfileCleanupPlans) {
|
||||
if (!(await removeAuthProfilesAcrossOwnerStores(plan))) {
|
||||
throw new Error(`Failed to remove retired auth profile "${plan.profileIds.join(", ")}".`);
|
||||
}
|
||||
}
|
||||
delete ctx.configResult.retiredAuthProfileCleanupPlans;
|
||||
}
|
||||
if (ctx.configResult.retiredPhoneControlStateCleanupPending === true) {
|
||||
const { finalizeRetiredPhoneControlCleanup } =
|
||||
await import("../commands/doctor-retired-phone-control.js");
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { RetiredAuthProfileCleanupPlan } from "../commands/doctor-auth-legacy-oauth.js";
|
||||
import type { probeGatewayMemoryStatus } from "../commands/doctor-gateway-health.js";
|
||||
import type { DoctorOptions, DoctorPrompter } from "../commands/doctor-prompter.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
@@ -23,6 +24,8 @@ type DoctorConfigResult = {
|
||||
preservedLegacyRootKeys?: readonly string[];
|
||||
shouldRepairCronCodexModelRefsAfterConfigWrite?: boolean;
|
||||
retiredPhoneControlStateCleanupPending?: boolean;
|
||||
/** Store cleanup deferred until the repaired config reaches disk. */
|
||||
retiredAuthProfileCleanupPlans?: readonly RetiredAuthProfileCleanupPlan[];
|
||||
blockedCodexModelIdentities?: readonly string[];
|
||||
/** Ephemeral doctor-only auth rename plan; never part of persisted config. */
|
||||
openAICodexAuthProfileIdMap?: ReadonlyMap<string, string>;
|
||||
|
||||
@@ -49,7 +49,10 @@ const mocks = vi.hoisted(() => ({
|
||||
warnings: [],
|
||||
})),
|
||||
maybeRepairGatewayDaemon: vi.fn().mockResolvedValue(undefined),
|
||||
maybeRepairLegacyOAuthProfileIds: vi.fn(async (cfg: unknown) => cfg),
|
||||
maybeRepairLegacyOAuthProfileIds: vi.fn(async (cfg: unknown) => ({
|
||||
config: cfg,
|
||||
retiredProfileCleanupPlans: [],
|
||||
})),
|
||||
maybeRepairLegacyOAuthSidecarProfiles: vi.fn().mockResolvedValue(undefined),
|
||||
collectAuthProfileHealthFindings: vi.fn(async () => []),
|
||||
noteAuthProfileHealth: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -648,9 +651,10 @@ describe("doctor health contributions", () => {
|
||||
warnings: [],
|
||||
});
|
||||
mocks.maybeRepairGatewayDaemon.mockClear().mockResolvedValue(undefined);
|
||||
mocks.maybeRepairLegacyOAuthProfileIds
|
||||
.mockClear()
|
||||
.mockImplementation(async (cfg: unknown) => cfg);
|
||||
mocks.maybeRepairLegacyOAuthProfileIds.mockClear().mockImplementation(async (cfg: unknown) => ({
|
||||
config: cfg,
|
||||
retiredProfileCleanupPlans: [],
|
||||
}));
|
||||
mocks.collectLegacyPluginManifestContractMigrations.mockReset().mockReturnValue([]);
|
||||
mocks.legacyPluginManifestContractMigrationToHealthFinding.mockClear();
|
||||
mocks.maybeRepairLegacyPluginManifestContracts.mockClear().mockResolvedValue(undefined);
|
||||
@@ -2211,6 +2215,39 @@ describe("doctor health contributions", () => {
|
||||
prompter: ctx.prompter,
|
||||
runtime: ctx.runtime,
|
||||
});
|
||||
expect(mocks.maybeRepairLegacyOAuthProfileIds.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mocks.maybeMigrateModelCatalogCredentials.mock.invocationCallOrder[0]!,
|
||||
);
|
||||
});
|
||||
|
||||
it("persists provider runtime mappings added while removing retired auth profiles", async () => {
|
||||
const contribution = requireDoctorContribution("doctor:auth-profiles");
|
||||
const cfg = {
|
||||
agents: { defaults: { models: { "anthropic/claude-sonnet-4-6": {} } } },
|
||||
};
|
||||
mocks.maybeRepairLegacyOAuthProfileIds.mockResolvedValue({
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"anthropic/claude-sonnet-4-6": { agentRuntime: { id: "claude-cli" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
retiredProfileCleanupPlans: [],
|
||||
});
|
||||
const ctx = createDoctorHealthFlowContext({
|
||||
cfg,
|
||||
sourceConfigValid: true,
|
||||
prompter: buildDoctorPrompter(true),
|
||||
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
|
||||
options: { nonInteractive: true },
|
||||
});
|
||||
|
||||
await contribution.run(ctx);
|
||||
|
||||
expect(ctx.configResult.explicitSetPaths).toContainEqual(["agents", "defaults", "models"]);
|
||||
});
|
||||
|
||||
it("registers auth profile health as an opt-in structured check", async () => {
|
||||
@@ -4035,7 +4072,7 @@ describe("doctor health contributions", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards explicit paths only for the pending doctor migration write", async () => {
|
||||
it("forwards explicit paths through later doctor repair writes", async () => {
|
||||
const ctx = buildWriteConfigCtx({});
|
||||
ctx.configResult.explicitSetPaths = [["agents", "entries"]];
|
||||
|
||||
@@ -4057,8 +4094,8 @@ describe("doctor health contributions", () => {
|
||||
expect(mocks.replaceConfigFile).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
writeOptions: expect.not.objectContaining({
|
||||
explicitSetPaths: expect.anything(),
|
||||
writeOptions: expect.objectContaining({
|
||||
explicitSetPaths: [["agents", "entries"]],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Doctor health contributions preserve the ordered interactive doctor flow while
|
||||
// exposing the same checks to structured lint and repair commands.
|
||||
import fs from "node:fs";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { isGatewayHostServiceEnvironment } from "../infra/gateway-supervision.js";
|
||||
import { scrubDoctorErrorMessage } from "./doctor-error-message.js";
|
||||
import { hasActiveGatewayExecCredential } from "./doctor-gateway-exec-credential.js";
|
||||
@@ -87,6 +88,21 @@ async function runAuthProfileHealth(ctx: DoctorHealthFlowContext): Promise<void>
|
||||
prompter: ctx.prompter,
|
||||
runtime: ctx.runtime,
|
||||
});
|
||||
const modelsBeforeRepair = ctx.cfg.agents?.defaults?.models;
|
||||
const legacyOAuthRepair = await maybeRepairLegacyOAuthProfileIds(ctx.cfg, ctx.prompter);
|
||||
ctx.cfg = legacyOAuthRepair.config;
|
||||
if (legacyOAuthRepair.retiredProfileCleanupPlans.length > 0) {
|
||||
ctx.configResult.retiredAuthProfileCleanupPlans = [
|
||||
...(ctx.configResult.retiredAuthProfileCleanupPlans ?? []),
|
||||
...legacyOAuthRepair.retiredProfileCleanupPlans,
|
||||
];
|
||||
}
|
||||
if (!isDeepStrictEqual(modelsBeforeRepair, ctx.cfg.agents?.defaults?.models)) {
|
||||
ctx.configResult.explicitSetPaths = [
|
||||
...(ctx.configResult.explicitSetPaths ?? []),
|
||||
["agents", "defaults", "models"],
|
||||
];
|
||||
}
|
||||
const { maybeMigrateModelCatalogCredentials } =
|
||||
await import("../commands/doctor-model-catalog-credentials.js");
|
||||
await maybeMigrateModelCatalogCredentials({
|
||||
@@ -95,7 +111,6 @@ async function runAuthProfileHealth(ctx: DoctorHealthFlowContext): Promise<void>
|
||||
prompter: ctx.prompter,
|
||||
runtime: ctx.runtime,
|
||||
});
|
||||
ctx.cfg = await maybeRepairLegacyOAuthProfileIds(ctx.cfg, ctx.prompter);
|
||||
await noteAuthProfileHealth({
|
||||
cfg: ctx.cfg,
|
||||
prompter: ctx.prompter,
|
||||
|
||||
@@ -4683,7 +4683,7 @@ async function resolveGatewayLiveRequestedModels(): Promise<string | undefined>
|
||||
platform: "linux",
|
||||
deps: {
|
||||
probeLocalCommand: async (command) => ({ command, found: false }),
|
||||
readClaudeCliCredentials: () => null,
|
||||
detectClaudeLoginState: async () => ({ credentials: false }),
|
||||
readCodexCliCredentials: () => null,
|
||||
readGeminiCliCredentials: () => null,
|
||||
},
|
||||
|
||||
@@ -21,10 +21,6 @@ import {
|
||||
removeProviderAuthProfilesWithLock,
|
||||
resolvePersistedAuthProfileOwnerAgentDir,
|
||||
} from "../../agents/auth-profiles.js";
|
||||
import {
|
||||
listConfiguredExternalCliProfileMetadataIds,
|
||||
normalizeExternalCliProfileMetadata,
|
||||
} from "../../agents/auth-profiles/external-cli-profile-metadata.js";
|
||||
import { getRuntimeExternalCliProfileIds } from "../../agents/auth-profiles/runtime-external-profile-references.js";
|
||||
import {
|
||||
isNonSecretApiKeyMarker,
|
||||
@@ -434,43 +430,6 @@ function resolveConfiguredProviders(
|
||||
return { providers: Array.from(out), expectsOAuth, directModelAuthProviders };
|
||||
}
|
||||
|
||||
function resolveLegacyExternalCliAliasProfileIds(
|
||||
cfg: OpenClawConfig,
|
||||
directModelAuthProviders: ReadonlySet<string>,
|
||||
): Map<string, string> {
|
||||
const profiles = cfg.auth?.profiles;
|
||||
const aliases = new Map<string, string>();
|
||||
for (const profileId of listConfiguredExternalCliProfileMetadataIds(profiles)) {
|
||||
const profile = profiles?.[profileId];
|
||||
const canonical = normalizeExternalCliProfileMetadata(profileId, profile);
|
||||
if (!profile || !canonical) {
|
||||
continue;
|
||||
}
|
||||
const provider = normalizeProviderId(profile.provider);
|
||||
const hasIndependentAuthProfile = Object.entries(profiles ?? {}).some(
|
||||
([otherProfileId, otherProfile]) =>
|
||||
otherProfileId !== profileId &&
|
||||
normalizeProviderId(otherProfile?.provider) === provider &&
|
||||
(otherProfile?.mode === "oauth" || otherProfile?.mode === "token"),
|
||||
);
|
||||
const hasIndependentAuthOrder = Object.entries(cfg.auth?.order ?? {}).some(
|
||||
([orderProvider, orderedProfileIds]) =>
|
||||
normalizeProviderId(orderProvider) === provider &&
|
||||
orderedProfileIds.some((orderedProfileId) => orderedProfileId !== profileId),
|
||||
);
|
||||
if (
|
||||
provider &&
|
||||
provider !== canonical.provider &&
|
||||
!directModelAuthProviders.has(provider) &&
|
||||
!hasIndependentAuthProfile &&
|
||||
!hasIndependentAuthOrder
|
||||
) {
|
||||
aliases.set(provider, profileId);
|
||||
}
|
||||
}
|
||||
return aliases;
|
||||
}
|
||||
|
||||
export const modelsAuthStatusHandlers: GatewayRequestHandlers = {
|
||||
"models.authLogout": async ({ params, respond, context }) => {
|
||||
const provider = readProviderParam(params);
|
||||
@@ -686,10 +645,6 @@ export const modelsAuthStatusHandlers: GatewayRequestHandlers = {
|
||||
.map(([profileId]) => profileId),
|
||||
);
|
||||
const configBoundProfileIds = resolveConfigBoundProfileIds(cfg, store, authAliasLookupParams);
|
||||
const legacyExternalCliAliasProfileIds = resolveLegacyExternalCliAliasProfileIds(
|
||||
cfg,
|
||||
configured.directModelAuthProviders,
|
||||
);
|
||||
const providers = suppressSyntheticAliasRowsCoveredByExternalCli(
|
||||
authHealth.providers.map((prov) =>
|
||||
mapProvider(
|
||||
@@ -703,7 +658,7 @@ export const modelsAuthStatusHandlers: GatewayRequestHandlers = {
|
||||
),
|
||||
),
|
||||
externalCliProfileIds,
|
||||
legacyExternalCliAliasProfileIds,
|
||||
new Map(),
|
||||
);
|
||||
const providerCapabilities = buildProviderCapabilities({
|
||||
config: cfg,
|
||||
|
||||
@@ -225,6 +225,57 @@ describe("resolveProviderAuths plugin boundary", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("excludes native credential providers from plugin OAuth resolution", async () => {
|
||||
const store = {
|
||||
profiles: {
|
||||
"anthropic:claude-cli": {
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: "native-access",
|
||||
refresh: "native-refresh",
|
||||
expires: Date.now() + 60_000,
|
||||
},
|
||||
"anthropic:managed": {
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: "managed-access",
|
||||
refresh: "managed-refresh",
|
||||
expires: Date.now() + 60_000,
|
||||
},
|
||||
},
|
||||
};
|
||||
ensureAuthProfileStoreMock.mockReturnValue(store as never);
|
||||
hasAnyAuthProfileStoreSourceMock.mockReturnValue(true);
|
||||
ensureAuthProfileStoreWithoutExternalProfilesMock.mockReturnValue(store as never);
|
||||
resolveAuthProfileOrderMock.mockReturnValue(["anthropic:claude-cli", "anthropic:managed"]);
|
||||
resolveApiKeyForProfileMock.mockImplementation(async (params) => {
|
||||
const profileId = (params as { profileId: string }).profileId;
|
||||
return profileId === "anthropic:managed"
|
||||
? { apiKey: "managed-access", provider: "anthropic" }
|
||||
: { apiKey: "native-access", provider: "claude-cli" };
|
||||
});
|
||||
resolveProviderUsageAuthWithPluginMock.mockImplementationOnce(async (rawParams) => {
|
||||
const params = rawParams as {
|
||||
context: {
|
||||
resolveOAuthToken: (options: {
|
||||
excludeProfileIds: string[];
|
||||
}) => Promise<{ token: string } | null>;
|
||||
};
|
||||
};
|
||||
return params.context.resolveOAuthToken({
|
||||
excludeProfileIds: ["anthropic:claude-cli"],
|
||||
});
|
||||
});
|
||||
|
||||
await expect(resolveProviderAuthsForTest({ providers: ["anthropic"] })).resolves.toEqual([
|
||||
{ provider: "anthropic", token: "managed-access" },
|
||||
]);
|
||||
expect(resolveApiKeyForProfileMock).toHaveBeenCalledTimes(1);
|
||||
expect(resolveApiKeyForProfileMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ profileId: "anthropic:managed" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not synthesize Codex app-server auth for generic OpenAI usage", async () => {
|
||||
await withTempHome(async (homeDir) => {
|
||||
await expect(
|
||||
|
||||
@@ -297,6 +297,7 @@ function resolveUsageCredentialProviderIds(params: {
|
||||
async function resolveOAuthToken(params: {
|
||||
state: UsageAuthState;
|
||||
provider: string;
|
||||
excludeProfileIds?: string[];
|
||||
}): Promise<ProviderAuth | null> {
|
||||
if (!params.state.allowAuthProfileStore) {
|
||||
return null;
|
||||
@@ -308,8 +309,12 @@ async function resolveOAuthToken(params: {
|
||||
provider: params.provider,
|
||||
});
|
||||
const deduped = dedupeProfileIds(order);
|
||||
const excludedProfileIds = new Set(params.excludeProfileIds ?? []);
|
||||
|
||||
for (const profileId of deduped) {
|
||||
if (excludedProfileIds.has(profileId)) {
|
||||
continue;
|
||||
}
|
||||
const cred = store.profiles[profileId];
|
||||
if (!cred || (cred.type !== "oauth" && cred.type !== "token")) {
|
||||
continue;
|
||||
@@ -385,6 +390,7 @@ async function resolveProviderUsageAuthViaPlugin(params: {
|
||||
const auth = await resolveOAuthToken({
|
||||
state: params.state,
|
||||
provider: options?.provider ?? params.provider,
|
||||
excludeProfileIds: options?.excludeProfileIds,
|
||||
});
|
||||
return auth
|
||||
? {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/** Retired Claude CLI credential shape kept only for source compatibility. */
|
||||
export type ClaudeCliCredential =
|
||||
| {
|
||||
type: "oauth";
|
||||
provider: "anthropic";
|
||||
access: string;
|
||||
refresh: string;
|
||||
expires: number;
|
||||
subscriptionType?: string;
|
||||
rateLimitTier?: string;
|
||||
email?: string;
|
||||
}
|
||||
| {
|
||||
type: "token";
|
||||
provider: "anthropic";
|
||||
token: string;
|
||||
expires: number;
|
||||
subscriptionType?: string;
|
||||
rateLimitTier?: string;
|
||||
email?: string;
|
||||
}
|
||||
| {
|
||||
type: "api_key_helper";
|
||||
provider: "anthropic";
|
||||
helperHash: string;
|
||||
};
|
||||
|
||||
export type ClaudeCliCredentialReadOptions = {
|
||||
allowKeychainPrompt?: boolean;
|
||||
tryKeychainWithoutPrompt?: boolean;
|
||||
onStoredCredentialUnreadable?: () => void;
|
||||
ttlMs?: number;
|
||||
platform?: NodeJS.Platform;
|
||||
homeDir?: string;
|
||||
execSync?: typeof import("node:child_process").execSync;
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated Claude CLI owns its native login. This returns null without reading credentials.
|
||||
* Scheduled for removal after v2026.10.
|
||||
*/
|
||||
export function readClaudeCliCredentialsCached(
|
||||
_options?: ClaudeCliCredentialReadOptions,
|
||||
): ClaudeCliCredential | null {
|
||||
return null;
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
deriveCopilotApiBaseUrlFromToken,
|
||||
isProviderApiKeyConfigured,
|
||||
normalizeGithubCopilotDomain,
|
||||
readClaudeCliCredentialsCached,
|
||||
removeProviderAuthProfilesWithLock,
|
||||
resolveCopilotApiToken,
|
||||
} from "./provider-auth.js";
|
||||
@@ -31,6 +32,13 @@ 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", () => {
|
||||
const onStoredCredentialUnreadable = vi.fn();
|
||||
|
||||
expect(readClaudeCliCredentialsCached({ onStoredCredentialUnreadable })).toBeNull();
|
||||
expect(onStoredCredentialUnreadable).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
async function withPartialCopilotResponse(run: (port: number) => Promise<void>): Promise<void> {
|
||||
|
||||
@@ -68,10 +68,12 @@ export {
|
||||
upsertAuthProfileWithLockCompat as upsertAuthProfileWithLock,
|
||||
} from "./provider-auth-write-compat.js";
|
||||
export { resolveEnvApiKey } from "../agents/model-auth-env.js";
|
||||
export { readCodexCliCredentialsCached } from "../agents/cli-credentials.js";
|
||||
export {
|
||||
type ClaudeCliCredential,
|
||||
type ClaudeCliCredentialReadOptions,
|
||||
readClaudeCliCredentialsCached,
|
||||
readCodexCliCredentialsCached,
|
||||
} from "../agents/cli-credentials.js";
|
||||
} from "./provider-auth-claude-compat.js";
|
||||
export { suggestOAuthProfileIdForLegacyDefault } from "../agents/auth-profiles/repair.js";
|
||||
export {
|
||||
CUSTOM_LOCAL_AUTH_MARKER,
|
||||
|
||||
@@ -237,20 +237,23 @@ export function applyAuthProfileConfig(
|
||||
export function configReferencesAuthProfile(cfg: OpenClawConfig, profileId: string): boolean {
|
||||
return (
|
||||
Boolean(cfg.auth?.profiles?.[profileId]) ||
|
||||
Object.values(cfg.auth?.order ?? {}).some((order) => order.includes(profileId))
|
||||
Object.values(cfg.auth?.order ?? {}).some((order) => order.includes(profileId)) ||
|
||||
Object.values(cfg.models?.providers ?? {}).some((provider) => provider.apiKey === profileId)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Counterpart to {@link applyAuthProfileConfig}: drops a profile from
|
||||
* `auth.profiles` and every `auth.order` list. An emptied provider order is
|
||||
* deleted rather than left as `[]`, because an authored empty order is a hard
|
||||
* "select no profiles" instruction and would disable the provider entirely.
|
||||
* Drops a profile from `auth.profiles`, every `auth.order` list, and provider-entry
|
||||
* `apiKey` references. An emptied provider order is deleted rather than left as
|
||||
* `[]`, because an authored empty order is a hard "select no profiles" instruction.
|
||||
*/
|
||||
export function removeAuthProfileConfig(cfg: OpenClawConfig, profileId: string): OpenClawConfig {
|
||||
if (!configReferencesAuthProfile(cfg, profileId)) {
|
||||
return cfg;
|
||||
}
|
||||
const authReferencesProfile =
|
||||
Boolean(cfg.auth?.profiles?.[profileId]) ||
|
||||
Object.values(cfg.auth?.order ?? {}).some((providerOrder) => providerOrder.includes(profileId));
|
||||
const profiles = Object.fromEntries(
|
||||
Object.entries(cfg.auth?.profiles ?? {}).filter(([id]) => id !== profileId),
|
||||
);
|
||||
@@ -268,13 +271,27 @@ export function removeAuthProfileConfig(cfg: OpenClawConfig, profileId: string):
|
||||
{},
|
||||
);
|
||||
const { order: _droppedOrder, ...auth } = cfg.auth ?? {};
|
||||
const providers = Object.fromEntries(
|
||||
Object.entries(cfg.models?.providers ?? {}).map(([providerId, provider]) => {
|
||||
if (provider.apiKey !== profileId) {
|
||||
return [providerId, provider];
|
||||
}
|
||||
const { apiKey: _droppedApiKey, ...nextProvider } = provider;
|
||||
return [providerId, nextProvider];
|
||||
}),
|
||||
);
|
||||
return {
|
||||
...cfg,
|
||||
auth: {
|
||||
...auth,
|
||||
profiles,
|
||||
...(Object.keys(order).length > 0 ? { order } : {}),
|
||||
},
|
||||
...(authReferencesProfile
|
||||
? {
|
||||
auth: {
|
||||
...auth,
|
||||
profiles,
|
||||
...(Object.keys(order).length > 0 ? { order } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(cfg.models?.providers ? { models: { ...cfg.models, providers } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -553,11 +553,12 @@ export type ProviderPlugin = {
|
||||
*/
|
||||
loginOAuth?: (callbacks: OAuthLoginCallbacks) => Promise<SessionOAuthCredentials>;
|
||||
/**
|
||||
* Legacy auth-profile ids that should be retired by `openclaw doctor`.
|
||||
* Legacy auth-profile ids that generic auth must ignore and `openclaw doctor` should remove.
|
||||
*
|
||||
* Use this when a provider plugin replaces an older core-managed profile id
|
||||
* and wants cleanup/migration messaging to live with the provider instead of
|
||||
* in hardcoded doctor tables.
|
||||
* in hardcoded doctor tables. A runtime-only external CLI profile remains usable by its exact
|
||||
* provider when it intentionally reuses a retired id.
|
||||
*/
|
||||
deprecatedProfileIds?: string[];
|
||||
/**
|
||||
|
||||
@@ -892,6 +892,16 @@ export function resolveProviderModernModelRef(params: {
|
||||
return resolveProviderRuntimePlugin(params)?.isModernModelRef?.(params.context);
|
||||
}
|
||||
|
||||
/** Returns provider-owned profile ids retired from generic credential resolution. */
|
||||
export function resolveProviderDeprecatedAuthProfileIds(params: {
|
||||
provider: string;
|
||||
config?: OpenClawConfig;
|
||||
workspaceDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): readonly string[] {
|
||||
return resolveProviderRuntimePlugin(params)?.deprecatedProfileIds ?? [];
|
||||
}
|
||||
|
||||
export function buildProviderMissingAuthMessageWithPlugin(params: {
|
||||
provider: string;
|
||||
config?: OpenClawConfig;
|
||||
|
||||
@@ -164,7 +164,10 @@ export type ProviderResolveUsageAuthContext = {
|
||||
providerIds?: string[];
|
||||
envDirect?: Array<string | undefined>;
|
||||
}) => Promise<string[]>;
|
||||
resolveOAuthToken: (params?: { provider?: string }) => Promise<ProviderUsageAuthToken | null>;
|
||||
resolveOAuthToken: (params?: {
|
||||
provider?: string;
|
||||
excludeProfileIds?: string[];
|
||||
}) => Promise<ProviderUsageAuthToken | null>;
|
||||
};
|
||||
|
||||
export type ProviderUsageAuthToken = {
|
||||
|
||||
Reference in New Issue
Block a user