fix(agents): keep OAuth auth read-through

This commit is contained in:
Peter Steinberger
2026-04-29 11:54:13 +01:00
parent 21a92ea0f6
commit e6cd90e3fd
37 changed files with 1306 additions and 127 deletions

View File

@@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Agents/auth: keep OAuth auth profiles inherited from the main agent read-through instead of copying refresh tokens into secondary agents, and refresh Codex app-server tokens against the owning store so multi-agent swarms avoid reused refresh-token failures. Fixes #74055. Thanks @ClarityInvest.
- ACP/commands: accept forwarded ACP timeout config controls in the OpenClaw bridge, treat unsupported discard-close controls as recoverable cleanup, and restore native `/verbose full` plus no-arg status behavior, so Discord command menus and nested ACP turns no longer fail on supported session controls. Thanks @vincentkoc.
- Channels/Discord: fail startup closed when Discord cannot resolve the bot's own identity and keep mention gating active when only configured mention patterns can detect mentions, so the provider no longer continues with a missing bot id. Fixes #42219; carries forward #46856 and #49218. Thanks @education-01 and @BenediktSchackenberg.
- Channels/Discord: split long CJK replies at punctuation and code-point-safe fallback boundaries so Discord chunking stays readable without corrupting astral characters. Fixes #38597; repairs #71384. Thanks @p3nchan.

View File

@@ -44,6 +44,24 @@ Token credentials (`type: "token"`) support inline `token` and/or `tokenRef`.
2. For eligible profiles, token material may be resolved from inline value or `tokenRef`.
3. Unresolvable refs produce `unresolved_ref` in `models status --probe` output.
## Agent copy portability
Agent auth inheritance is read-through. When an agent has no local profile, it
can resolve profiles from the default/main agent store at runtime without
copying secret material into its own `auth-profiles.json`.
Explicit copy flows, such as `openclaw agents add`, use this portability policy:
- `api_key` profiles are portable unless `copyToAgents: false`.
- `token` profiles are portable unless `copyToAgents: false`.
- `oauth` profiles are not portable by default because refresh tokens can be
single-use or rotation-sensitive.
- Provider-owned OAuth flows may opt in with `copyToAgents: true` only when
copying refresh material across agents is known safe.
Non-portable profiles remain available through read-through inheritance unless
the target agent signs in separately and creates its own local profile.
## Explicit auth order filtering
- When `auth.order.<provider>` or the auth-store order override is set for a

View File

@@ -110,6 +110,11 @@ Notes:
- Passing any explicit add flags switches the command into the non-interactive path.
- Non-interactive mode requires both an agent name and `--workspace`.
- `main` is reserved and cannot be used as the new agent id.
- In interactive mode, auth seeding copies only portable static profiles
(`api_key` and static `token` by default). OAuth refresh-token profiles remain
available only by read-through inheritance from the real `main` agent store.
If the configured default agent is not `main`, sign in separately for OAuth
profiles on the new agent.
### `agents bindings`

View File

