fix(auth): scope external CLI auth status overlays (#74156)

* fix(auth): scope external CLI auth status overlays

* fix: pass external auth config to overlays

* fix(auth): keep no-prompt CLI reads file-only

* docs: update clawsweeper app wording
This commit is contained in:
Peter Steinberger
2026-04-29 12:23:50 +01:00
committed by GitHub
parent 8f6c72823e
commit f79553bef6
14 changed files with 251 additions and 32 deletions

View File

@@ -24,12 +24,12 @@ read-only report work read-only unless Peter asked to commit.
## One Bot, One App
Use the ClawSweeper repo and the `openclaw-ci` GitHub App. Use only
Use the ClawSweeper repo and the `clawsweeper` GitHub App. Use only
`CLAWSWEEPER_*` configuration for this automation.
Required app setup:
- `CLAWSWEEPER_APP_CLIENT_ID`: public app client ID for `openclaw-ci`.
- `CLAWSWEEPER_APP_CLIENT_ID`: public app client ID for `clawsweeper`.
- `CLAWSWEEPER_APP_PRIVATE_KEY`: private key used only inside
`actions/create-github-app-token` steps.
- Target app permissions: read target scan context; write issues and pull

View File

@@ -91,6 +91,7 @@ Docs: https://docs.openclaw.ai
- Agents/Claude CLI: reuse already-cached macOS Keychain credentials for no-prompt Claude credential reads, so doctor/runtime checks do not miss fresh interactive Claude auth. Fixes #73682. Thanks @RyanSandoval.
- Agents/Claude CLI doctor: scope workspace and project-dir checks to agents that actually use the Claude CLI runtime, so non-default Claude agents no longer make the default agent look Claude-backed. Fixes #73903. Thanks @bobfreeman1989.
- Gateway/sessions: expose effective agent runtime metadata on session rows, `sessions.patch`, and local `openclaw sessions --json`, while keeping Claude CLI-backed rows on the canonical model provider so runtime backend and model identity are no longer conflated. Fixes #73090. Thanks @vishutdhar.
- Gateway/auth status: scope external CLI credential overlays to configured providers, runtimes, or profiles and keep status reads off new Keychain prompts, so single-provider Gateway configs no longer probe unrelated Claude/Codex/MiniMax auth on startup. Fixes #73908. Thanks @Ailuras.
- Agents/runtime status: expose effective agent runtime metadata in `agents.list`, Control UI agent panels, and `/agents`, and avoid rendering stale or cumulative CLI token totals as live context usage. Fixes #73660, #73578, and #45268. Thanks @spartman, @DashLabsDev, and @xyooz.
- Agents/transcripts: strip empty assistant text blocks while preserving valid text, images, and signatures, so Anthropic-style providers no longer reject sanitized transcript turns. Fixes #73640. Thanks @jowhee327.
- Providers/Bedrock: omit deprecated `temperature` for Claude Opus 4.7 Bedrock model ids, named and application inference profiles, including dotted `opus-4.7` refs, and classify the nested validation response for failover. Fixes #73663. Thanks @bstanbury.

View File

@@ -80,6 +80,14 @@ the target agent signs in separately and creates its own local profile.
candidate for it, `models status --probe` reports `status: no_model` with
`reasonCode: no_model`.
## External CLI credential discovery
- Runtime-only credentials owned by external CLIs are discovered only when the
provider, runtime, or auth profile is in scope for the current operation, or
when a stored local profile for that external source already exists.
- Read-only/status paths pass `allowKeychainPrompt: false`; they use file-backed
external CLI credentials only and do not read or reuse macOS Keychain results.
## OAuth SecretRef Policy Guard
- SecretRef input is for static credentials only.

View File

@@ -59,7 +59,7 @@ describe("external CLI auth scope", () => {
{
id: "worker",
model: "opencode-go/kimi-k2.6",
agentRuntime: { id: "codex" },
agentRuntime: { id: "codex-app-server" },
subagents: { model: { primary: "z.ai/glm-4.7" } },
},
],
@@ -75,11 +75,12 @@ describe("external CLI auth scope", () => {
"openai-codex",
"minimax-portal",
"claude-cli",
"codex",
"codex-app-server",
"opencode-go",
"z.ai",
"zai",
]),
);
expect(scope?.profileIds).toContain("openai-codex:default");
});
});

View File

