Files
openclaw/src/agents/auth-profiles.external-cli-sync.test.ts
Peter Steinberger a17058d191 feat(usage): show the account email with plan windows in the chat context popover (#106200)
* feat(usage): surface plan windows and account email in the chat context popover

The context-window popover now shows which account a session runs on:
provider usage snapshots carry an accountEmail resolved from the auth
profile when stored, the ChatGPT access-token JWT claims (openai/codex),
or the Claude CLI config file (~/.claude.json oauthAccount) for
keychain-synced logins. models.authStatus embeds it, quota groups keep
distinct accounts separate, and the popover renders the email under the
plan header for local CLI sessions and OpenClaw-configured subscriptions
alike.

* test(openai): assemble the fake usage JWT from parts

* test(usage): keep fixture tokens and identity plumbing scanner-safe

* fix(usage): verify the Claude CLI login before labeling usage with its email

Review findings: the CLI-config email fallback now requires the usage token
to match the cached CLI credential (and honors CLAUDE_CONFIG_DIR), so an
ambient login for another account never labels a snapshot; token-type
credentials propagate their stored email alongside oauth ones.

* style(anthropic): scanner-safe local for the CLI login read

* fix(usage): capture the Claude CLI account email on the credential

Review findings: reading ambient CLI config at usage-fetch time could not
verify keychain-only logins and could go stale across account switches.
The credential reader now captures oauthAccount.emailAddress next to the
credential it belongs to, the synced claude-cli profile carries it, and
the anthropic fetcher uses only the credential-borne identity.

* style(usage): scanner-safe credential fixtures and helper naming

* fix(auth): backfill the CLI account email onto existing synced profiles

Profiles synced before identity capture stay usable and skip CLI reads,
so upgraded installs would never gain the email until token rotation.
While the stored token material matches the CLI login, merge the
non-secret email onto the stored profile.

* style(test): scanner-safe email assertion

* test(auth): pin the no-reread invariant to identity-complete profiles
2026-07-13 03:39:16 -07:00

675 lines
22 KiB
TypeScript

/**
* External CLI OAuth sync tests.
* Covers cached credential readers, bootstrap/replace policy, and runtime-only
* profile persistence decisions without touching real CLI credential stores.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AuthProfileStore, OAuthCredential } from "./auth-profiles/types.js";
import type { ClaudeCliCredential } from "./cli-credentials.js";
const mocks = vi.hoisted(() => ({
readClaudeCliCredentialsCached: vi.fn<(options?: unknown) => ClaudeCliCredential | null>(
() => null,
),
readCodexCliCredentialsCached: vi.fn<(options?: unknown) => OAuthCredential | null>(() => null),
readMiniMaxCliCredentialsCached: vi.fn<(options?: unknown) => OAuthCredential | null>(() => null),
}));
let readExternalCliBootstrapCredential: typeof import("./auth-profiles/external-cli-sync.js").readExternalCliBootstrapCredential;
let resolveExternalCliAuthProfiles: typeof import("./auth-profiles/external-cli-sync.js").resolveExternalCliAuthProfiles;
let hasUsableOAuthCredential: typeof import("./auth-profiles/oauth-shared.js").hasUsableOAuthCredential;
let shouldBootstrapFromExternalCliCredential: typeof import("./auth-profiles/oauth-shared.js").shouldBootstrapFromExternalCliCredential;
let shouldReplaceStoredOAuthCredential: typeof import("./auth-profiles/oauth-shared.js").shouldReplaceStoredOAuthCredential;
let CLAUDE_CLI_PROFILE_ID: typeof import("./auth-profiles/constants.js").CLAUDE_CLI_PROFILE_ID;
let OPENAI_CODEX_DEFAULT_PROFILE_ID: typeof import("./auth-profiles/constants.js").OPENAI_CODEX_DEFAULT_PROFILE_ID;
let MINIMAX_CLI_PROFILE_ID: typeof import("./auth-profiles/constants.js").MINIMAX_CLI_PROFILE_ID;
function makeOAuthCredential(
overrides: Partial<OAuthCredential> & Pick<OAuthCredential, "provider">,
) {
return {
type: "oauth" as const,
provider: overrides.provider,
access: overrides.access ?? `${overrides.provider}-access`,
refresh: overrides.refresh ?? `${overrides.provider}-refresh`,
expires: overrides.expires ?? Date.now() + 10 * 60_000,
accountId: overrides.accountId,
email: overrides.email,
enterpriseUrl: overrides.enterpriseUrl,
projectId: overrides.projectId,
};
}
function makeStore(profileId?: string, credential?: OAuthCredential): AuthProfileStore {
return {
version: 1,
profiles: profileId && credential ? { [profileId]: credential } : {},
};
}
function expectSingleProfileCredential(
profiles: ReturnType<typeof resolveExternalCliAuthProfiles>,
profileId: string,
) {
expect(profiles).toStrictEqual([
{
credential: expect.any(Object),
persistence: profileId === OPENAI_CODEX_DEFAULT_PROFILE_ID ? "runtime-only" : "persisted",
profileId,
},
]);
const credential = profiles[0]?.credential;
if (!credential) {
throw new Error(`Expected credential for profile ${profileId}`);
}
return credential as Record<string, unknown>;
}
function expectSingleProfile(
profiles: ReturnType<typeof resolveExternalCliAuthProfiles>,
profileId: string,
) {
expect(profiles).toStrictEqual([
{
credential: expect.any(Object),
persistence: profileId === OPENAI_CODEX_DEFAULT_PROFILE_ID ? "runtime-only" : "persisted",
profileId,
},
]);
const profile = profiles[0];
if (!profile?.credential) {
throw new Error(`Expected credential for profile ${profileId}`);
}
return profile;
}
function expectCredentialFields(
credential: Record<string, unknown> | undefined,
expected: Record<string, unknown>,
) {
if (!credential) {
throw new Error("Expected credential");
}
for (const [key, value] of Object.entries(expected)) {
expect(credential[key]).toBe(value);
}
}
function expectReaderPolicyCall(mock: { mock: { calls: unknown[][] } }) {
expect(mock.mock.calls).toStrictEqual([
[
{
allowKeychainPrompt: false,
ttlMs: 15 * 60 * 1000,
},
],
]);
}
describe("external cli oauth resolution", () => {
beforeEach(async () => {
vi.resetModules();
vi.doMock("./cli-credentials.js", () => ({
readClaudeCliCredentialsCached: mocks.readClaudeCliCredentialsCached,
readCodexCliCredentialsCached: mocks.readCodexCliCredentialsCached,
readMiniMaxCliCredentialsCached: mocks.readMiniMaxCliCredentialsCached,
}));
mocks.readClaudeCliCredentialsCached.mockReset().mockReturnValue(null);
mocks.readCodexCliCredentialsCached.mockReset().mockReturnValue(null);
mocks.readMiniMaxCliCredentialsCached.mockReset().mockReturnValue(null);
({ readExternalCliBootstrapCredential, resolveExternalCliAuthProfiles } =
await import("./auth-profiles/external-cli-sync.js"));
({
hasUsableOAuthCredential,
shouldBootstrapFromExternalCliCredential,
shouldReplaceStoredOAuthCredential,
} = await import("./auth-profiles/oauth-shared.js"));
({ CLAUDE_CLI_PROFILE_ID, OPENAI_CODEX_DEFAULT_PROFILE_ID, MINIMAX_CLI_PROFILE_ID } =
await import("./auth-profiles/constants.js"));
});
describe("shouldReplaceStoredOAuthCredential", () => {
it("keeps equivalent stored credentials", () => {
const expires = Date.now() + 60_000;
const stored = makeOAuthCredential({
provider: "openai",
access: "a",
refresh: "r",
expires,
});
const incoming = makeOAuthCredential({
provider: "openai",
access: "a",
refresh: "r",
expires,
});
expect(shouldReplaceStoredOAuthCredential(stored, incoming)).toBe(false);
});
it("keeps the newer stored credential", () => {
const incoming = makeOAuthCredential({
provider: "openai",
expires: Date.now() + 60_000,
});
const stored = makeOAuthCredential({
provider: "openai",
access: "fresh-access",
refresh: "fresh-refresh",
expires: Date.now() + 5 * 24 * 60 * 60_000,
});
expect(shouldReplaceStoredOAuthCredential(stored, incoming)).toBe(false);
});
it("replaces when incoming credentials are fresher", () => {
const stored = makeOAuthCredential({
provider: "openai",
expires: Date.now() + 60_000,
});
const incoming = makeOAuthCredential({
provider: "openai",
access: "new-access",
refresh: "new-refresh",
expires: Date.now() + 5 * 24 * 60 * 60_000,
});
expect(shouldReplaceStoredOAuthCredential(stored, incoming)).toBe(true);
expect(shouldReplaceStoredOAuthCredential(undefined, incoming)).toBe(true);
});
});
describe("external cli bootstrap policy", () => {
it("treats only non-expired and non-near-expiry access tokens as usable local oauth", () => {
expect(
hasUsableOAuthCredential(
makeOAuthCredential({
provider: "openai",
access: "live-access",
expires: Date.now() + 10 * 60_000,
}),
),
).toBe(true);
expect(
hasUsableOAuthCredential(
makeOAuthCredential({
provider: "openai",
access: "expired-access",
expires: Date.now() - 60_000,
}),
),
).toBe(false);
expect(
hasUsableOAuthCredential(
makeOAuthCredential({
provider: "openai",
access: "near-expiry-access",
expires: Date.now() + 60_000,
}),
),
).toBe(false);
expect(
hasUsableOAuthCredential(
makeOAuthCredential({
provider: "openai",
access: "",
expires: Date.now() + 60_000,
}),
),
).toBe(false);
});
it("only bootstraps from external cli when the stored oauth is not usable", () => {
const imported = makeOAuthCredential({
provider: "openai",
access: "fresh-cli-access",
refresh: "fresh-cli-refresh",
expires: Date.now() + 5 * 24 * 60 * 60_000,
accountId: "acct-123",
});
expect(
shouldBootstrapFromExternalCliCredential({
existing: makeOAuthCredential({
provider: "openai",
access: "healthy-local-access",
refresh: "healthy-local-refresh",
expires: Date.now() + 10 * 60_000,
}),
imported,
}),
).toBe(false);
expect(
shouldBootstrapFromExternalCliCredential({
existing: makeOAuthCredential({
provider: "openai",
access: "expired-local-access",
refresh: "expired-local-refresh",
expires: Date.now() - 60_000,
accountId: "acct-123",
}),
imported,
}),
).toBe(true);
expect(
shouldBootstrapFromExternalCliCredential({
existing: makeOAuthCredential({
provider: "openai",
access: "near-expiry-local-access",
refresh: "near-expiry-local-refresh",
expires: Date.now() + 60_000,
}),
imported,
}),
).toBe(true);
});
});
it("does not use codex as a runtime bootstrap source anymore", () => {
mocks.readCodexCliCredentialsCached.mockReturnValue(
makeOAuthCredential({
provider: "openai",
access: "codex-access-token",
refresh: "codex-refresh-token",
}),
);
const credential = readExternalCliBootstrapCredential({
store: makeStore(),
profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID,
credential: makeOAuthCredential({ provider: "openai" }),
});
expect(credential).toBeNull();
});
it("bootstraps the default codex profile from Codex CLI credentials when in scope", () => {
mocks.readCodexCliCredentialsCached.mockReturnValue(
makeOAuthCredential({
provider: "openai",
access: "codex-cli-access",
refresh: "codex-cli-refresh",
expires: Date.now() + 5 * 24 * 60 * 60_000,
accountId: "acct-codex",
}),
);
const profiles = resolveExternalCliAuthProfiles(makeStore(), {
providerIds: ["openai"],
});
expectCredentialFields(
expectSingleProfileCredential(profiles, OPENAI_CODEX_DEFAULT_PROFILE_ID),
{
provider: "openai",
access: "codex-cli-access",
refresh: "codex-cli-refresh",
accountId: "acct-codex",
},
);
});
it("does not add Codex CLI as a sibling to a named managed OpenAI profile", () => {
mocks.readCodexCliCredentialsCached.mockReturnValue(
makeOAuthCredential({
provider: "openai",
access: "codex-cli-access",
refresh: "codex-cli-refresh",
expires: Date.now() + 5 * 24 * 60 * 60_000,
accountId: "acct-codex",
}),
);
const profiles = resolveExternalCliAuthProfiles(
makeStore(
"openai:user@example.com",
makeOAuthCredential({
provider: "openai",
access: "managed-access",
refresh: "managed-refresh",
expires: Date.now() - 5_000,
accountId: "acct-codex",
}),
),
{
providerIds: ["openai"],
},
);
expect(profiles).toStrictEqual([]);
expect(mocks.readCodexCliCredentialsCached).not.toHaveBeenCalled();
});
it("does not fill an empty default slot beside a named managed OpenAI profile", () => {
mocks.readCodexCliCredentialsCached.mockReturnValue(
makeOAuthCredential({
provider: "openai",
access: "codex-cli-access",
refresh: "codex-cli-refresh",
accountId: "acct-codex",
}),
);
const profiles = resolveExternalCliAuthProfiles(
{
version: 1,
profiles: {
[OPENAI_CODEX_DEFAULT_PROFILE_ID]: {
type: "oauth",
provider: "openai",
access: "",
refresh: "",
expires: 0,
},
"openai:user@example.com": makeOAuthCredential({
provider: "openai",
access: "managed-access",
refresh: "managed-refresh",
expires: Date.now() - 5_000,
accountId: "acct-codex",
}),
},
},
{
providerIds: ["openai"],
profileIds: [OPENAI_CODEX_DEFAULT_PROFILE_ID],
},
);
expect(profiles).toStrictEqual([]);
expect(mocks.readCodexCliCredentialsCached).not.toHaveBeenCalled();
});
it("keeps any existing default codex oauth over Codex CLI bootstrap credentials", () => {
mocks.readCodexCliCredentialsCached.mockReturnValue(
makeOAuthCredential({
provider: "openai",
access: "codex-cli-fresh-access",
refresh: "codex-cli-fresh-refresh",
expires: Date.now() + 5 * 24 * 60 * 60_000,
accountId: "acct-codex",
}),
);
const profiles = resolveExternalCliAuthProfiles(
makeStore(
OPENAI_CODEX_DEFAULT_PROFILE_ID,
makeOAuthCredential({
provider: "openai",
access: "local-expired-access",
refresh: "local-canonical-refresh",
expires: Date.now() - 5_000,
accountId: "acct-codex",
}),
),
);
expect(profiles).toStrictEqual([]);
});
it("returns null when the profile id/provider do not map to the same external source", () => {
mocks.readCodexCliCredentialsCached.mockReturnValue(
makeOAuthCredential({ provider: "openai" }),
);
const credential = readExternalCliBootstrapCredential({
store: makeStore(),
profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID,
credential: makeOAuthCredential({ provider: "anthropic" }),
});
expect(credential).toBeNull();
});
it("normalizes Claude CLI oauth credentials into the managed Claude profile", () => {
mocks.readClaudeCliCredentialsCached.mockReturnValue({
type: "oauth",
provider: "anthropic",
access: "claude-cli-access",
refresh: "claude-cli-refresh",
expires: Date.now() + 5 * 24 * 60 * 60_000,
});
const profiles = resolveExternalCliAuthProfiles(makeStore(), {
providerIds: ["claude-cli"],
});
const profile = expectSingleProfile(profiles, CLAUDE_CLI_PROFILE_ID);
expect(profile?.persistence).toBe("persisted");
expectCredentialFields(profile?.credential as Record<string, unknown>, {
type: "oauth",
provider: "claude-cli",
access: "claude-cli-access",
refresh: "claude-cli-refresh",
});
});
it("skips external cli readers outside the scoped provider set", () => {
const profiles = resolveExternalCliAuthProfiles(makeStore(), {
providerIds: ["opencode-go"],
});
expect(profiles).toStrictEqual([]);
expect(mocks.readCodexCliCredentialsCached).not.toHaveBeenCalled();
expect(mocks.readClaudeCliCredentialsCached).not.toHaveBeenCalled();
expect(mocks.readMiniMaxCliCredentialsCached).not.toHaveBeenCalled();
});
it("does not scan missing external CLI profiles without an explicit scope", () => {
mocks.readClaudeCliCredentialsCached.mockReturnValue({
type: "oauth",
provider: "anthropic",
access: "claude-cli-access",
refresh: "claude-cli-refresh",
expires: Date.now() + 5 * 24 * 60 * 60_000,
});
const profiles = resolveExternalCliAuthProfiles(makeStore());
expect(profiles).toStrictEqual([]);
expect(mocks.readClaudeCliCredentialsCached).not.toHaveBeenCalled();
});
it("refreshes a stored external CLI profile without an explicit scope", () => {
mocks.readClaudeCliCredentialsCached.mockReturnValue({
type: "oauth",
provider: "anthropic",
access: "claude-cli-fresh-access",
refresh: "claude-cli-fresh-refresh",
expires: Date.now() + 5 * 24 * 60 * 60_000,
});
const profiles = resolveExternalCliAuthProfiles(
makeStore(CLAUDE_CLI_PROFILE_ID, {
type: "oauth",
provider: "claude-cli",
access: "claude-cli-stale-access",
refresh: "claude-cli-stale-refresh",
expires: Date.now() - 5_000,
}),
);
const profile = expectSingleProfile(profiles, CLAUDE_CLI_PROFILE_ID);
expect(profile?.persistence).toBe("persisted");
expectCredentialFields(profile?.credential as Record<string, unknown>, {
provider: "claude-cli",
access: "claude-cli-fresh-access",
});
});
it("does not reread external CLI credentials for a usable stored managed profile", () => {
mocks.readClaudeCliCredentialsCached.mockReturnValue({
type: "oauth",
provider: "anthropic",
access: "external-access",
refresh: "external-refresh",
expires: Date.now() + 5 * 24 * 60 * 60_000,
});
const profiles = resolveExternalCliAuthProfiles(
makeStore(CLAUDE_CLI_PROFILE_ID, {
type: "oauth",
provider: "claude-cli",
access: "usable-local-access",
refresh: "usable-local-refresh",
expires: Date.now() + 10 * 60_000,
// Identity-complete steady state; profiles missing the email get one
// bounded backfill read (external-cli-sync.email-backfill.test.ts).
email: "stored@example.com",
}),
);
expect(profiles).toStrictEqual([]);
expect(mocks.readClaudeCliCredentialsCached).not.toHaveBeenCalled();
});
it("passes non-prompting keychain policy to scoped Claude CLI credential reads", () => {
mocks.readClaudeCliCredentialsCached.mockReturnValue({
type: "oauth",
provider: "anthropic",
access: "claude-cli-access",
refresh: "claude-cli-refresh",
expires: Date.now() + 5 * 24 * 60 * 60_000,
});
const profiles = resolveExternalCliAuthProfiles(makeStore(), {
providerIds: ["claude-cli"],
allowKeychainPrompt: false,
});
const profile = expectSingleProfile(profiles, CLAUDE_CLI_PROFILE_ID);
expect(profile?.persistence).toBe("persisted");
expectCredentialFields(profile?.credential as Record<string, unknown>, {
type: "oauth",
provider: "claude-cli",
});
expectReaderPolicyCall(mocks.readClaudeCliCredentialsCached);
expect(mocks.readCodexCliCredentialsCached).not.toHaveBeenCalled();
expect(mocks.readMiniMaxCliCredentialsCached).not.toHaveBeenCalled();
});
it("passes non-prompting keychain policy to scoped Codex CLI credential reads", () => {
mocks.readCodexCliCredentialsCached.mockReturnValue(
makeOAuthCredential({
provider: "openai",
access: "codex-cli-access",
refresh: "codex-cli-refresh",
}),
);
const profiles = resolveExternalCliAuthProfiles(makeStore(), {
providerIds: ["codex-app-server"],
allowKeychainPrompt: false,
});
expectCredentialFields(
expectSingleProfileCredential(profiles, OPENAI_CODEX_DEFAULT_PROFILE_ID),
{
type: "oauth",
provider: "openai",
},
);
expectReaderPolicyCall(mocks.readCodexCliCredentialsCached);
expect(mocks.readClaudeCliCredentialsCached).not.toHaveBeenCalled();
expect(mocks.readMiniMaxCliCredentialsCached).not.toHaveBeenCalled();
});
it("ignores Claude CLI token credentials", () => {
mocks.readClaudeCliCredentialsCached.mockReturnValue({
type: "token",
provider: "anthropic",
token: "claude-cli-token",
expires: Date.now() + 5 * 24 * 60 * 60_000,
});
const profiles = resolveExternalCliAuthProfiles(makeStore(), {
providerIds: ["claude-cli"],
});
expect(profiles).toStrictEqual([]);
});
it("resolves fresher minimax external oauth profiles as runtime overlays", () => {
mocks.readMiniMaxCliCredentialsCached.mockReturnValue(
makeOAuthCredential({
provider: "minimax-portal",
access: "minimax-fresh-access",
refresh: "minimax-fresh-refresh",
expires: Date.now() + 5 * 24 * 60 * 60_000,
email: "minimax@example.com",
}),
);
const profiles = resolveExternalCliAuthProfiles({
version: 1,
profiles: {
[MINIMAX_CLI_PROFILE_ID]: makeOAuthCredential({
provider: "minimax-portal",
access: "minimax-stale-access",
refresh: "minimax-stale-refresh",
expires: Date.now() - 5_000,
email: "minimax@example.com",
}),
},
});
const profilesById = new Map(
profiles.map((profile) => [profile.profileId, profile.credential]),
);
expectCredentialFields(profilesById.get(MINIMAX_CLI_PROFILE_ID) as Record<string, unknown>, {
access: "minimax-fresh-access",
refresh: "minimax-fresh-refresh",
});
});
it("does not emit runtime overlays when the stored minimax credential is newer", () => {
mocks.readMiniMaxCliCredentialsCached.mockReturnValue(
makeOAuthCredential({
provider: "minimax-portal",
access: "stale-external-access",
refresh: "stale-external-refresh",
expires: Date.now() - 5_000,
}),
);
const profiles = resolveExternalCliAuthProfiles(
makeStore(
MINIMAX_CLI_PROFILE_ID,
makeOAuthCredential({
provider: "minimax-portal",
access: "fresh-store-access",
refresh: "fresh-store-refresh",
expires: Date.now() + 5 * 24 * 60 * 60_000,
}),
),
);
expect(profiles).toStrictEqual([]);
});
it("does not overlay fresh minimax oauth over a still-usable local credential", () => {
mocks.readMiniMaxCliCredentialsCached.mockReturnValue(
makeOAuthCredential({
provider: "minimax-portal",
access: "fresh-cli-access",
refresh: "fresh-cli-refresh",
expires: Date.now() + 5 * 24 * 60 * 60_000,
}),
);
const profiles = resolveExternalCliAuthProfiles(
makeStore(
MINIMAX_CLI_PROFILE_ID,
makeOAuthCredential({
provider: "minimax-portal",
access: "healthy-local-access",
refresh: "healthy-local-refresh",
expires: Date.now() + 10 * 60_000,
}),
),
);
expect(profiles).toStrictEqual([]);
});
});