@@ -29,7 +29,12 @@ Auth profiles are **per-agent**. Each agent reads from its own:
</Note>
<Warning>
Main agent credentials are **not** shared automatically. Never reuse `agentDir` across agents (it causes auth/session collisions). If you want to share creds, copy `auth-profiles.json` into the other agent's `agentDir`.
Never reuse `agentDir` across agents (it causes auth/session collisions). Agents
can read through to the default/main agent's auth profiles when they do not have
a local profile, but OpenClaw does not clone OAuth refresh tokens into the
secondary agent store. If you want an independent OAuth account, sign in from
that agent; if you copy credentials manually, copy only portable static
`api_key` or `token` profiles.
</Warning>
Skills are loaded from each agent workspace plus shared roots such as `~/.openclaw/skills`, then filtered by the effective agent skill allowlist when configured. Use `agents.defaults.skills` for a shared baseline and `agents.list[].skills` for per-agent replacement. See [Skills: per-agent vs shared](/tools/skills#per-agent-vs-shared-skills) and [Skills: agent skill allowlists](/tools/skills#agent-skill-allowlists).

View File

@@ -54,7 +54,7 @@ To reduce that, OpenClaw treats `auth-profiles.json` as a **token sink**:
## Storage (where tokens live)
Secrets are stored **per-agent**:
Secrets are stored in agent auth stores:
- Auth profiles (OAuth + API keys + optional value-level refs): `~/.openclaw/agents/<agentId>/agent/auth-profiles.json`
- Legacy compatibility file: `~/.openclaw/agents/<agentId>/agent/auth.json`
@@ -68,6 +68,13 @@ All of the above also respect `$OPENCLAW_STATE_DIR` (state dir override). Full r
For static secret refs and runtime snapshot activation behavior, see [Secrets Management](/gateway/secrets).
When a secondary agent has no local auth profile, OpenClaw uses read-through
inheritance from the default/main agent store. It does not clone the main
agent's `auth-profiles.json` on read. OAuth refresh tokens are especially
sensitive: normal copy flows skip them by default because some providers rotate
or invalidate refresh tokens after use. Configure a separate OAuth login for an
agent when it needs an independent account.
## Anthropic legacy token compatibility
<Warning>
@@ -132,6 +139,9 @@ At runtime:
- if `expires` is in the future → use the stored access token
- if expired → refresh (under a file lock) and overwrite the stored credentials
- if a secondary agent reads an inherited main-agent OAuth profile, refresh
writes back to the main agent store instead of copying the refresh token into
the secondary agent store
- exception: some external CLI credentials stay externally managed; OpenClaw
re-reads those CLI auth stores instead of spending copied refresh tokens.
Codex CLI bootstrap is intentionally narrower: it seeds an empty

View File

@@ -343,7 +343,8 @@ troubleshooting, see the main [FAQ](/help/faq).
Fix options:
- Run `openclaw agents add <id>` and configure auth during the wizard.
- Or copy `auth-profiles.json` from the main agent's `agentDir` into the new agent's `agentDir`.
- Or copy only portable static `api_key` / `token` profiles from the main agent's auth store into the new agent's auth store.
- For OAuth profiles, sign in from the new agent when it needs its own account; otherwise OpenClaw can read through to the default/main agent without cloning refresh tokens.
Do **not** reuse `agentDir` across agents; it causes auth/session collisions.

View File

@@ -21,7 +21,7 @@ Each agent in a multi-agent setup can override the global sandbox and tool polic
</CardGroup>
<Warning>
Auth is per-agent: each agent reads from its own `agentDir` auth store at `~/.openclaw/agents/<agentId>/agent/auth-profiles.json`. Credentials are **not** shared between agents. Never reuse `agentDir` across agents. If you want to share creds, copy `auth-profiles.json` into the other agent's `agentDir`.
Auth is scoped by agent: each agent has its own `agentDir` auth store at `~/.openclaw/agents/<agentId>/agent/auth-profiles.json`. Never reuse `agentDir` across agents. Agents can read through to the default/main agent's auth profiles when they do not have a local profile, but OAuth refresh tokens are not cloned into secondary agent stores. If you copy credentials manually, copy only portable static `api_key` or `token` profiles.
</Warning>
---

View File

@@ -1,6 +1,10 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import {
clearRuntimeAuthProfileStoreSnapshots,
loadAuthProfileStoreForSecretsRuntime,
} from "openclaw/plugin-sdk/agent-runtime";
import { upsertAuthProfile } from "openclaw/plugin-sdk/provider-auth";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
@@ -72,7 +76,7 @@ vi.mock("openclaw/plugin-sdk/agent-runtime", async (importOriginal) => {
if (refreshed?.access) {
oauthCredential = refreshed as typeof oauthCredential;
params.store.profiles[params.profileId] = oauthCredential;
if (params.agentDir) {
if (params.agentDir || process.env.OPENCLAW_STATE_DIR) {
actual.saveAuthProfileStore(params.store, params.agentDir);
}
}
@@ -92,6 +96,7 @@ vi.mock("openclaw/plugin-sdk/agent-runtime", async (importOriginal) => {
afterEach(() => {
vi.unstubAllEnvs();
clearRuntimeAuthProfileStoreSnapshots();
oauthMocks.refreshOpenAICodexToken.mockReset();
providerRuntimeMocks.formatProviderAuthProfileApiKeyWithPlugin.mockReset();
providerRuntimeMocks.refreshProviderOAuthCredentialWithPlugin.mockClear();
@@ -635,6 +640,132 @@ describe("bridgeCodexAppServerStartOptions", () => {
}
});
it("refreshes inherited main Codex OAuth without cloning it into the child store", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const stateDir = path.join(root, "state");
const childAgentDir = path.join(stateDir, "agents", "worker", "agent");
const childAuthPath = path.join(childAgentDir, "auth-profiles.json");
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
vi.stubEnv("OPENCLAW_AGENT_DIR", "");
oauthMocks.refreshOpenAICodexToken.mockResolvedValueOnce({
access: "main-refreshed-access-token",
refresh: "main-refreshed-refresh-token",
expires: Date.now() + 60_000,
accountId: "account-main-refreshed",
});
try {
upsertAuthProfile({
profileId: "openai-codex:work",
credential: {
type: "oauth",
provider: "openai-codex",
access: "main-current-access-token",
refresh: "main-refresh-token",
expires: Date.now() + 60_000,
accountId: "account-main",
email: "main-codex@example.test",
},
});
await expect(
refreshCodexAppServerAuthTokens({
agentDir: childAgentDir,
authProfileId: "openai-codex:work",
}),
).resolves.toEqual({
accessToken: "main-refreshed-access-token",
chatgptAccountId: "account-main-refreshed",
chatgptPlanType: null,
});
expect(oauthMocks.refreshOpenAICodexToken).toHaveBeenCalledWith("main-refresh-token");
await expect(fs.access(childAuthPath)).rejects.toMatchObject({ code: "ENOENT" });
expect(loadAuthProfileStoreForSecretsRuntime().profiles["openai-codex:work"]).toMatchObject({
type: "oauth",
provider: "openai-codex",
access: "main-refreshed-access-token",
refresh: "main-refreshed-refresh-token",
});
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("force-refreshes the owner credential instead of a stale child OAuth clone", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const stateDir = path.join(root, "state");
const childAgentDir = path.join(stateDir, "agents", "worker", "agent");
const childAuthPath = path.join(childAgentDir, "auth-profiles.json");
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
vi.stubEnv("OPENCLAW_AGENT_DIR", "");
oauthMocks.refreshOpenAICodexToken.mockResolvedValueOnce({
access: "main-refreshed-access-token",
refresh: "main-refreshed-refresh-token",
expires: Date.now() + 60_000,
accountId: "account-main-refreshed",
});
try {
upsertAuthProfile({
profileId: "openai-codex:work",
credential: {
type: "oauth",
provider: "openai-codex",
access: "main-current-access-token",
refresh: "main-owner-refresh-token",
expires: Date.now() + 60_000,
accountId: "account-main",
email: "main-codex@example.test",
},
});
await fs.mkdir(childAgentDir, { recursive: true });
await fs.writeFile(
childAuthPath,
JSON.stringify({
version: 1,
profiles: {
"openai-codex:work": {
type: "oauth",
provider: "openai-codex",
access: "child-stale-access-token",
refresh: "child-stale-refresh-token",
expires: Date.now() - 60_000,
accountId: "account-main",
email: "main-codex@example.test",
},
},
}),
);
await expect(
refreshCodexAppServerAuthTokens({
agentDir: childAgentDir,
authProfileId: "openai-codex:work",
}),
).resolves.toEqual({
accessToken: "main-refreshed-access-token",
chatgptAccountId: "account-main-refreshed",
chatgptPlanType: null,
});
expect(oauthMocks.refreshOpenAICodexToken).toHaveBeenCalledWith("main-owner-refresh-token");
expect(loadAuthProfileStoreForSecretsRuntime().profiles["openai-codex:work"]).toMatchObject({
type: "oauth",
provider: "openai-codex",
access: "main-refreshed-access-token",
refresh: "main-refreshed-refresh-token",
});
const child = JSON.parse(await fs.readFile(childAuthPath, "utf8")) as {
profiles: Record<string, { access?: string; refresh?: string }>;
};
expect(child.profiles["openai-codex:work"]).toMatchObject({
access: "child-stale-access-token",
refresh: "child-stale-refresh-token",
});
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("accepts a refreshed Codex OAuth credential when the stored provider is a legacy alias", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
oauthMocks.refreshOpenAICodexToken.mockResolvedValueOnce({

View File

@@ -3,6 +3,7 @@ import {
loadAuthProfileStoreForSecretsRuntime,
resolveProviderIdForAuth,
resolveApiKeyForProfile,
resolvePersistedAuthProfileOwnerAgentDir,
saveAuthProfileStore,
type AuthProfileCredential,
type OAuthCredential,
@@ -178,17 +179,26 @@ async function resolveOAuthCredentialForCodexAppServer(
credential: OAuthCredential,
params: { agentDir: string; forceRefresh: boolean },
): Promise<OAuthCredential> {
const store = ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false });
const ownerAgentDir = resolvePersistedAuthProfileOwnerAgentDir({
agentDir: params.agentDir,
profileId,
});
const store = ensureAuthProfileStore(ownerAgentDir, { allowKeychainPrompt: false });
const ownerCredential = store.profiles[profileId];
const credentialForOwner =
ownerCredential?.type === "oauth" && isCodexAppServerAuthProvider(ownerCredential.provider)
? ownerCredential
: credential;
if (params.forceRefresh) {
store.profiles[profileId] = { ...credential, expires: 0 };
saveAuthProfileStore(store, params.agentDir);
store.profiles[profileId] = { ...credentialForOwner, expires: 0 };
saveAuthProfileStore(store, ownerAgentDir);
}
const resolved = await resolveApiKeyForProfile({
store,
profileId,
agentDir: params.agentDir,
agentDir: ownerAgentDir,
});
const refreshed = loadAuthProfileStoreForSecretsRuntime(params.agentDir).profiles[profileId];
const refreshed = loadAuthProfileStoreForSecretsRuntime(ownerAgentDir).profiles[profileId];
const storedCredential = store.profiles[profileId];
const candidate =
refreshed?.type === "oauth" && isCodexAppServerAuthProvider(refreshed.provider)

View File

@@ -80,30 +80,35 @@ function turnStartResult(turnId = "turn-auth-contract") {
function createCodexAuthProfileHarness(params: { startMethod: "thread/start" | "thread/resume" }) {
const seenAuthProfileIds: Array<string | undefined> = [];
const seenAgentDirs: Array<string | undefined> = [];
const requests: Array<{ method: string; params: unknown }> = [];
let notify: (notification: unknown) => Promise<void> = async () => undefined;
__testing.setCodexAppServerClientFactoryForTests(async (_startOptions, authProfileId) => {
seenAuthProfileIds.push(authProfileId);
return {
request: vi.fn(async (method: string, requestParams?: unknown) => {
requests.push({ method, params: requestParams });
if (method === params.startMethod) {
return threadStartResult();
}
if (method === "turn/start") {
return turnStartResult();
}
throw new Error(`unexpected method: ${method}`);
}),
addNotificationHandler: (handler: (notification: unknown) => Promise<void>) => {
notify = handler;
return () => undefined;
},
addRequestHandler: () => () => undefined,
} as never;
});
__testing.setCodexAppServerClientFactoryForTests(
async (_startOptions, authProfileId, agentDir) => {
seenAuthProfileIds.push(authProfileId);
seenAgentDirs.push(agentDir);
return {
request: vi.fn(async (method: string, requestParams?: unknown) => {
requests.push({ method, params: requestParams });
if (method === params.startMethod) {
return threadStartResult();
}
if (method === "turn/start") {
return turnStartResult();
}
throw new Error(`unexpected method: ${method}`);
}),
addNotificationHandler: (handler: (notification: unknown) => Promise<void>) => {
notify = handler;
return () => undefined;
},
addRequestHandler: () => () => undefined,
} as never;
},
);
return {
seenAuthProfileIds,
seenAgentDirs,
async waitForMethod(method: string) {
await vi.waitFor(() => expect(requests.some((entry) => entry.method === method)).toBe(true), {
interval: 1,
@@ -140,6 +145,7 @@ describe("Auth profile runtime contract - Codex app-server adapter", () => {
const sessionFile = path.join(tmpDir, "session.jsonl");
const params = createParams(sessionFile, tmpDir);
params.authProfileId = AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId;
params.agentDir = tmpDir;
const run = runCodexAppServerAttempt(params);
await vi.waitFor(
@@ -149,6 +155,7 @@ describe("Auth profile runtime contract - Codex app-server adapter", () => {
]),
{ interval: 1 },
);
expect(harness.seenAgentDirs).toEqual([tmpDir]);
await harness.waitForMethod("turn/start");
await harness.completeTurn();
await run;

View File

@@ -4,14 +4,16 @@ import type { CodexAppServerStartOptions } from "./config.js";
export type CodexAppServerClientFactory = (
startOptions?: CodexAppServerStartOptions,
authProfileId?: string,
agentDir?: string,
) => Promise<CodexAppServerClient>;
export const defaultCodexAppServerClientFactory: CodexAppServerClientFactory = (
startOptions,
authProfileId,
agentDir,
) =>
import("./shared-client.js").then(({ getSharedCodexAppServerClient }) =>
getSharedCodexAppServerClient({ startOptions, authProfileId }),
getSharedCodexAppServerClient({ startOptions, authProfileId, agentDir }),
);
export function createCodexAppServerClientFactoryTestHooks(

View File

@@ -125,6 +125,7 @@ async function compactCodexNativeThread(
const client = await clientFactory(
appServer.start,
requestedAuthProfileId ?? binding.authProfileId,
params.agentDir,
);
const waiter = createCodexNativeCompactionWaiter(client, binding.threadId);
let completion: CodexNativeCompactionCompletion;

View File

@@ -355,6 +355,19 @@ describe("Codex app-server config", () => {
expect(second).not.toContain("sk-second");
});
it("derives distinct shared-client keys for distinct agent dirs", () => {
const startOptions = {
transport: "stdio" as const,
command: "codex",
args: ["app-server"],
headers: {},
};
expect(codexAppServerStartOptionsKey(startOptions, { agentDir: "/tmp/agent-a" })).not.toEqual(
codexAppServerStartOptionsKey(startOptions, { agentDir: "/tmp/agent-b" }),
);
});
it("keeps runtime config keys aligned with manifest schema and UI hints", async () => {
const manifest = JSON.parse(
await fs.readFile(new URL("../../openclaw.plugin.json", import.meta.url), "utf8"),

View File

@@ -294,7 +294,7 @@ export function resolveCodexComputerUseConfig(
export function codexAppServerStartOptionsKey(
options: CodexAppServerStartOptions,
params: { authProfileId?: string } = {},
params: { authProfileId?: string; agentDir?: string } = {},
): string {
return JSON.stringify({
transport: options.transport,
@@ -311,6 +311,7 @@ export function codexAppServerStartOptionsKey(
.map(([key, value]) => [key, hashSecretForKey(value, `env:${key}`)]),
clearEnv: [...(options.clearEnv ?? [])].toSorted(),
authProfileId: params.authProfileId ?? null,
agentDir: params.agentDir ?? null,
});
}

View File

@@ -28,6 +28,7 @@ export type CodexAppServerListModelsOptions = {
timeoutMs?: number;
startOptions?: CodexAppServerStartOptions;
authProfileId?: string;
agentDir?: string;
sharedClient?: boolean;
};
@@ -77,11 +78,13 @@ async function withCodexAppServerModelClient<T>(
startOptions: options.startOptions,
timeoutMs,
authProfileId: options.authProfileId,
agentDir: options.agentDir,
})
: await createIsolatedCodexAppServerClient({
startOptions: options.startOptions,
timeoutMs,
authProfileId: options.authProfileId,
agentDir: options.agentDir,
});
try {
return await run({ client, timeoutMs });

View File

@@ -145,7 +145,9 @@ function assistantMessage(text: string, timestamp: number) {
function createAppServerHarness(
requestImpl: (method: string, params: unknown) => Promise<unknown>,
options: { onStart?: (authProfileId: string | undefined) => void } = {},
options: {
onStart?: (authProfileId: string | undefined, agentDir: string | undefined) => void;
} = {},
) {
const requests: Array<{ method: string; params: unknown }> = [];
let notify: (notification: CodexServerNotification) => Promise<void> = async () => undefined;
@@ -154,17 +156,19 @@ function createAppServerHarness(
return requestImpl(method, params);
});
__testing.setCodexAppServerClientFactoryForTests(async (_startOptions, authProfileId) => {
options.onStart?.(authProfileId);
return {
request,
addNotificationHandler: (handler: typeof notify) => {
notify = handler;
return () => undefined;
},
addRequestHandler: () => () => undefined,
} as never;
});
__testing.setCodexAppServerClientFactoryForTests(
async (_startOptions, authProfileId, agentDir) => {
options.onStart?.(authProfileId, agentDir);
return {
request,
addNotificationHandler: (handler: typeof notify) => {
notify = handler;
return () => undefined;
},
addRequestHandler: () => () => undefined,
} as never;
},
);
return {
request,
@@ -202,7 +206,9 @@ function createAppServerHarness(
function createStartedThreadHarness(
requestImpl: (method: string, params: unknown) => Promise<unknown> = async () => undefined,
options: { onStart?: (authProfileId: string | undefined) => void } = {},
options: {
onStart?: (authProfileId: string | undefined, agentDir: string | undefined) => void;
} = {},
) {
return createAppServerHarness(async (method, params) => {
const override = await requestImpl(method, params);
@@ -1300,14 +1306,19 @@ describe("runCodexAppServerAttempt", () => {
it("passes the selected auth profile into app-server startup", async () => {
const seenAuthProfileIds: Array<string | undefined> = [];
const seenAgentDirs: Array<string | undefined> = [];
const { requests, waitForMethod, completeTurn } = createStartedThreadHarness(undefined, {
onStart: (authProfileId) => seenAuthProfileIds.push(authProfileId),
onStart: (authProfileId, agentDir) => {
seenAuthProfileIds.push(authProfileId);
seenAgentDirs.push(agentDir);
},
});
const params = createParams(
path.join(tempDir, "session.jsonl"),
path.join(tempDir, "workspace"),
);
params.authProfileId = "openai-codex:work";
params.agentDir = path.join(tempDir, "agent");
const run = runCodexAppServerAttempt(params);
await vi.waitFor(() => expect(seenAuthProfileIds).toEqual(["openai-codex:work"]), {
@@ -1319,6 +1330,7 @@ describe("runCodexAppServerAttempt", () => {
await run;
expect(seenAuthProfileIds).toEqual(["openai-codex:work"]);
expect(seenAgentDirs).toEqual([path.join(tempDir, "agent")]);
expect(requests.map((entry) => entry.method)).toContain("turn/start");
});
@@ -1622,6 +1634,7 @@ describe("runCodexAppServerAttempt", () => {
});
const params = createParams(sessionFile, workspaceDir);
delete params.authProfileId;
params.agentDir = path.join(tempDir, "agent");
const binding = await startOrResumeThread({
client: {
@@ -1660,6 +1673,7 @@ describe("runCodexAppServerAttempt", () => {
dynamicToolsFingerprint: "[]",
});
const seenAuthProfileIds: Array<string | undefined> = [];
const seenAgentDirs: Array<string | undefined> = [];
const { requests, waitForMethod, completeTurn } = createAppServerHarness(
async (method: string) => {
if (method === "thread/resume") {
@@ -1670,10 +1684,16 @@ describe("runCodexAppServerAttempt", () => {
}
throw new Error(`unexpected method: ${method}`);
},
{ onStart: (authProfileId) => seenAuthProfileIds.push(authProfileId) },
{
onStart: (authProfileId, agentDir) => {
seenAuthProfileIds.push(authProfileId);
seenAgentDirs.push(agentDir);
},
},
);
const params = createParams(sessionFile, workspaceDir);
delete params.authProfileId;
params.agentDir = path.join(tempDir, "agent");
const run = runCodexAppServerAttempt(params);
await vi.waitFor(() => expect(seenAuthProfileIds).toEqual(["openai-codex:bound"]), {
@@ -1685,6 +1705,7 @@ describe("runCodexAppServerAttempt", () => {
await run;
expect(seenAuthProfileIds).toEqual(["openai-codex:bound"]);
expect(seenAgentDirs).toEqual([path.join(tempDir, "agent")]);
expect(requests.map((entry) => entry.method)).toContain("turn/start");
});
});

View File

@@ -311,7 +311,7 @@ export async function runCodexAppServerAttempt(
timeoutFloorMs: options.startupTimeoutFloorMs,
signal: runAbortController.signal,
operation: async () => {
const startupClient = await clientFactory(appServer.start, startupAuthProfileId);
const startupClient = await clientFactory(appServer.start, startupAuthProfileId, agentDir);
await ensureCodexComputerUse({
client: startupClient,
pluginConfig: options.pluginConfig,

View File

@@ -145,6 +145,33 @@ describe("shared Codex app-server client", () => {
);
});
it("uses the selected agent dir for shared app-server auth bridging", async () => {
const harness = createClientHarness();
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
const listPromise = listCodexAppServerModels({
timeoutMs: 1000,
authProfileId: "openai-codex:work",
agentDir: "/tmp/openclaw-agent-nova",
});
await sendInitializeResult(harness, "openclaw/0.125.0 (macOS; test)");
await sendEmptyModelList(harness);
await expect(listPromise).resolves.toEqual({ models: [] });
expect(mocks.bridgeCodexAppServerStartOptions).toHaveBeenCalledWith(
expect.objectContaining({
agentDir: "/tmp/openclaw-agent-nova",
authProfileId: "openai-codex:work",
}),
);
expect(mocks.applyCodexAppServerAuthProfile).toHaveBeenCalledWith(
expect.objectContaining({
agentDir: "/tmp/openclaw-agent-nova",
authProfileId: "openai-codex:work",
}),
);
});
it("resolves the managed binary before bridging and spawning the shared client", async () => {
const harness = createClientHarness();
const startSpy = vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);

View File

@@ -29,9 +29,10 @@ export async function getSharedCodexAppServerClient(options?: {
startOptions?: CodexAppServerStartOptions;
timeoutMs?: number;
authProfileId?: string;
agentDir?: string;
}): Promise<CodexAppServerClient> {
const state = getSharedCodexAppServerClientState();
const agentDir = resolveOpenClawAgentDir();
const agentDir = options?.agentDir ?? resolveOpenClawAgentDir();
const requestedStartOptions =
options?.startOptions ?? resolveCodexAppServerRuntimeOptions().start;
const managedStartOptions = await resolveManagedCodexAppServerStartOptions(requestedStartOptions);
@@ -42,6 +43,7 @@ export async function getSharedCodexAppServerClient(options?: {
});
const key = codexAppServerStartOptionsKey(startOptions, {
authProfileId: options?.authProfileId,
agentDir,
});
if (state.key && state.key !== key) {
clearSharedCodexAppServerClient();
@@ -87,8 +89,9 @@ export async function createIsolatedCodexAppServerClient(options?: {
startOptions?: CodexAppServerStartOptions;
timeoutMs?: number;
authProfileId?: string;
agentDir?: string;
}): Promise<CodexAppServerClient> {
const agentDir = resolveOpenClawAgentDir();
const agentDir = options?.agentDir ?? resolveOpenClawAgentDir();
const requestedStartOptions =
options?.startOptions ?? resolveCodexAppServerRuntimeOptions().start;
const managedStartOptions = await resolveManagedCodexAppServerStartOptions(requestedStartOptions);

View File

@@ -839,6 +839,53 @@ describe("ensureAuthProfileStore", () => {
}
});
it("does not clone inherited auth stores during normal agent reads", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-read-through-"));
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
try {
const stateDir = path.join(root, ".openclaw");
const mainAgentDir = path.join(stateDir, "agents", "main", "agent");
const workerAgentDir = path.join(stateDir, "agents", "worker", "agent");
const workerStorePath = path.join(workerAgentDir, "auth-profiles.json");
fs.mkdirSync(mainAgentDir, { recursive: true });
fs.writeFileSync(
path.join(mainAgentDir, "auth-profiles.json"),
`${JSON.stringify(
{
version: AUTH_STORE_VERSION,
profiles: {
"openai-codex:default": {
type: "oauth",
provider: "openai-codex",
access: "main-access",
refresh: "main-refresh",
expires: Date.now() + 60_000,
},
},
},
null,
2,
)}\n`,
"utf8",
);
process.env.OPENCLAW_STATE_DIR = stateDir;
clearRuntimeAuthProfileStoreSnapshots();
const store = ensureAuthProfileStore(workerAgentDir);
expect(store.profiles["openai-codex:default"]).toMatchObject({
type: "oauth",
provider: "openai-codex",
access: "main-access",
});
expect(fs.existsSync(workerStorePath)).toBe(false);
} finally {
clearRuntimeAuthProfileStoreSnapshots();
restoreEnvValue("OPENCLAW_STATE_DIR", previousStateDir);
fs.rmSync(root, { recursive: true, force: true });
}
});
it("logs one warning with aggregated reasons for rejected auth-profiles entries", () => {
const warnSpy = vi.spyOn(log, "warn").mockImplementation(() => undefined);
try {

View File

@@ -5,6 +5,7 @@ import { describe, expect, it, vi } from "vitest";
import { resolveAuthStatePath, resolveAuthStorePath } from "./auth-profiles/paths.js";
import {
clearRuntimeAuthProfileStoreSnapshots,
ensureAuthProfileStoreForLocalUpdate,
ensureAuthProfileStore,
replaceRuntimeAuthProfileStoreSnapshots,
saveAuthProfileStore,
@@ -183,4 +184,226 @@ describe("saveAuthProfileStore", () => {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("does not persist unchanged inherited main OAuth when saving secondary local updates", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-save-inherited-"));
const stateDir = path.join(root, ".openclaw");
const childAgentDir = path.join(stateDir, "agents", "worker", "agent");
const childAuthPath = resolveAuthStorePath(childAgentDir);
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
vi.stubEnv("OPENCLAW_AGENT_DIR", "");
try {
saveAuthProfileStore({
version: 1,
profiles: {
"openai-codex:default": {
type: "oauth",
provider: "openai-codex",
access: "main-access-token",
refresh: "main-refresh-token",
expires: Date.now() + 60_000,
},
},
});
const localUpdateStore = ensureAuthProfileStoreForLocalUpdate(childAgentDir);
expect(localUpdateStore.profiles["openai-codex:default"]).toMatchObject({
type: "oauth",
refresh: "main-refresh-token",
});
localUpdateStore.profiles["openai:default"] = {
type: "api_key",
provider: "openai",
key: "sk-child-local",
};
saveAuthProfileStore(localUpdateStore, childAgentDir, {
filterExternalAuthProfiles: false,
});
const child = JSON.parse(await fs.readFile(childAuthPath, "utf8")) as {
profiles: Record<string, unknown>;
};
expect(child.profiles["openai:default"]).toMatchObject({
type: "api_key",
provider: "openai",
});
expect(child.profiles["openai-codex:default"]).toBeUndefined();
saveAuthProfileStore({
version: 1,
profiles: {
"openai-codex:default": {
type: "oauth",
provider: "openai-codex",
access: "main-refreshed-access-token",
refresh: "main-refreshed-refresh-token",
expires: Date.now() + 120_000,
},
},
});
expect(ensureAuthProfileStore(childAgentDir).profiles["openai-codex:default"]).toMatchObject({
type: "oauth",
access: "main-refreshed-access-token",
refresh: "main-refreshed-refresh-token",
});
} finally {
clearRuntimeAuthProfileStoreSnapshots();
vi.unstubAllEnvs();
await fs.rm(root, { recursive: true, force: true });
}
});
it("does not persist stale inherited main OAuth after main refreshes", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-save-stale-inherited-"));
const stateDir = path.join(root, ".openclaw");
const childAgentDir = path.join(stateDir, "agents", "worker", "agent");
const childAuthPath = resolveAuthStorePath(childAgentDir);
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
vi.stubEnv("OPENCLAW_AGENT_DIR", "");
try {
saveAuthProfileStore({
version: 1,
profiles: {
"openai-codex:default": {
type: "oauth",
provider: "openai-codex",
access: "main-old-access-token",
refresh: "main-old-refresh-token",
expires: Date.now() + 60_000,
accountId: "acct-shared",
email: "codex@example.test",
},
},
});
const localUpdateStore = ensureAuthProfileStoreForLocalUpdate(childAgentDir);
expect(localUpdateStore.profiles["openai-codex:default"]).toMatchObject({
type: "oauth",
refresh: "main-old-refresh-token",
});
saveAuthProfileStore({
version: 1,
profiles: {
"openai-codex:default": {
type: "oauth",
provider: "openai-codex",
access: "main-refreshed-access-token",
refresh: "main-refreshed-refresh-token",
expires: Date.now() + 120_000,
accountId: "acct-shared",
email: "codex@example.test",
},
},
});
localUpdateStore.profiles["openai:default"] = {
type: "api_key",
provider: "openai",
key: "sk-child-local",
};
saveAuthProfileStore(localUpdateStore, childAgentDir, {
filterExternalAuthProfiles: false,
});
const child = JSON.parse(await fs.readFile(childAuthPath, "utf8")) as {
profiles: Record<string, unknown>;
};
expect(child.profiles["openai:default"]).toMatchObject({
type: "api_key",
provider: "openai",
});
expect(child.profiles["openai-codex:default"]).toBeUndefined();
expect(ensureAuthProfileStore(childAgentDir).profiles["openai-codex:default"]).toMatchObject({
type: "oauth",
access: "main-refreshed-access-token",
refresh: "main-refreshed-refresh-token",
});
} finally {
clearRuntimeAuthProfileStoreSnapshots();
vi.unstubAllEnvs();
await fs.rm(root, { recursive: true, force: true });
}
});
it("preserves inherited main OAuth in active secondary runtime snapshots", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-save-snapshot-"));
const stateDir = path.join(root, ".openclaw");
const childAgentDir = path.join(stateDir, "agents", "worker", "agent");
const childAuthPath = resolveAuthStorePath(childAgentDir);
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
vi.stubEnv("OPENCLAW_AGENT_DIR", "");
try {
saveAuthProfileStore({
version: 1,
profiles: {
"openai-codex:default": {
type: "oauth",
provider: "openai-codex",
access: "main-access-token",
refresh: "main-refresh-token",
expires: Date.now() + 60_000,
},
},
});
const localUpdateStore = ensureAuthProfileStoreForLocalUpdate(childAgentDir);
localUpdateStore.profiles["openai:default"] = {
type: "api_key",
provider: "openai",
key: "sk-child-local",
};
replaceRuntimeAuthProfileStoreSnapshots([
{
agentDir: childAgentDir,
store: localUpdateStore,
},
]);
saveAuthProfileStore(localUpdateStore, childAgentDir, {
filterExternalAuthProfiles: false,
});
const child = JSON.parse(await fs.readFile(childAuthPath, "utf8")) as {
profiles: Record<string, unknown>;
};
expect(child.profiles["openai-codex:default"]).toBeUndefined();
const runtime = ensureAuthProfileStore(childAgentDir);
expect(runtime.profiles["openai:default"]).toMatchObject({
type: "api_key",
provider: "openai",
});
expect(runtime.profiles["openai-codex:default"]).toMatchObject({
type: "oauth",
access: "main-access-token",
refresh: "main-refresh-token",
});
saveAuthProfileStore({
version: 1,
profiles: {
"openai-codex:default": {
type: "oauth",
provider: "openai-codex",
access: "main-refreshed-access-token",
refresh: "main-refreshed-refresh-token",
expires: Date.now() + 120_000,
},
},
});
expect(ensureAuthProfileStore(childAgentDir).profiles["openai-codex:default"]).toMatchObject({
type: "oauth",
access: "main-refreshed-access-token",
refresh: "main-refreshed-refresh-token",
});
} finally {
clearRuntimeAuthProfileStoreSnapshots();
vi.unstubAllEnvs();
await fs.rm(root, { recursive: true, force: true });
}
});
});

View File

@@ -24,6 +24,13 @@ export {
repairOAuthProfileIdMismatch,
suggestOAuthProfileIdForLegacyDefault,
} from "./auth-profiles/repair.js";
export {
buildPortableAuthProfileSecretsStoreForAgentCopy,
isAuthProfileCredentialPortableForAgentCopy,
resolveAuthProfilePortability,
type AuthProfilePortability,
type AuthProfilePortabilityReason,
} from "./auth-profiles/portability.js";
export {
clearRuntimeAuthProfileStoreSnapshots,
ensureAuthProfileStore,
@@ -35,6 +42,8 @@ export {
replaceRuntimeAuthProfileStoreSnapshots,
loadAuthProfileStore,
saveAuthProfileStore,
findPersistedAuthProfileCredential,
resolvePersistedAuthProfileOwnerAgentDir,
} from "./auth-profiles/store.js";
export type {
ApiKeyCredential,

View File

@@ -28,6 +28,7 @@ import {
ensureAuthProfileStore,
loadAuthProfileStoreForSecretsRuntime,
saveAuthProfileStore,
resolvePersistedAuthProfileOwnerAgentDir,
updateAuthProfileStoreWithLock,
} from "./store.js";
import type { AuthProfileStore, OAuthCredential, OAuthCredentials } from "./types.js";
@@ -228,7 +229,6 @@ export function createOAuthManager(adapter: OAuthManagerAdapter) {
isSafeToAdoptMainStoreOAuthIdentity(params.credential, mainCred)
) {
params.store.profiles[params.profileId] = { ...mainCred };
saveAuthProfileStore(params.store, params.agentDir);
log.info("adopted newer OAuth credentials from main agent", {
profileId: params.profileId,
agentDir: params.agentDir,
@@ -317,14 +317,15 @@ export function createOAuthManager(adapter: OAuthManagerAdapter) {
provider: string;
agentDir?: string;
}): Promise<ResolvedOAuthAccess | null> {
const authPath = resolveAuthStorePath(params.agentDir);
const ownerAgentDir = resolvePersistedAuthProfileOwnerAgentDir(params);
const authPath = resolveAuthStorePath(ownerAgentDir);
ensureAuthStoreFile(authPath);
const globalRefreshLockPath = resolveOAuthRefreshLockPath(params.provider, params.profileId);
try {
return await withFileLock(globalRefreshLockPath, OAUTH_REFRESH_LOCK_OPTIONS, async () =>
withFileLock(authPath, AUTH_STORE_LOCK_OPTIONS, async () => {
const store = loadAuthProfileStoreForSecretsRuntime(params.agentDir);
const store = loadAuthProfileStoreForSecretsRuntime(ownerAgentDir);
const cred = store.profiles[params.profileId];
if (!cred || cred.type !== "oauth") {
return null;
@@ -349,7 +350,6 @@ export function createOAuthManager(adapter: OAuthManagerAdapter) {
isSafeToAdoptMainStoreOAuthIdentity(cred, mainCred)
) {
store.profiles[params.profileId] = { ...mainCred };
saveAuthProfileStore(store, params.agentDir);
log.info("adopted fresh OAuth credential from main store (under refresh lock)", {
profileId: params.profileId,
agentDir: params.agentDir,
@@ -402,7 +402,7 @@ export function createOAuthManager(adapter: OAuthManagerAdapter) {
!areOAuthCredentialsEquivalent(cred, externallyManaged)
) {
store.profiles[params.profileId] = { ...externallyManaged };
saveAuthProfileStore(store, params.agentDir);
saveAuthProfileStore(store, ownerAgentDir);
}
credentialToRefresh = externallyManaged;
if (hasUsableOAuthCredential(externallyManaged)) {
@@ -432,8 +432,8 @@ export function createOAuthManager(adapter: OAuthManagerAdapter) {
return null;
}
store.profiles[params.profileId] = refreshedCredentials;
saveAuthProfileStore(store, params.agentDir);
if (params.agentDir) {
saveAuthProfileStore(store, ownerAgentDir);
if (ownerAgentDir) {
const mainPath = resolveAuthStorePath(undefined);
if (mainPath !== authPath) {
await mirrorRefreshedCredentialIntoMainStore({
@@ -569,7 +569,6 @@ export function createOAuthManager(adapter: OAuthManagerAdapter) {
isSafeToAdoptMainStoreOAuthIdentity(params.credential, mainCred)
) {
refreshedStore.profiles[params.profileId] = { ...mainCred };
saveAuthProfileStore(refreshedStore, params.agentDir);
log.info("inherited fresh OAuth credentials from main agent", {
profileId: params.profileId,
agentDir: params.agentDir,

View File

@@ -183,20 +183,20 @@ describe("resolveApiKeyForProfile fallback to main agent", () => {
// Load the secondary agent's store (will merge with main agent's store)
// Call resolveApiKeyForProfile with the secondary agent's expired credentials:
// refresh fails, then fallback copies main credentials to secondary.
// fresh main credentials are used read-through without copying the refresh token.
const result = await resolveFromSecondaryAgent(profileId);
expect(result).not.toBeNull();
expect(result?.apiKey).toBe("fresh-access-token");
expect(result?.provider).toBe("anthropic");
// Verify the credentials were copied to the secondary agent
const updatedSecondaryStore = JSON.parse(
// The secondary store keeps its local credential; inherited OAuth is read-through.
const secondaryStore = JSON.parse(
await fs.readFile(path.join(secondaryAgentDir, "auth-profiles.json"), "utf8"),
) as AuthProfileStore;
expect(updatedSecondaryStore.profiles[profileId]).toMatchObject({
access: "fresh-access-token",
expires: freshTime,
expect(secondaryStore.profiles[profileId]).toMatchObject({
access: "expired-access-token",
expires: expiredTime,
});
});
@@ -230,12 +230,12 @@ describe("resolveApiKeyForProfile fallback to main agent", () => {
expect(result?.apiKey).toBe("main-newer-access-token");
const updatedSecondaryStore = JSON.parse(
const secondaryStore = JSON.parse(
await fs.readFile(path.join(secondaryAgentDir, "auth-profiles.json"), "utf8"),
) as AuthProfileStore;
expect(updatedSecondaryStore.profiles[profileId]).toMatchObject({
access: "main-newer-access-token",
expires: mainExpiry,
expect(secondaryStore.profiles[profileId]).toMatchObject({
access: "secondary-access-token",
expires: secondaryExpiry,
});
});

View File

@@ -207,6 +207,140 @@ describe("resolveApiKeyForProfile OAuth refresh mirror-to-main (#26322)", () =>
expect(refreshProviderOAuthCredentialWithPluginMock).not.toHaveBeenCalled();
});
it("answers app-server forced refresh from fresh main credentials when a sub-agent copy is expired", async () => {
const profileId = "openai-codex:peter@example.test";
const provider = "openai-codex";
const freshExpiry = Date.now() + 60 * 60 * 1000;
const subAgentDir = path.join(tempRoot, "agents", "sub-app-server-force", "agent");
await fs.mkdir(subAgentDir, { recursive: true });
saveAuthProfileStore(
createExpiredOauthStore({
profileId,
provider,
accountId: "acct-shared",
email: "peter@example.test",
}),
subAgentDir,
);
saveAuthProfileStore(
{
version: 1,
profiles: {
[profileId]: {
type: "oauth",
provider,
access: "main-fresh-access",
refresh: "main-fresh-refresh",
expires: freshExpiry,
accountId: "acct-shared",
email: "peter@example.test",
},
},
},
mainAgentDir,
);
const store = ensureAuthProfileStore(subAgentDir);
const credential = store.profiles[profileId];
if (!credential || credential.type !== "oauth") {
throw new Error("expected seeded OAuth profile");
}
store.profiles[profileId] = { ...credential, expires: 0 };
saveAuthProfileStore(store, subAgentDir);
const result = await resolveApiKeyForProfileInTest(resolveApiKeyForProfile, {
store,
profileId,
agentDir: subAgentDir,
});
expect(result?.apiKey).toBe("main-fresh-access");
expect(result?.provider).toBe(provider);
expect(refreshProviderOAuthCredentialWithPluginMock).not.toHaveBeenCalled();
});
it("refreshes the main owner when a stale local OAuth clone shadows a newer main credential", async () => {
const profileId = "openai-codex:default";
const provider = "openai-codex";
const accountId = "acct-shared";
const now = Date.now();
const freshExpiry = now + 60 * 60 * 1000;
const subAgentDir = path.join(tempRoot, "agents", "sub-stale-clone-owner", "agent");
await fs.mkdir(subAgentDir, { recursive: true });
saveAuthProfileStore(
{
version: 1,
profiles: {
[profileId]: {
type: "oauth",
provider,
access: "local-stale-access",
refresh: "local-stale-refresh",
expires: now - 120_000,
accountId,
},
},
},
subAgentDir,
);
saveAuthProfileStore(
{
version: 1,
profiles: {
[profileId]: {
type: "oauth",
provider,
access: "main-expired-access",
refresh: "main-owner-refresh",
expires: now - 60_000,
accountId,
},
},
},
mainAgentDir,
);
refreshProviderOAuthCredentialWithPluginMock.mockImplementationOnce(
async (params?: { context?: unknown }) => {
const credential = params?.context as OAuthCredential | undefined;
expect(credential?.refresh).toBe("main-owner-refresh");
return {
access: "main-owner-refreshed-access",
refresh: "main-owner-refreshed-refresh",
expires: freshExpiry,
} as never;
},
);
const result = await resolveApiKeyForProfileInTest(resolveApiKeyForProfile, {
store: ensureAuthProfileStore(subAgentDir),
profileId,
agentDir: subAgentDir,
});
expect(result?.apiKey).toBe("main-owner-refreshed-access");
expect(refreshProviderOAuthCredentialWithPluginMock).toHaveBeenCalledTimes(1);
const subRaw = JSON.parse(
await fs.readFile(path.join(subAgentDir, "auth-profiles.json"), "utf8"),
) as AuthProfileStore;
expect(subRaw.profiles[profileId]).toMatchObject({
access: "local-stale-access",
refresh: "local-stale-refresh",
});
const mainRaw = JSON.parse(
await fs.readFile(path.join(mainAgentDir, "auth-profiles.json"), "utf8"),
) as AuthProfileStore;
expect(mainRaw.profiles[profileId]).toMatchObject({
access: "main-owner-refreshed-access",
refresh: "main-owner-refreshed-refresh",
expires: freshExpiry,
});
});
it("inherits main-agent credentials via the catch-block fallback when refresh throws after main becomes fresh", async () => {
// Exercises the specific catch-block `if (params.agentDir) { mainStore … }`
// branch (lines 826-848 in oauth.ts). Setup:
@@ -218,8 +352,8 @@ describe("resolveApiKeyForProfile OAuth refresh mirror-to-main (#26322)", () =>
// completed a refresh just as ours failed".
// 3. The catch block's loadFreshStoredOAuthCredential reads the sub
// store (still expired). Then the main-agent-inherit fallback
// kicks in, copies main's fresh creds into the sub store, and
// returns them.
// kicks in and returns main's fresh creds read-through without copying
// the refresh token into the sub store.
const profileId = "openai-codex:default";
const provider = "openai-codex";
const freshExpiry = Date.now() + 60 * 60 * 1000;
@@ -268,13 +402,12 @@ describe("resolveApiKeyForProfile OAuth refresh mirror-to-main (#26322)", () =>
expect(result?.apiKey).toBe("main-side-refreshed-access");
expect(result?.provider).toBe(provider);
// Sub-agent's store should now carry main's creds (inherited).
// Sub-agent's store keeps its local expired credential; inherited OAuth is read-through.
const subRaw = JSON.parse(
await fs.readFile(path.join(subAgentDir, "auth-profiles.json"), "utf8"),
) as AuthProfileStore;
expect(subRaw.profiles[profileId]).toMatchObject({
access: "main-side-refreshed-access",
expires: freshExpiry,
access: "cached-access-token",
});
});

View File

@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import {
buildPortableAuthProfileSecretsStoreForAgentCopy,
resolveAuthProfilePortability,
} from "./portability.js";
import type { AuthProfileCredential, AuthProfileStore } from "./types.js";
describe("auth profile portability", () => {
it("copies static credentials but skips OAuth refresh tokens by default", () => {
const store: AuthProfileStore = {
version: 1,
profiles: {
"openai:default": {
type: "api_key",
provider: "openai",
key: "sk-test",
},
"github-copilot:default": {
type: "token",
provider: "github-copilot",
token: "gho-test",
},
"openai-codex:default": {
type: "oauth",
provider: "openai-codex",
access: "access-token",
refresh: "refresh-token",
expires: Date.now() + 60_000,
},
},
};
const portable = buildPortableAuthProfileSecretsStoreForAgentCopy(store);
expect(portable.copiedProfileIds).toEqual(["openai:default", "github-copilot:default"]);
expect(portable.skippedProfileIds).toEqual(["openai-codex:default"]);
expect(portable.store.profiles).toEqual({
"openai:default": store.profiles["openai:default"],
"github-copilot:default": store.profiles["github-copilot:default"],
});
});
it("allows provider-owned OAuth profiles to opt in explicitly", () => {
const credential: AuthProfileCredential = {
type: "oauth",
provider: "demo",
access: "access-token",
refresh: "refresh-token",
expires: Date.now() + 60_000,
copyToAgents: true,
};
expect(resolveAuthProfilePortability(credential)).toEqual({
portable: true,
reason: "oauth-provider-opted-in",
});
});
it("lets static credentials opt out", () => {
expect(
resolveAuthProfilePortability({
type: "api_key",
provider: "openai",
key: "sk-test",
copyToAgents: false,
}),
).toEqual({
portable: false,
reason: "credential-opted-out",
});
});
});

View File

@@ -0,0 +1,63 @@
import { AUTH_STORE_VERSION } from "./constants.js";
import type { AuthProfileCredential, AuthProfileSecretsStore, AuthProfileStore } from "./types.js";
export type AuthProfilePortabilityReason =
| "portable-static-credential"
| "non-portable-oauth-refresh-token"
| "credential-opted-out"
| "oauth-provider-opted-in";
export type AuthProfilePortability = {
portable: boolean;
reason: AuthProfilePortabilityReason;
};
function hasAgentCopyOverride(credential: AuthProfileCredential): boolean | undefined {
return typeof credential.copyToAgents === "boolean" ? credential.copyToAgents : undefined;
}
export function resolveAuthProfilePortability(
credential: AuthProfileCredential,
): AuthProfilePortability {
const override = hasAgentCopyOverride(credential);
if (override === false) {
return { portable: false, reason: "credential-opted-out" };
}
if (credential.type === "oauth") {
return override === true
? { portable: true, reason: "oauth-provider-opted-in" }
: { portable: false, reason: "non-portable-oauth-refresh-token" };
}
return { portable: true, reason: "portable-static-credential" };
}
export function isAuthProfileCredentialPortableForAgentCopy(
credential: AuthProfileCredential,
): boolean {
return resolveAuthProfilePortability(credential).portable;
}
export function buildPortableAuthProfileSecretsStoreForAgentCopy(store: AuthProfileStore): {
store: AuthProfileSecretsStore;
copiedProfileIds: string[];
skippedProfileIds: string[];
} {
const copiedProfileIds: string[] = [];
const skippedProfileIds: string[] = [];
const profiles = Object.fromEntries(
Object.entries(store.profiles).flatMap(([profileId, credential]) => {
if (!isAuthProfileCredentialPortableForAgentCopy(credential)) {
skippedProfileIds.push(profileId);
return [];
}
copiedProfileIds.push(profileId);
return [[profileId, credential]];
}),
) as AuthProfileSecretsStore["profiles"];
return {
store: { version: AUTH_STORE_VERSION, profiles },
copiedProfileIds,
skippedProfileIds,
};
}

View File

@@ -1,4 +1,5 @@
import fs from "node:fs";
import { isDeepStrictEqual } from "node:util";
import { withFileLock } from "../../infra/file-lock.js";
import { saveJsonFile } from "../../infra/json-file.js";
import {
@@ -8,6 +9,7 @@ import {
log,
} from "./constants.js";
import { overlayExternalAuthProfiles, shouldPersistExternalAuthProfile } from "./external-auth.js";
import { hasOAuthIdentity, isSafeToAdoptMainStoreOAuthIdentity } from "./oauth-shared.js";
import {
ensureAuthStoreFile,
resolveAuthStatePath,
@@ -59,6 +61,53 @@ function cloneAuthProfileStore(store: AuthProfileStore): AuthProfileStore {
return structuredClone(store);
}
function isInheritedMainOAuthCredential(params: {
agentDir?: string;
profileId: string;
credential: AuthProfileStore["profiles"][string];
}): boolean {
if (!params.agentDir || params.credential.type !== "oauth") {
return false;
}
const authPath = resolveAuthStorePath(params.agentDir);
const mainAuthPath = resolveAuthStorePath();
if (authPath === mainAuthPath) {
return false;
}
const localStore = loadPersistedAuthProfileStore(params.agentDir);
if (localStore?.profiles[params.profileId]) {
return false;
}
const mainCredential = loadPersistedAuthProfileStore()?.profiles[params.profileId];
return (
mainCredential?.type === "oauth" &&
(isDeepStrictEqual(mainCredential, params.credential) ||
(hasOAuthIdentity(params.credential) &&
isSafeToAdoptMainStoreOAuthIdentity(params.credential, mainCredential)))
);
}
function shouldUseMainOwnerForLocalOAuthCredential(params: {
local: AuthProfileStore["profiles"][string];
main: AuthProfileStore["profiles"][string] | undefined;
}): boolean {
if (params.local.type !== "oauth" || params.main?.type !== "oauth") {
return false;
}
if (!isSafeToAdoptMainStoreOAuthIdentity(params.local, params.main)) {
return false;
}
if (isDeepStrictEqual(params.local, params.main)) {
return true;
}
return (
Number.isFinite(params.main.expires) &&
(!Number.isFinite(params.local.expires) || params.main.expires >= params.local.expires)
);
}
function resolveRuntimeAuthProfileStore(agentDir?: string): AuthProfileStore | null {
const mainKey = resolveAuthStorePath(undefined);
const requestedKey = resolveAuthStorePath(agentDir);
@@ -76,7 +125,11 @@ function resolveRuntimeAuthProfileStore(agentDir?: string): AuthProfileStore | n
return mergeAuthProfileStores(mainStore, requestedStore);
}
if (requestedStore) {
return requestedStore;
const persistedMainStore = loadAuthProfileStoreForAgent(undefined, {
readOnly: true,
syncExternalCli: false,
});
return mergeAuthProfileStores(persistedMainStore, requestedStore);
}
if (mainStore) {
return mainStore;
@@ -126,6 +179,56 @@ function writeCachedAuthProfileStore(params: {
});
}
function shouldKeepProfileInLocalStore(params: {
store: AuthProfileStore;
profileId: string;
credential: AuthProfileStore["profiles"][string];
agentDir?: string;
options?: SaveAuthProfileStoreOptions;
}): boolean {
if (params.credential.type !== "oauth") {
return true;
}
if (
isInheritedMainOAuthCredential({
agentDir: params.agentDir,
profileId: params.profileId,
credential: params.credential,
})
) {
return false;
}
if (params.options?.filterExternalAuthProfiles === false) {
return true;
}
return shouldPersistExternalAuthProfile({
store: params.store,
profileId: params.profileId,
credential: params.credential,
agentDir: params.agentDir,
});
}
function buildLocalAuthProfileStoreForSave(params: {
store: AuthProfileStore;
agentDir?: string;
options?: SaveAuthProfileStoreOptions;
}): AuthProfileStore {
const localStore = cloneAuthProfileStore(params.store);
localStore.profiles = Object.fromEntries(
Object.entries(localStore.profiles).filter(([profileId, credential]) =>
shouldKeepProfileInLocalStore({
store: params.store,
profileId,
credential,
agentDir: params.agentDir,
options: params.options,
}),
),
);
return localStore;
}
export async function updateAuthProfileStoreWithLock(params: {
agentDir?: string;
updater: (store: AuthProfileStore) => boolean;
@@ -201,24 +304,6 @@ function loadAuthProfileStoreForAgent(
return asStore;
}
// Fallback: inherit auth-profiles from main agent if subagent has none
if (agentDir && !readOnly) {
const mainStore = loadPersistedAuthProfileStore();
if (mainStore && Object.keys(mainStore.profiles).length > 0) {
// Clone only secret-bearing profiles to subagent directory for auth inheritance.
saveJsonFile(authPath, buildPersistedAuthProfileSecretsStore(mainStore));
log.info("inherited auth-profiles from main agent", { agentDir });
const inherited = { version: mainStore.version, profiles: { ...mainStore.profiles } };
writeCachedAuthProfileStore({
authPath,
authMtimeMs: readAuthStoreMtimeMs(authPath),
stateMtimeMs: readAuthStoreMtimeMs(statePath),
store: inherited,
});
return inherited;
}
}
const legacy = loadLegacyAuthProfileStore(agentDir);
const store: AuthProfileStore = {
version: AUTH_STORE_VERSION,
@@ -362,6 +447,34 @@ export function findPersistedAuthProfileCredential(params: {
return loadPersistedAuthProfileStore()?.profiles[params.profileId];
}
export function resolvePersistedAuthProfileOwnerAgentDir(params: {
agentDir?: string;
profileId: string;
}): string | undefined {
if (!params.agentDir) {
return undefined;
}
const requestedStore = loadPersistedAuthProfileStore(params.agentDir);
const requestedPath = resolveAuthStorePath(params.agentDir);
const mainPath = resolveAuthStorePath();
if (requestedPath === mainPath) {
return undefined;
}
const mainStore = loadPersistedAuthProfileStore();
const requestedProfile = requestedStore?.profiles[params.profileId];
if (requestedProfile) {
return shouldUseMainOwnerForLocalOAuthCredential({
local: requestedProfile,
main: mainStore?.profiles[params.profileId],
})
? undefined
: params.agentDir;
}
return mainStore?.profiles[params.profileId] ? undefined : params.agentDir;
}
export function ensureAuthProfileStoreForLocalUpdate(agentDir?: string): AuthProfileStore {
const options: LoadAuthProfileStoreOptions = { syncExternalCli: false };
const store = loadAuthProfileStoreForAgent(agentDir, options);
@@ -398,30 +511,17 @@ export function saveAuthProfileStore(
): void {
const authPath = resolveAuthStorePath(agentDir);
const statePath = resolveAuthStatePath(agentDir);
const payload = buildPersistedAuthProfileSecretsStore(store, ({ profileId, credential }) => {
if (credential.type !== "oauth") {
return true;
}
if (options?.filterExternalAuthProfiles === false) {
return true;
}
return shouldPersistExternalAuthProfile({
store,
profileId,
credential,
agentDir,
});
});
const localStore = buildLocalAuthProfileStoreForSave({ store, agentDir, options });
const payload = buildPersistedAuthProfileSecretsStore(localStore);
saveJsonFile(authPath, payload);
savePersistedAuthProfileState(store, agentDir);
const runtimeStore = cloneAuthProfileStore(store);
savePersistedAuthProfileState(localStore, agentDir);
writeCachedAuthProfileStore({
authPath,
authMtimeMs: readAuthStoreMtimeMs(authPath),
stateMtimeMs: readAuthStoreMtimeMs(statePath),
store: runtimeStore,
store: localStore,
});
if (hasRuntimeAuthProfileStoreSnapshot(agentDir)) {
setRuntimeAuthProfileStoreSnapshot(runtimeStore, agentDir);
setRuntimeAuthProfileStoreSnapshot(localStore, agentDir);
}
}

View File

@@ -20,6 +20,8 @@ export type ApiKeyCredential = {
provider: string;
key?: string;
keyRef?: SecretRef;
/** Explicit opt-out for copying this profile when creating another agent. */
copyToAgents?: boolean;
email?: string;
displayName?: string;
/** Optional provider-specific metadata (e.g., account IDs, gateway IDs). */
@@ -35,6 +37,8 @@ export type TokenCredential = {
provider: string;
token?: string;
tokenRef?: SecretRef;
/** Explicit opt-out for copying this profile when creating another agent. */
copyToAgents?: boolean;
/** Optional expiry timestamp (ms since epoch). */
expires?: number;
email?: string;
@@ -45,6 +49,11 @@ export type OAuthCredential = OAuthCredentials & {
type: "oauth";
provider: string;
clientId?: string;
/**
* OAuth refresh tokens are not portable by default. Provider-owned flows may
* set this only when copying refresh material across agents is known safe.
*/
copyToAgents?: boolean;
email?: string;
displayName?: string;
};

View File

@@ -641,7 +641,7 @@ export async function resolveApiKeyForProvider(params: {
[
`No API key found for provider "${provider}".`,
`Auth store: ${authStorePath} (agentDir: ${resolvedAgentDir}).`,
`Configure auth for this agent (${formatCliCommand("openclaw agents add <id>")}) or copy auth-profiles.json from the main agentDir.`,
`Configure auth for this agent (${formatCliCommand("openclaw agents add <id>")}) or copy only portable static auth profiles from the main agentDir.`,
].join(" "),
);
}

View File

@@ -1,4 +1,8 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { AUTH_STORE_VERSION } from "../agents/auth-profiles/constants.js";
import { baseConfigSnapshot, createTestRuntime } from "./test-runtime-config-helpers.js";
const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn());
@@ -23,6 +27,7 @@ vi.mock("../wizard/clack-prompter.js", () => ({
}));
import { WizardCancelledError } from "../wizard/prompts.js";
import { __testing } from "./agents.commands.add.js";
import { agentsAddCommand } from "./agents.js";
const runtime = createTestRuntime();
@@ -75,4 +80,77 @@ describe("agents add command", () => {
expect(runtime.exit).toHaveBeenCalledWith(1);
expect(writeConfigFileMock).not.toHaveBeenCalled();
});
it("copies only portable auth profiles when seeding a new agent store", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agents-add-auth-copy-"));
try {
const sourceAgentDir = path.join(root, "main", "agent");
const destAgentDir = path.join(root, "work", "agent");
const destAuthPath = path.join(destAgentDir, "auth-profiles.json");
await fs.mkdir(sourceAgentDir, { recursive: true });
await fs.writeFile(
path.join(sourceAgentDir, "auth-profiles.json"),
`${JSON.stringify(
{
version: AUTH_STORE_VERSION,
profiles: {
"openai:default": {
type: "api_key",
provider: "openai",
key: "sk-test",
},
"github-copilot:default": {
type: "token",
provider: "github-copilot",
token: "gho-test",
},
"openai-codex:default": {
type: "oauth",
provider: "openai-codex",
access: "codex-access",
refresh: "codex-refresh",
expires: Date.now() + 60_000,
},
},
},
null,
2,
)}\n`,
"utf8",
);
const result = await __testing.copyPortableAuthProfiles({
sourceAgentDir,
destAuthPath,
});
expect(result).toEqual({ copied: 2, skipped: 1 });
const copied = JSON.parse(await fs.readFile(destAuthPath, "utf8")) as {
profiles: Record<string, unknown>;
};
expect(Object.keys(copied.profiles).toSorted()).toEqual([
"github-copilot:default",
"openai:default",
]);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("does not claim skipped OAuth profiles stay shared from a non-main source agent", () => {
expect(
__testing.formatSkippedOAuthProfilesMessage({
sourceAgentId: "default-work",
sourceIsInheritedMain: false,
}),
).toBe(
'OAuth profiles were not copied from "default-work"; sign in separately for this agent.',
);
expect(
__testing.formatSkippedOAuthProfilesMessage({
sourceAgentId: "main",
sourceIsInheritedMain: true,
}),
).toBe('OAuth profiles stay shared from "main" unless this agent signs in separately.');
});
});

View File

@@ -5,10 +5,15 @@ import {
resolveAgentWorkspaceDir,
resolveDefaultAgentId,
} from "../agents/agent-scope.js";
import { ensureAuthProfileStore } from "../agents/auth-profiles.js";
import {
buildPortableAuthProfileSecretsStoreForAgentCopy,
ensureAuthProfileStore,
} from "../agents/auth-profiles.js";
import { resolveAuthStorePath } from "../agents/auth-profiles/paths.js";
import { loadPersistedAuthProfileStore } from "../agents/auth-profiles/persisted.js";
import { commitConfigWithPendingPluginInstalls } from "../cli/plugins-install-record-commit.js";
import { logConfigUpdated } from "../config/logging.js";
import { saveJsonFile } from "../infra/json-file.js";
import { DEFAULT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js";
import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
@@ -52,6 +57,35 @@ async function fileExists(pathname: string): Promise<boolean> {
}
}
async function copyPortableAuthProfiles(params: {
destAuthPath: string;
sourceAgentDir: string;
}): Promise<{ copied: number; skipped: number }> {
const sourceStore = loadPersistedAuthProfileStore(params.sourceAgentDir);
if (!sourceStore || Object.keys(sourceStore.profiles).length === 0) {
return { copied: 0, skipped: 0 };
}
const portable = buildPortableAuthProfileSecretsStoreForAgentCopy(sourceStore);
if (portable.copiedProfileIds.length === 0) {
return { copied: 0, skipped: portable.skippedProfileIds.length };
}
await fs.mkdir(path.dirname(params.destAuthPath), { recursive: true });
saveJsonFile(params.destAuthPath, portable.store);
return {
copied: portable.copiedProfileIds.length,
skipped: portable.skippedProfileIds.length,
};
}
function formatSkippedOAuthProfilesMessage(params: {
sourceAgentId: string;
sourceIsInheritedMain: boolean;
}): string {
return params.sourceIsInheritedMain
? `OAuth profiles stay shared from "${params.sourceAgentId}" unless this agent signs in separately.`
: `OAuth profiles were not copied from "${params.sourceAgentId}"; sign in separately for this agent.`;
}
export async function agentsAddCommand(
opts: AgentsAddOptions,
runtime: RuntimeEnv = defaultRuntime,
@@ -244,24 +278,53 @@ export async function agentsAddCommand(
const defaultAgentId = resolveDefaultAgentId(cfg);
if (defaultAgentId !== agentId) {
const sourceAuthPath = resolveAuthStorePath(resolveAgentDir(cfg, defaultAgentId));
const sourceAgentDir = resolveAgentDir(cfg, defaultAgentId);
const sourceAuthPath = resolveAuthStorePath(sourceAgentDir);
const destAuthPath = resolveAuthStorePath(agentDir);
const mainAuthPath = resolveAuthStorePath(undefined);
const sameAuthPath =
normalizeLowercaseStringOrEmpty(path.resolve(sourceAuthPath)) ===
normalizeLowercaseStringOrEmpty(path.resolve(destAuthPath));
const sourceIsInheritedMain =
normalizeLowercaseStringOrEmpty(path.resolve(sourceAuthPath)) ===
normalizeLowercaseStringOrEmpty(path.resolve(mainAuthPath));
if (
!sameAuthPath &&
(await fileExists(sourceAuthPath)) &&
!(await fileExists(destAuthPath))
) {
const shouldCopy = await prompter.confirm({
message: `Copy auth profiles from "${defaultAgentId}"?`,
initialValue: false,
});
if (shouldCopy) {
await fs.mkdir(path.dirname(destAuthPath), { recursive: true });
await fs.copyFile(sourceAuthPath, destAuthPath);
await prompter.note(`Copied auth profiles from "${defaultAgentId}".`, "Auth profiles");
const sourceStore = loadPersistedAuthProfileStore(sourceAgentDir);
const portable = sourceStore
? buildPortableAuthProfileSecretsStoreForAgentCopy(sourceStore)
: undefined;
if (portable && portable.copiedProfileIds.length > 0) {
const shouldCopy = await prompter.confirm({
message: `Copy portable auth profiles from "${defaultAgentId}"?`,
initialValue: false,
});
if (shouldCopy) {
await fs.mkdir(path.dirname(destAuthPath), { recursive: true });
saveJsonFile(destAuthPath, portable.store);
const skippedText =
portable.skippedProfileIds.length > 0
? ` ${formatSkippedOAuthProfilesMessage({
sourceAgentId: defaultAgentId,
sourceIsInheritedMain,
})}`
: "";
await prompter.note(
`Copied ${portable.copiedProfileIds.length} portable auth profile${portable.copiedProfileIds.length === 1 ? "" : "s"} from "${defaultAgentId}".${skippedText}`,
"Auth profiles",
);
}
} else if ((portable?.skippedProfileIds.length ?? 0) > 0) {
await prompter.note(
formatSkippedOAuthProfilesMessage({
sourceAgentId: defaultAgentId,
sourceIsInheritedMain,
}),
"Auth profiles",
);
}
}
}
@@ -389,3 +452,8 @@ export async function agentsAddCommand(
throw err;
}
}
export const __testing = {
copyPortableAuthProfiles,
formatSkippedOAuthProfilesMessage,
};

View File

@@ -1,14 +1,24 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NON_ENV_SECRETREF_MARKER } from "../../agents/model-auth-markers.js";
import { withEnv } from "../../test-utils/env.js";
import { resolveProviderAuthOverview } from "./list.auth-overview.js";
const persistedStores = vi.hoisted(() => new Map<string, { profiles: Record<string, unknown> }>());
vi.mock("../../agents/auth-profiles/display.js", () => ({
resolveAuthProfileDisplayLabel: vi.fn(({ profileId }: { profileId: string }) => profileId),
}));
vi.mock("../../agents/auth-profiles/persisted.js", () => ({
loadPersistedAuthProfileStore: vi.fn((agentDir?: string) =>
persistedStores.get(agentDir ?? "__main__"),
),
}));
vi.mock("../../agents/auth-profiles/paths.js", () => ({
resolveAuthStorePathForDisplay: vi.fn(() => "/tmp/auth-profiles.json"),
resolveAuthStorePathForDisplay: vi.fn((agentDir?: string) =>
agentDir ? `${agentDir}/auth-profiles.json` : "/tmp/auth-profiles.json",
),
}));
vi.mock("../../agents/auth-profiles/profiles.js", () => ({
@@ -82,6 +92,10 @@ function resolveOpenAiOverview(apiKey: string) {
}
describe("resolveProviderAuthOverview", () => {
beforeEach(() => {
persistedStores.clear();
});
it("does not throw when token profile only has tokenRef", () => {
const overview = resolveProviderAuthOverview({
provider: "github-copilot",
@@ -102,6 +116,68 @@ describe("resolveProviderAuthOverview", () => {
expect(overview.profiles.labels[0]).toContain("token:ref(env:GITHUB_TOKEN)");
});
it("reports the selected agent auth store when profiles are effective", () => {
persistedStores.set("/tmp/openclaw-agent-custom", {
profiles: {
"openai-codex:peter@example.test": {},
},
});
const overview = resolveProviderAuthOverview({
provider: "openai-codex",
cfg: {},
store: {
version: 1,
profiles: {
"openai-codex:peter@example.test": {
type: "oauth",
provider: "openai-codex",
access: "access-token",
refresh: "refresh-token",
expires: Date.now() + 60_000,
},
},
} as never,
modelsPath: "/tmp/openclaw-agent-custom/models.json",
agentDir: "/tmp/openclaw-agent-custom",
});
expect(overview.effective).toEqual({
kind: "profiles",
detail: "/tmp/openclaw-agent-custom/auth-profiles.json",
});
});
it("reports the main auth store for inherited profiles", () => {
persistedStores.set("__main__", {
profiles: {
"openai-codex:peter@example.test": {},
},
});
const overview = resolveProviderAuthOverview({
provider: "openai-codex",
cfg: {},
store: {
version: 1,
profiles: {
"openai-codex:peter@example.test": {
type: "oauth",
provider: "openai-codex",
access: "access-token",
refresh: "refresh-token",
expires: Date.now() + 60_000,
},
},
} as never,
modelsPath: "/tmp/openclaw-agent-custom/models.json",
agentDir: "/tmp/openclaw-agent-custom",
});
expect(overview.effective).toEqual({
kind: "profiles",
detail: "/tmp/auth-profiles.json",
});
});
it("renders marker-backed models.json auth as marker detail", () => {
const overview = withEnv({ OPENAI_API_KEY: undefined }, () =>
resolveOpenAiOverview(NON_ENV_SECRETREF_MARKER),

View File

@@ -1,6 +1,7 @@
import { formatRemainingShort } from "../../agents/auth-health.js";
import { resolveAuthProfileDisplayLabel } from "../../agents/auth-profiles/display.js";
import { resolveAuthStorePathForDisplay } from "../../agents/auth-profiles/paths.js";
import { loadPersistedAuthProfileStore } from "../../agents/auth-profiles/persisted.js";
import { listProfilesForProvider } from "../../agents/auth-profiles/profiles.js";
import type { AuthProfileStore } from "../../agents/auth-profiles/types.js";
import { resolveProfileUnusableUntilForDisplay } from "../../agents/auth-profiles/usage.js";
@@ -42,11 +43,29 @@ function formatProfileSecretLabel(params: {
return params.kind === "token" ? "token:missing" : "missing";
}
function resolveProfileSourceAgentDir(params: {
agentDir?: string;
profileIds: string[];
}): string | undefined {
if (!params.agentDir || params.profileIds.length === 0) {
return params.agentDir;
}
const localStore = loadPersistedAuthProfileStore(params.agentDir);
if (params.profileIds.some((profileId) => Boolean(localStore?.profiles[profileId]))) {
return params.agentDir;
}
const mainStore = loadPersistedAuthProfileStore(undefined);
return params.profileIds.every((profileId) => Boolean(mainStore?.profiles[profileId]))
? undefined
: params.agentDir;
}
export function resolveProviderAuthOverview(params: {
provider: string;
cfg: OpenClawConfig;
store: AuthProfileStore;
modelsPath: string;
agentDir?: string;
syntheticAuth?: { value: string; source: string };
}): ProviderAuthOverview {
const { provider, cfg, store } = params;
@@ -112,7 +131,14 @@ export function resolveProviderAuthOverview(params: {
if (profiles.length > 0) {
return {
kind: "profiles",
detail: shortenHomePath(resolveAuthStorePathForDisplay()),
detail: shortenHomePath(
resolveAuthStorePathForDisplay(
resolveProfileSourceAgentDir({
agentDir: params.agentDir,
profileIds: profiles,
}),
),
),
};
}
if (envKey) {

View File

@@ -295,6 +295,7 @@ export async function modelsStatusCommand(
cfg,
store,
modelsPath,
agentDir,
syntheticAuth: syntheticAuthByProvider.get(provider),
}),
)

View File

@@ -48,10 +48,11 @@ const mocks = vi.hoisted(() => {
.filter(([, cred]) => cred.provider === provider)
.map(([id]) => id);
}),
loadPersistedAuthProfileStore: vi.fn().mockReturnValue(store),
resolveAuthProfileDisplayLabel: vi.fn(({ profileId }: { profileId: string }) => profileId),
resolveAuthStorePathForDisplay: vi
.fn()
.mockReturnValue("/tmp/openclaw-agent/auth-profiles.json"),
resolveAuthStorePathForDisplay: vi.fn(
(agentDir?: string) => `${agentDir ?? "/tmp/openclaw-agent"}/auth-profiles.json`,
),
resolveProfileUnusableUntilForDisplay: vi.fn().mockReturnValue(undefined),
resolveEnvApiKey: vi.fn((provider: string) => {
if (provider === "openai") {
@@ -142,6 +143,9 @@ vi.mock("../../agents/auth-profiles/display.js", () => ({
vi.mock("../../agents/auth-profiles/paths.js", () => ({
resolveAuthStorePathForDisplay: mocks.resolveAuthStorePathForDisplay,
}));
vi.mock("../../agents/auth-profiles/persisted.js", () => ({
loadPersistedAuthProfileStore: mocks.loadPersistedAuthProfileStore,
}));
vi.mock("../../agents/auth-profiles/profiles.js", () => ({
listProfilesForProvider: mocks.listProfilesForProvider,
}));
@@ -359,6 +363,16 @@ describe("modelsStatusCommand auth overview", () => {
defaultSource: "agent",
fallbacksSource: "agent",
});
const openAiCodex = (
payload.auth.providers as Array<{
provider: string;
effective?: { kind: string; detail?: string };
}>
).find((provider) => provider.provider === "openai-codex");
expect(openAiCodex?.effective).toEqual({
kind: "profiles",
detail: "/tmp/openclaw-agent-custom/auth-profiles.json",
});
},
);
});

View File

@@ -45,6 +45,8 @@ export {
replaceRuntimeAuthProfileStoreSnapshots,
loadAuthProfileStore,
saveAuthProfileStore,
findPersistedAuthProfileCredential,
resolvePersistedAuthProfileOwnerAgentDir,
calculateAuthProfileCooldownMs,
clearAuthProfileCooldown,
clearExpiredCooldowns,