@@ -374,6 +374,36 @@ describe("external cli oauth resolution", () => {
expect(mocks.readMiniMaxCliCredentialsCached).not.toHaveBeenCalled();
});
it("passes non-prompting keychain policy to scoped Codex CLI credential reads", () => {
mocks.readCodexCliCredentialsCached.mockReturnValue(
makeOAuthCredential({
provider: "openai-codex",
access: "codex-cli-access",
refresh: "codex-cli-refresh",
}),
);
const profiles = resolveExternalCliAuthProfiles(makeStore(), {
providerIds: ["codex-app-server"],
allowKeychainPrompt: false,
});
expect(profiles).toEqual([
{
profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID,
credential: expect.objectContaining({
type: "oauth",
provider: "openai-codex",
}),
},
]);
expect(mocks.readCodexCliCredentialsCached).toHaveBeenCalledWith(
expect.objectContaining({ allowKeychainPrompt: false }),
);
expect(mocks.readClaudeCliCredentialsCached).not.toHaveBeenCalled();
expect(mocks.readMiniMaxCliCredentialsCached).not.toHaveBeenCalled();
});
it("ignores Claude CLI token credentials", () => {
mocks.readClaudeCliCredentialsCached.mockReturnValue({
type: "token",

View File

@@ -1,3 +1,4 @@
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { ProviderExternalAuthProfile } from "../../plugins/provider-external-auth.types.js";
import { resolveExternalAuthProfilesWithPlugins } from "../../plugins/provider-runtime.js";
import * as externalCliSync from "./external-cli-sync.js";
@@ -12,6 +13,7 @@ type ExternalAuthProfileMap = Map<string, ProviderExternalAuthProfile>;
type ResolveExternalAuthProfiles = typeof resolveExternalAuthProfilesWithPlugins;
type ExternalCliOverlayOptions = {
allowKeychainPrompt?: boolean;
config?: OpenClawConfig;
externalCliProviderIds?: Iterable<string>;
externalCliProfileIds?: Iterable<string>;
};
@@ -50,8 +52,9 @@ function resolveExternalAuthProfileMap(params: {
resolveExternalAuthProfilesForRuntime ?? resolveExternalAuthProfilesWithPlugins;
const profiles = resolveProfiles({
env,
config: params.externalCli?.config,
context: {
config: undefined,
config: params.externalCli?.config,
agentDir: params.agentDir,
workspaceDir: undefined,
env,
@@ -118,6 +121,7 @@ export function shouldPersistExternalAuthProfile(params: {
credential: OAuthCredential;
agentDir?: string;
env?: NodeJS.ProcessEnv;
config?: OpenClawConfig;
externalCliProviderIds?: Iterable<string>;
externalCliProfileIds?: Iterable<string>;
}): boolean {
@@ -126,6 +130,7 @@ export function shouldPersistExternalAuthProfile(params: {
agentDir: params.agentDir,
env: params.env,
externalCli: {
config: params.config,
externalCliProviderIds: params.externalCliProviderIds,
externalCliProfileIds: params.externalCliProfileIds,
},

View File

@@ -48,6 +48,7 @@ function addExternalCliRuntimeScope(out: Set<string>, value: string | undefined)
normalized === "claude-cli" ||
normalized === "codex" ||
normalized === "codex-cli" ||
normalized === "codex-app-server" ||
normalized === "openai-codex" ||
normalized === "minimax" ||
normalized === "minimax-cli" ||
@@ -73,8 +74,14 @@ export function resolveExternalCliAuthScopeFromConfig(
}
addProviderScopeId(providerIds, profile?.provider);
}
for (const provider of Object.keys(cfg.auth?.order ?? {})) {
for (const [provider, orderedProfileIds] of Object.entries(cfg.auth?.order ?? {})) {
addProviderScopeId(providerIds, provider);
for (const profileId of orderedProfileIds ?? []) {
const normalizedProfileId = profileId.trim();
if (normalizedProfileId) {
profileIds.add(normalizedProfileId);
}
}
}
const defaults = cfg.agents?.defaults;

View File

@@ -100,8 +100,12 @@ const EXTERNAL_CLI_SYNC_PROVIDERS: ExternalCliSyncProvider[] = [
{
profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID,
provider: "openai-codex",
aliases: ["codex", "codex-cli"],
readCredentials: () => readCodexCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }),
aliases: ["codex", "codex-cli", "codex-app-server"],
readCredentials: (options) =>
readCodexCliCredentialsCached({
ttlMs: EXTERNAL_CLI_SYNC_TTL_MS,
allowKeychainPrompt: options?.allowKeychainPrompt,
}),
bootstrapOnly: true,
},
{

View File

@@ -11,7 +11,7 @@ const resolveExternalAuthProfilesWithPluginsMock = vi.fn<
(params: unknown) => ProviderExternalAuthProfile[]
>(() => []);
const readCodexCliCredentialsCachedMock = vi.hoisted(() =>
vi.fn<() => OAuthCredential | null>(() => null),
vi.fn<(_options?: unknown) => OAuthCredential | null>(() => null),
);
vi.mock("../cli-credentials.js", () => ({
@@ -70,6 +70,29 @@ describe("auth external oauth helpers", () => {
});
});
it("passes config and CLI scope through overlay resolution", () => {
const cfg = {
models: {
providers: { "openai-codex": { auth: "oauth" as const, baseUrl: "", models: [] } },
},
};
readCodexCliCredentialsCachedMock.mockReturnValueOnce(createCredential());
overlayExternalOAuthProfiles(createStore(), {
allowKeychainPrompt: false,
config: cfg,
externalCliProviderIds: ["openai-codex"],
});
expect(resolveExternalAuthProfilesWithPluginsMock).toHaveBeenCalledWith(
expect.objectContaining({
config: cfg,
context: expect.objectContaining({ config: cfg }),
}),
);
expect(readCodexCliCredentialsCachedMock).toHaveBeenCalledTimes(1);
});
it("omits exact runtime-only overlays from persisted store writes", () => {
const credential = createCredential();
resolveExternalAuthProfilesWithPluginsMock.mockReturnValueOnce([

View File

@@ -1,5 +1,6 @@
import fs from "node:fs";
import { isDeepStrictEqual } from "node:util";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { withFileLock } from "../../infra/file-lock.js";
import { saveJsonFile } from "../../infra/json-file.js";
import {
@@ -36,6 +37,7 @@ import type { AuthProfileStore } from "./types.js";
type LoadAuthProfileStoreOptions = {
allowKeychainPrompt?: boolean;
config?: OpenClawConfig;
readOnly?: boolean;
syncExternalCli?: boolean;
externalCliProviderIds?: Iterable<string>;
@@ -359,6 +361,7 @@ export function loadAuthProfileStoreForRuntime(
return overlayExternalAuthProfiles(store, {
agentDir,
allowKeychainPrompt: options?.allowKeychainPrompt,
config: options?.config,
externalCliProviderIds: options?.externalCliProviderIds,
externalCliProfileIds: options?.externalCliProfileIds,
});
@@ -368,6 +371,7 @@ export function loadAuthProfileStoreForRuntime(
return overlayExternalAuthProfiles(mergeAuthProfileStores(mainStore, store), {
agentDir,
allowKeychainPrompt: options?.allowKeychainPrompt,
config: options?.config,
externalCliProviderIds: options?.externalCliProviderIds,
externalCliProfileIds: options?.externalCliProfileIds,
});
@@ -394,6 +398,7 @@ export function ensureAuthProfileStore(
agentDir?: string,
options?: {
allowKeychainPrompt?: boolean;
config?: OpenClawConfig;
externalCliProviderIds?: Iterable<string>;
externalCliProfileIds?: Iterable<string>;
},
@@ -403,6 +408,7 @@ export function ensureAuthProfileStore(
{
agentDir,
allowKeychainPrompt: options?.allowKeychainPrompt,
config: options?.config,
externalCliProviderIds: options?.externalCliProviderIds,
externalCliProfileIds: options?.externalCliProfileIds,
},

View File

@@ -272,7 +272,7 @@ describe("cli credentials", () => {
expect(execSyncMock).toHaveBeenCalledTimes(1);
});
it("reuses cached Claude keychain credentials for no-prompt reads", async () => {
it("keeps no-prompt Claude reads on the file credential path after a keychain read", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-claude-cache-"));
vi.setSystemTime(new Date("2025-01-01T00:00:00Z"));
mockClaudeCliCredentialRead();
@@ -292,7 +292,12 @@ describe("cli credentials", () => {
execSync: execSyncMock,
});
expect(withoutPrompt).toEqual(withKeychain);
expect(withKeychain).toMatchObject({
type: "oauth",
provider: "anthropic",
refresh: "cached-refresh",
});
expect(withoutPrompt).toBeNull();
expect(execSyncMock).toHaveBeenCalledTimes(1);
});
@@ -361,6 +366,126 @@ describe("cli credentials", () => {
});
});
it("does not read Codex keychain when keychain prompts are disabled", async () => {
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-codex-no-prompt-"));
process.env.CODEX_HOME = tempHome;
const expSeconds = Math.floor(Date.parse("2026-03-24T12:34:56Z") / 1000);
const authPath = path.join(tempHome, "auth.json");
fs.mkdirSync(tempHome, { recursive: true, mode: 0o700 });
fs.writeFileSync(
authPath,
JSON.stringify({
tokens: {
access_token: createJwtWithExp(expSeconds),
refresh_token: "file-refresh",
},
}),
"utf8",
);
const creds = readCodexCliCredentialsCached({
allowKeychainPrompt: false,
ttlMs: CLI_CREDENTIALS_CACHE_TTL_MS,
platform: "darwin",
execSync: execSyncMock,
});
expect(creds).toMatchObject({
access: createJwtWithExp(expSeconds),
refresh: "file-refresh",
provider: "openai-codex",
});
expect(execSyncMock).not.toHaveBeenCalled();
});
it("does not let no-keychain Codex cache misses poison keychain reads", async () => {
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-codex-cache-"));
process.env.CODEX_HOME = tempHome;
const expSeconds = Math.floor(Date.parse("2026-03-24T12:34:56Z") / 1000);
const withoutKeychain = readCodexCliCredentialsCached({
allowKeychainPrompt: false,
ttlMs: CLI_CREDENTIALS_CACHE_TTL_MS,
platform: "darwin",
execSync: execSyncMock,
});
expect(withoutKeychain).toBeNull();
execSyncMock.mockReturnValue(
JSON.stringify({
tokens: {
access_token: createJwtWithExp(expSeconds),
refresh_token: "keychain-refresh",
},
}),
);
const withKeychain = readCodexCliCredentialsCached({
allowKeychainPrompt: true,
ttlMs: CLI_CREDENTIALS_CACHE_TTL_MS,
platform: "darwin",
execSync: execSyncMock,
});
expect(withKeychain).toMatchObject({
access: createJwtWithExp(expSeconds),
refresh: "keychain-refresh",
provider: "openai-codex",
});
expect(execSyncMock).toHaveBeenCalledTimes(1);
});
it("keeps no-prompt Codex reads on auth.json after a keychain read", async () => {
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-codex-cache-"));
process.env.CODEX_HOME = tempHome;
const keychainExpiry = Math.floor(Date.parse("2026-03-24T12:34:56Z") / 1000);
const fileExpiry = Math.floor(Date.parse("2026-03-25T12:34:56Z") / 1000);
const authPath = path.join(tempHome, "auth.json");
fs.mkdirSync(tempHome, { recursive: true, mode: 0o700 });
fs.writeFileSync(
authPath,
JSON.stringify({
tokens: {
access_token: createJwtWithExp(fileExpiry),
refresh_token: "file-refresh",
},
}),
"utf8",
);
execSyncMock.mockReturnValue(
JSON.stringify({
tokens: {
access_token: createJwtWithExp(keychainExpiry),
refresh_token: "keychain-refresh",
},
}),
);
const withKeychain = readCodexCliCredentialsCached({
allowKeychainPrompt: true,
ttlMs: CLI_CREDENTIALS_CACHE_TTL_MS,
platform: "darwin",
execSync: execSyncMock,
});
const withoutPrompt = readCodexCliCredentialsCached({
allowKeychainPrompt: false,
ttlMs: CLI_CREDENTIALS_CACHE_TTL_MS,
platform: "darwin",
execSync: execSyncMock,
});
expect(withKeychain).toMatchObject({
refresh: "keychain-refresh",
expires: keychainExpiry * 1000,
provider: "openai-codex",
});
expect(withoutPrompt).toMatchObject({
refresh: "file-refresh",
expires: fileExpiry * 1000,
provider: "openai-codex",
});
expect(execSyncMock).toHaveBeenCalledTimes(1);
});
it("invalidates cached Codex credentials when auth.json changes within the TTL window", () => {
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-codex-cache-"));
process.env.CODEX_HOME = tempHome;

View File

@@ -249,9 +249,10 @@ function readCodexKeychainAuthRecord(options?: {
codexHome?: string;
platform?: NodeJS.Platform;
execSync?: ExecSyncFn;
allowKeychainPrompt?: boolean;
}): Record<string, unknown> | null {
const { platform, execSyncImpl, codexHome } = resolveCodexKeychainParams(options);
if (platform !== "darwin") {
if (platform !== "darwin" || options?.allowKeychainPrompt === false) {
return null;
}
const account = computeCodexKeychainAccount(codexHome);
@@ -277,6 +278,7 @@ function readCodexKeychainCredentials(options?: {
codexHome?: string;
platform?: NodeJS.Platform;
execSync?: ExecSyncFn;
allowKeychainPrompt?: boolean;
}): CodexCliCredential | null {
const parsed = readCodexKeychainAuthRecord(options);
if (!parsed) {
@@ -458,17 +460,6 @@ export function readClaudeCliCredentialsCached(options?: {
const platform = options?.platform ?? process.platform;
const ttlMs = options?.ttlMs ?? 0;
const credentialsPath = resolveClaudeCliCredentialsPath(options?.homeDir);
const keychainCacheKey = `${credentialsPath}:keychain`;
if (
ttlMs > 0 &&
platform === "darwin" &&
options?.allowKeychainPrompt === false &&
claudeCliCache?.cacheKey === keychainCacheKey &&
claudeCliCache.value &&
Date.now() - claudeCliCache.readAt < ttlMs
) {
return claudeCliCache.value;
}
const keychainIntent =
platform === "darwin" && options?.allowKeychainPrompt !== false ? "keychain" : "file";
return readCachedCliCredential({
@@ -608,11 +599,13 @@ export function writeClaudeCliCredentials(
export function readCodexCliCredentials(options?: {
codexHome?: string;
allowKeychainPrompt?: boolean;
platform?: NodeJS.Platform;
execSync?: ExecSyncFn;
}): CodexCliCredential | null {
const keychain = readCodexKeychainCredentials({
codexHome: options?.codexHome,
allowKeychainPrompt: options?.allowKeychainPrompt,
platform: options?.platform,
execSync: options?.execSync,
});
@@ -664,18 +657,24 @@ export function readCodexCliCredentials(options?: {
export function readCodexCliCredentialsCached(options?: {
codexHome?: string;
allowKeychainPrompt?: boolean;
ttlMs?: number;
platform?: NodeJS.Platform;
execSync?: ExecSyncFn;
}): CodexCliCredential | null {
const platform = options?.platform ?? process.platform;
const ttlMs = options?.ttlMs ?? 0;
const authPath = path.join(resolveCodexHomePath(options?.codexHome), CODEX_CLI_AUTH_FILENAME);
const keychainIntent =
platform === "darwin" && options?.allowKeychainPrompt !== false ? "keychain" : "file";
return readCachedCliCredential({
ttlMs: options?.ttlMs ?? 0,
ttlMs,
cache: codexCliCache,
cacheKey: `${options?.platform ?? process.platform}|${authPath}`,
cacheKey: `${platform}|${authPath}:${keychainIntent}`,
read: () =>
readCodexCliCredentials({
codexHome: options?.codexHome,
allowKeychainPrompt: options?.allowKeychainPrompt,
platform: options?.platform,
execSync: options?.execSync,
}),

View File

@@ -219,6 +219,8 @@ describe("models.authStatus", () => {
expect(mocks.ensureAuthProfileStore).toHaveBeenCalledWith(
"/tmp/agent",
expect.objectContaining({
allowKeychainPrompt: false,
config: expect.any(Object),
externalCliProviderIds: expect.arrayContaining(["opencode-go"]),
externalCliProfileIds: ["opencode-go:default"],
}),
@@ -232,7 +234,15 @@ describe("models.authStatus", () => {
it("keeps the auth store overlay unscoped when config has no provider signal", async () => {
await handler(createOptions());
expect(mocks.ensureAuthProfileStore).toHaveBeenCalledWith("/tmp/agent");
expect(mocks.ensureAuthProfileStore).toHaveBeenCalledWith(
"/tmp/agent",
expect.objectContaining({
allowKeychainPrompt: false,
config: expect.any(Object),
externalCliProviderIds: undefined,
externalCliProfileIds: undefined,
}),
);
});
it("still returns providers when usage fetch fails", async () => {

View File

@@ -294,12 +294,12 @@ export const modelsAuthStatusHandlers: GatewayRequestHandlers = {
const cfg = context.getRuntimeConfig();
const agentDir = resolveOpenClawAgentDir();
const externalCliAuthScope = resolveExternalCliAuthScopeFromConfig(cfg);
const store = externalCliAuthScope
? ensureAuthProfileStore(agentDir, {
externalCliProviderIds: externalCliAuthScope.providerIds,
externalCliProfileIds: externalCliAuthScope.profileIds,
})
: ensureAuthProfileStore(agentDir);
const store = ensureAuthProfileStore(agentDir, {
allowKeychainPrompt: false,
config: cfg,
externalCliProviderIds: externalCliAuthScope?.providerIds,
externalCliProfileIds: externalCliAuthScope?.profileIds,
});
const configured = resolveConfiguredProviders(cfg);
const authHealth: AuthHealthSummary = buildAuthHealthSummary({
store,