Skip to content

Commit e6cd90e

Browse files
committed
fix(agents): keep OAuth auth read-through
1 parent 21a92ea commit e6cd90e

37 files changed

Lines changed: 1306 additions & 127 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai
1818

1919
### Fixes
2020

21+
- 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.
2122
- 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.
2223
- 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.
2324
- 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.

docs/auth-credential-semantics.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,24 @@ Token credentials (`type: "token"`) support inline `token` and/or `tokenRef`.
4444
2. For eligible profiles, token material may be resolved from inline value or `tokenRef`.
4545
3. Unresolvable refs produce `unresolved_ref` in `models status --probe` output.
4646

47+
## Agent copy portability
48+
49+
Agent auth inheritance is read-through. When an agent has no local profile, it
50+
can resolve profiles from the default/main agent store at runtime without
51+
copying secret material into its own `auth-profiles.json`.
52+
53+
Explicit copy flows, such as `openclaw agents add`, use this portability policy:
54+
55+
- `api_key` profiles are portable unless `copyToAgents: false`.
56+
- `token` profiles are portable unless `copyToAgents: false`.
57+
- `oauth` profiles are not portable by default because refresh tokens can be
58+
single-use or rotation-sensitive.
59+
- Provider-owned OAuth flows may opt in with `copyToAgents: true` only when
60+
copying refresh material across agents is known safe.
61+
62+
Non-portable profiles remain available through read-through inheritance unless
63+
the target agent signs in separately and creates its own local profile.
64+
4765
## Explicit auth order filtering
4866

4967
- When `auth.order.<provider>` or the auth-store order override is set for a

docs/cli/agents.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,11 @@ Notes:
110110
- Passing any explicit add flags switches the command into the non-interactive path.
111111
- Non-interactive mode requires both an agent name and `--workspace`.
112112
- `main` is reserved and cannot be used as the new agent id.
113+
- In interactive mode, auth seeding copies only portable static profiles
114+
(`api_key` and static `token` by default). OAuth refresh-token profiles remain
115+
available only by read-through inheritance from the real `main` agent store.
116+
If the configured default agent is not `main`, sign in separately for OAuth
117+
profiles on the new agent.
113118

114119
### `agents bindings`
115120

docs/concepts/multi-agent.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,12 @@ Auth profiles are **per-agent**. Each agent reads from its own:
2929
</Note>
3030

3131
<Warning>
32-
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`.
32+
Never reuse `agentDir` across agents (it causes auth/session collisions). Agents
33+
can read through to the default/main agent's auth profiles when they do not have
34+
a local profile, but OpenClaw does not clone OAuth refresh tokens into the
35+
secondary agent store. If you want an independent OAuth account, sign in from
36+
that agent; if you copy credentials manually, copy only portable static
37+
`api_key` or `token` profiles.
3338
</Warning>
3439

3540
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).

docs/concepts/oauth.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ To reduce that, OpenClaw treats `auth-profiles.json` as a **token sink**:
5454

5555
## Storage (where tokens live)
5656

57-
Secrets are stored **per-agent**:
57+
Secrets are stored in agent auth stores:
5858

5959
- Auth profiles (OAuth + API keys + optional value-level refs): `~/.openclaw/agents/<agentId>/agent/auth-profiles.json`
6060
- 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
6868

6969
For static secret refs and runtime snapshot activation behavior, see [Secrets Management](/gateway/secrets).
7070

71+
When a secondary agent has no local auth profile, OpenClaw uses read-through
72+
inheritance from the default/main agent store. It does not clone the main
73+
agent's `auth-profiles.json` on read. OAuth refresh tokens are especially
74+
sensitive: normal copy flows skip them by default because some providers rotate
75+
or invalidate refresh tokens after use. Configure a separate OAuth login for an
76+
agent when it needs an independent account.
77+
7178
## Anthropic legacy token compatibility
7279

7380
<Warning>
@@ -132,6 +139,9 @@ At runtime:
132139

133140
- if `expires` is in the future → use the stored access token
134141
- if expired → refresh (under a file lock) and overwrite the stored credentials
142+
- if a secondary agent reads an inherited main-agent OAuth profile, refresh
143+
writes back to the main agent store instead of copying the refresh token into
144+
the secondary agent store
135145
- exception: some external CLI credentials stay externally managed; OpenClaw
136146
re-reads those CLI auth stores instead of spending copied refresh tokens.
137147
Codex CLI bootstrap is intentionally narrower: it seeds an empty

docs/help/faq-models.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -343,7 +343,8 @@ troubleshooting, see the main [FAQ](/help/faq).
343343
Fix options:
344344

345345
- Run `openclaw agents add <id>` and configure auth during the wizard.
346-
- Or copy `auth-profiles.json` from the main agent's `agentDir` into the new agent's `agentDir`.
346+
- Or copy only portable static `api_key` / `token` profiles from the main agent's auth store into the new agent's auth store.
347+
- 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.
347348

348349
Do **not** reuse `agentDir` across agents; it causes auth/session collisions.
349350

docs/tools/multi-agent-sandbox-tools.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ Each agent in a multi-agent setup can override the global sandbox and tool polic
2121
</CardGroup>
2222

2323
<Warning>
24-
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`.
24+
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.
2525
</Warning>
2626

2727
---

extensions/codex/src/app-server/auth-bridge.test.ts

Lines changed: 132 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import fs from "node:fs/promises";
22
import os from "node:os";
33
import path from "node:path";
4+
import {
5+
clearRuntimeAuthProfileStoreSnapshots,
6+
loadAuthProfileStoreForSecretsRuntime,
7+
} from "openclaw/plugin-sdk/agent-runtime";
48
import { upsertAuthProfile } from "openclaw/plugin-sdk/provider-auth";
59
import { afterEach, describe, expect, it, vi } from "vitest";
610
import {
@@ -72,7 +76,7 @@ vi.mock("openclaw/plugin-sdk/agent-runtime", async (importOriginal) => {
7276
if (refreshed?.access) {
7377
oauthCredential = refreshed as typeof oauthCredential;
7478
params.store.profiles[params.profileId] = oauthCredential;
75-
if (params.agentDir) {
79+
if (params.agentDir || process.env.OPENCLAW_STATE_DIR) {
7680
actual.saveAuthProfileStore(params.store, params.agentDir);
7781
}
7882
}
@@ -92,6 +96,7 @@ vi.mock("openclaw/plugin-sdk/agent-runtime", async (importOriginal) => {
9296

9397
afterEach(() => {
9498
vi.unstubAllEnvs();
99+
clearRuntimeAuthProfileStoreSnapshots();
95100
oauthMocks.refreshOpenAICodexToken.mockReset();
96101
providerRuntimeMocks.formatProviderAuthProfileApiKeyWithPlugin.mockReset();
97102
providerRuntimeMocks.refreshProviderOAuthCredentialWithPlugin.mockClear();
@@ -635,6 +640,132 @@ describe("bridgeCodexAppServerStartOptions", () => {
635640
}
636641
});
637642

643+
it("refreshes inherited main Codex OAuth without cloning it into the child store", async () => {
644+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
645+
const stateDir = path.join(root, "state");
646+
const childAgentDir = path.join(stateDir, "agents", "worker", "agent");
647+
const childAuthPath = path.join(childAgentDir, "auth-profiles.json");
648+
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
649+
vi.stubEnv("OPENCLAW_AGENT_DIR", "");
650+
oauthMocks.refreshOpenAICodexToken.mockResolvedValueOnce({
651+
access: "main-refreshed-access-token",
652+
refresh: "main-refreshed-refresh-token",
653+
expires: Date.now() + 60_000,
654+
accountId: "account-main-refreshed",
655+
});
656+
try {
657+
upsertAuthProfile({
658+
profileId: "openai-codex:work",
659+
credential: {
660+
type: "oauth",
661+
provider: "openai-codex",
662+
access: "main-current-access-token",
663+
refresh: "main-refresh-token",
664+
expires: Date.now() + 60_000,
665+
accountId: "account-main",
666+
email: "main-codex@example.test",
667+
},
668+
});
669+
670+
await expect(
671+
refreshCodexAppServerAuthTokens({
672+
agentDir: childAgentDir,
673+
authProfileId: "openai-codex:work",
674+
}),
675+
).resolves.toEqual({
676+
accessToken: "main-refreshed-access-token",
677+
chatgptAccountId: "account-main-refreshed",
678+
chatgptPlanType: null,
679+
});
680+
681+
expect(oauthMocks.refreshOpenAICodexToken).toHaveBeenCalledWith("main-refresh-token");
682+
await expect(fs.access(childAuthPath)).rejects.toMatchObject({ code: "ENOENT" });
683+
expect(loadAuthProfileStoreForSecretsRuntime().profiles["openai-codex:work"]).toMatchObject({
684+
type: "oauth",
685+
provider: "openai-codex",
686+
access: "main-refreshed-access-token",
687+
refresh: "main-refreshed-refresh-token",
688+
});
689+
} finally {
690+
await fs.rm(root, { recursive: true, force: true });
691+
}
692+
});
693+
694+
it("force-refreshes the owner credential instead of a stale child OAuth clone", async () => {
695+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
696+
const stateDir = path.join(root, "state");
697+
const childAgentDir = path.join(stateDir, "agents", "worker", "agent");
698+
const childAuthPath = path.join(childAgentDir, "auth-profiles.json");
699+
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
700+
vi.stubEnv("OPENCLAW_AGENT_DIR", "");
701+
oauthMocks.refreshOpenAICodexToken.mockResolvedValueOnce({
702+
access: "main-refreshed-access-token",
703+
refresh: "main-refreshed-refresh-token",
704+
expires: Date.now() + 60_000,
705+
accountId: "account-main-refreshed",
706+
});
707+
try {
708+
upsertAuthProfile({
709+
profileId: "openai-codex:work",
710+
credential: {
711+
type: "oauth",
712+
provider: "openai-codex",
713+
access: "main-current-access-token",
714+
refresh: "main-owner-refresh-token",
715+
expires: Date.now() + 60_000,
716+
accountId: "account-main",
717+
email: "main-codex@example.test",
718+
},
719+
});
720+
await fs.mkdir(childAgentDir, { recursive: true });
721+
await fs.writeFile(
722+
childAuthPath,
723+
JSON.stringify({
724+
version: 1,
725+
profiles: {
726+
"openai-codex:work": {
727+
type: "oauth",
728+
provider: "openai-codex",
729+
access: "child-stale-access-token",
730+
refresh: "child-stale-refresh-token",
731+
expires: Date.now() - 60_000,
732+
accountId: "account-main",
733+
email: "main-codex@example.test",
734+
},
735+
},
736+
}),
737+
);
738+
739+
await expect(
740+
refreshCodexAppServerAuthTokens({
741+
agentDir: childAgentDir,
742+
authProfileId: "openai-codex:work",
743+
}),
744+
).resolves.toEqual({
745+
accessToken: "main-refreshed-access-token",
746+
chatgptAccountId: "account-main-refreshed",
747+
chatgptPlanType: null,
748+
});
749+
750+
expect(oauthMocks.refreshOpenAICodexToken).toHaveBeenCalledWith("main-owner-refresh-token");
751+
expect(loadAuthProfileStoreForSecretsRuntime().profiles["openai-codex:work"]).toMatchObject({
752+
type: "oauth",
753+
provider: "openai-codex",
754+
access: "main-refreshed-access-token",
755+
refresh: "main-refreshed-refresh-token",
756+
});
757+
const child = JSON.parse(await fs.readFile(childAuthPath, "utf8")) as {
758+
profiles: Record<string, { access?: string; refresh?: string }>;
759+
};
760+
expect(child.profiles["openai-codex:work"]).toMatchObject({
761+
access: "child-stale-access-token",
762+
refresh: "child-stale-refresh-token",
763+
});
764+
} finally {
765+
await fs.rm(root, { recursive: true, force: true });
766+
}
767+
});
768+
638769
it("accepts a refreshed Codex OAuth credential when the stored provider is a legacy alias", async () => {
639770
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
640771
oauthMocks.refreshOpenAICodexToken.mockResolvedValueOnce({

extensions/codex/src/app-server/auth-bridge.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
loadAuthProfileStoreForSecretsRuntime,
44
resolveProviderIdForAuth,
55
resolveApiKeyForProfile,
6+
resolvePersistedAuthProfileOwnerAgentDir,
67
saveAuthProfileStore,
78
type AuthProfileCredential,
89
type OAuthCredential,
@@ -178,17 +179,26 @@ async function resolveOAuthCredentialForCodexAppServer(
178179
credential: OAuthCredential,
179180
params: { agentDir: string; forceRefresh: boolean },
180181
): Promise<OAuthCredential> {
181-
const store = ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false });
182+
const ownerAgentDir = resolvePersistedAuthProfileOwnerAgentDir({
183+
agentDir: params.agentDir,
184+
profileId,
185+
});
186+
const store = ensureAuthProfileStore(ownerAgentDir, { allowKeychainPrompt: false });
187+
const ownerCredential = store.profiles[profileId];
188+
const credentialForOwner =
189+
ownerCredential?.type === "oauth" && isCodexAppServerAuthProvider(ownerCredential.provider)
190+
? ownerCredential
191+
: credential;
182192
if (params.forceRefresh) {
183-
store.profiles[profileId] = { ...credential, expires: 0 };
184-
saveAuthProfileStore(store, params.agentDir);
193+
store.profiles[profileId] = { ...credentialForOwner, expires: 0 };
194+
saveAuthProfileStore(store, ownerAgentDir);
185195
}
186196
const resolved = await resolveApiKeyForProfile({
187197
store,
188198
profileId,
189-
agentDir: params.agentDir,
199+
agentDir: ownerAgentDir,
190200
});
191-
const refreshed = loadAuthProfileStoreForSecretsRuntime(params.agentDir).profiles[profileId];
201+
const refreshed = loadAuthProfileStoreForSecretsRuntime(ownerAgentDir).profiles[profileId];
192202
const storedCredential = store.profiles[profileId];
193203
const candidate =
194204
refreshed?.type === "oauth" && isCodexAppServerAuthProvider(refreshed.provider)

extensions/codex/src/app-server/auth-profile-runtime-contract.test.ts

Lines changed: 27 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -80,30 +80,35 @@ function turnStartResult(turnId = "turn-auth-contract") {
8080

8181
function createCodexAuthProfileHarness(params: { startMethod: "thread/start" | "thread/resume" }) {
8282
const seenAuthProfileIds: Array<string | undefined> = [];
83+
const seenAgentDirs: Array<string | undefined> = [];
8384
const requests: Array<{ method: string; params: unknown }> = [];
8485
let notify: (notification: unknown) => Promise<void> = async () => undefined;
85-
__testing.setCodexAppServerClientFactoryForTests(async (_startOptions, authProfileId) => {
86-
seenAuthProfileIds.push(authProfileId);
87-
return {
88-
request: vi.fn(async (method: string, requestParams?: unknown) => {
89-
requests.push({ method, params: requestParams });
90-
if (method === params.startMethod) {
91-
return threadStartResult();
92-
}
93-
if (method === "turn/start") {
94-
return turnStartResult();
95-
}
96-
throw new Error(`unexpected method: ${method}`);
97-
}),
98-
addNotificationHandler: (handler: (notification: unknown) => Promise<void>) => {
99-
notify = handler;
100-
return () => undefined;
101-
},
102-
addRequestHandler: () => () => undefined,
103-
} as never;
104-
});
86+
__testing.setCodexAppServerClientFactoryForTests(
87+
async (_startOptions, authProfileId, agentDir) => {
88+
seenAuthProfileIds.push(authProfileId);
89+
seenAgentDirs.push(agentDir);
90+
return {
91+
request: vi.fn(async (method: string, requestParams?: unknown) => {
92+
requests.push({ method, params: requestParams });
93+
if (method === params.startMethod) {
94+
return threadStartResult();
95+
}
96+
if (method === "turn/start") {
97+
return turnStartResult();
98+
}
99+
throw new Error(`unexpected method: ${method}`);
100+
}),
101+
addNotificationHandler: (handler: (notification: unknown) => Promise<void>) => {
102+
notify = handler;
103+
return () => undefined;
104+
},
105+
addRequestHandler: () => () => undefined,
106+
} as never;
107+
},
108+
);
105109
return {
106110
seenAuthProfileIds,
111+
seenAgentDirs,
107112
async waitForMethod(method: string) {
108113
await vi.waitFor(() => expect(requests.some((entry) => entry.method === method)).toBe(true), {
109114
interval: 1,
@@ -140,6 +145,7 @@ describe("Auth profile runtime contract - Codex app-server adapter", () => {
140145
const sessionFile = path.join(tmpDir, "session.jsonl");
141146
const params = createParams(sessionFile, tmpDir);
142147
params.authProfileId = AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId;
148+
params.agentDir = tmpDir;
143149

144150
const run = runCodexAppServerAttempt(params);
145151
await vi.waitFor(
@@ -149,6 +155,7 @@ describe("Auth profile runtime contract - Codex app-server adapter", () => {
149155
]),
150156
{ interval: 1 },
151157
);
158+
expect(harness.seenAgentDirs).toEqual([tmpDir]);
152159
await harness.waitForMethod("turn/start");
153160
await harness.completeTurn();
154161
await run;

0 commit comments

Comments
 (0)