Skip to content

Commit cec4042

Browse files
joshavantsteipete
authored andcommitted
Auth labels: handle token refs and share Pi credential conversion
1 parent e1301c3 commit cec4042

7 files changed

Lines changed: 226 additions & 172 deletions

File tree

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
const ensureAuthProfileStoreMock = vi.hoisted(() => vi.fn());
4+
const resolveAuthProfileOrderMock = vi.hoisted(() => vi.fn());
5+
const resolveAuthProfileDisplayLabelMock = vi.hoisted(() => vi.fn());
6+
7+
vi.mock("./auth-profiles.js", () => ({
8+
ensureAuthProfileStore: (...args: unknown[]) => ensureAuthProfileStoreMock(...args),
9+
resolveAuthProfileOrder: (...args: unknown[]) => resolveAuthProfileOrderMock(...args),
10+
resolveAuthProfileDisplayLabel: (...args: unknown[]) =>
11+
resolveAuthProfileDisplayLabelMock(...args),
12+
}));
13+
14+
vi.mock("./model-auth.js", () => ({
15+
getCustomProviderApiKey: () => undefined,
16+
resolveEnvApiKey: () => null,
17+
}));
18+
19+
const { resolveModelAuthLabel } = await import("./model-auth-label.js");
20+
21+
describe("resolveModelAuthLabel", () => {
22+
beforeEach(() => {
23+
ensureAuthProfileStoreMock.mockReset();
24+
resolveAuthProfileOrderMock.mockReset();
25+
resolveAuthProfileDisplayLabelMock.mockReset();
26+
});
27+
28+
it("does not throw when token profile only has tokenRef", () => {
29+
ensureAuthProfileStoreMock.mockReturnValue({
30+
version: 1,
31+
profiles: {
32+
"github-copilot:default": {
33+
type: "token",
34+
provider: "github-copilot",
35+
tokenRef: { source: "env", id: "GITHUB_TOKEN" },
36+
},
37+
},
38+
} as never);
39+
resolveAuthProfileOrderMock.mockReturnValue(["github-copilot:default"]);
40+
resolveAuthProfileDisplayLabelMock.mockReturnValue("github-copilot:default");
41+
42+
const label = resolveModelAuthLabel({
43+
provider: "github-copilot",
44+
cfg: {},
45+
sessionEntry: { authProfileOverride: "github-copilot:default" } as never,
46+
});
47+
48+
expect(label).toContain("token ref(env:GITHUB_TOKEN)");
49+
});
50+
});

src/agents/model-auth-label.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,20 @@ function formatApiKeySnippet(apiKey: string): string {
1919
return `${head}${tail}`;
2020
}
2121

22+
function formatCredentialSnippet(params: {
23+
value: string | undefined;
24+
ref: { source: string; id: string } | undefined;
25+
}): string {
26+
const value = typeof params.value === "string" ? params.value.trim() : "";
27+
if (value) {
28+
return formatApiKeySnippet(value);
29+
}
30+
if (params.ref) {
31+
return `ref(${params.ref.source}:${params.ref.id})`;
32+
}
33+
return "unknown";
34+
}
35+
2236
export function resolveModelAuthLabel(params: {
2337
provider?: string;
2438
cfg?: OpenClawConfig;
@@ -57,9 +71,13 @@ export function resolveModelAuthLabel(params: {
5771
return `oauth${label ? ` (${label})` : ""}`;
5872
}
5973
if (profile.type === "token") {
60-
return `token ${formatApiKeySnippet(profile.token)}${label ? ` (${label})` : ""}`;
74+
return `token ${formatCredentialSnippet({ value: profile.token, ref: profile.tokenRef })}${
75+
label ? ` (${label})` : ""
76+
}`;
6177
}
62-
return `api-key ${formatApiKeySnippet(profile.key ?? "")}${label ? ` (${label})` : ""}`;
78+
return `api-key ${formatCredentialSnippet({ value: profile.key, ref: profile.keyRef })}${
79+
label ? ` (${label})` : ""
80+
}`;
6381
}
6482

6583
const envKey = resolveEnvApiKey(providerKey);

src/agents/pi-auth-credentials.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import type { AuthProfileCredential, AuthProfileStore } from "./auth-profiles.js";
2+
import { normalizeProviderId } from "./model-selection.js";
3+
4+
export type PiApiKeyCredential = { type: "api_key"; key: string };
5+
export type PiOAuthCredential = {
6+
type: "oauth";
7+
access: string;
8+
refresh: string;
9+
expires: number;
10+
};
11+
12+
export type PiCredential = PiApiKeyCredential | PiOAuthCredential;
13+
export type PiCredentialMap = Record<string, PiCredential>;
14+
15+
export function convertAuthProfileCredentialToPi(cred: AuthProfileCredential): PiCredential | null {
16+
if (cred.type === "api_key") {
17+
const key = typeof cred.key === "string" ? cred.key.trim() : "";
18+
if (!key) {
19+
return null;
20+
}
21+
return { type: "api_key", key };
22+
}
23+
24+
if (cred.type === "token") {
25+
const token = typeof cred.token === "string" ? cred.token.trim() : "";
26+
if (!token) {
27+
return null;
28+
}
29+
if (
30+
typeof cred.expires === "number" &&
31+
Number.isFinite(cred.expires) &&
32+
Date.now() >= cred.expires
33+
) {
34+
return null;
35+
}
36+
return { type: "api_key", key: token };
37+
}
38+
39+
if (cred.type === "oauth") {
40+
const access = typeof cred.access === "string" ? cred.access.trim() : "";
41+
const refresh = typeof cred.refresh === "string" ? cred.refresh.trim() : "";
42+
if (!access || !refresh || !Number.isFinite(cred.expires) || cred.expires <= 0) {
43+
return null;
44+
}
45+
return {
46+
type: "oauth",
47+
access,
48+
refresh,
49+
expires: cred.expires,
50+
};
51+
}
52+
53+
return null;
54+
}
55+
56+
export function resolvePiCredentialMapFromStore(store: AuthProfileStore): PiCredentialMap {
57+
const credentials: PiCredentialMap = {};
58+
for (const credential of Object.values(store.profiles)) {
59+
const provider = normalizeProviderId(String(credential.provider ?? "")).trim();
60+
if (!provider || credentials[provider]) {
61+
continue;
62+
}
63+
const converted = convertAuthProfileCredentialToPi(credential);
64+
if (converted) {
65+
credentials[provider] = converted;
66+
}
67+
}
68+
return credentials;
69+
}
70+
71+
export function piCredentialsEqual(a: PiCredential | undefined, b: PiCredential): boolean {
72+
if (!a || typeof a !== "object") {
73+
return false;
74+
}
75+
if (a.type !== b.type) {
76+
return false;
77+
}
78+
79+
if (a.type === "api_key" && b.type === "api_key") {
80+
return a.key === b.key;
81+
}
82+
83+
if (a.type === "oauth" && b.type === "oauth") {
84+
return a.access === b.access && a.refresh === b.refresh && a.expires === b.expires;
85+
}
86+
87+
return false;
88+
}

src/agents/pi-auth-json.ts

Lines changed: 10 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,17 @@
11
import fs from "node:fs/promises";
22
import path from "node:path";
33
import { ensureAuthProfileStore } from "./auth-profiles.js";
4-
import type { AuthProfileCredential } from "./auth-profiles/types.js";
5-
import { normalizeProviderId } from "./model-selection.js";
4+
import {
5+
piCredentialsEqual,
6+
resolvePiCredentialMapFromStore,
7+
type PiCredential,
8+
} from "./pi-auth-credentials.js";
69

710
/**
811
* @deprecated Legacy bridge for older flows that still expect `agentDir/auth.json`.
912
* Runtime auth resolution uses auth-profiles directly and should not depend on this module.
1013
*/
11-
type AuthJsonCredential =
12-
| {
13-
type: "api_key";
14-
key: string;
15-
}
16-
| {
17-
type: "oauth";
18-
access: string;
19-
refresh: string;
20-
expires: number;
21-
[key: string]: unknown;
22-
};
14+
type AuthJsonCredential = PiCredential;
2315

2416
type AuthJsonShape = Record<string, AuthJsonCredential>;
2517

@@ -36,75 +28,6 @@ async function readAuthJson(filePath: string): Promise<AuthJsonShape> {
3628
}
3729
}
3830

39-
/**
40-
* Convert an OpenClaw auth-profiles credential to pi-coding-agent auth.json format.
41-
* Returns null if the credential cannot be converted.
42-
*/
43-
function convertCredential(cred: AuthProfileCredential): AuthJsonCredential | null {
44-
if (cred.type === "api_key") {
45-
const key = typeof cred.key === "string" ? cred.key.trim() : "";
46-
if (!key) {
47-
return null;
48-
}
49-
return { type: "api_key", key };
50-
}
51-
52-
if (cred.type === "token") {
53-
// pi-coding-agent treats static tokens as api_key type
54-
const token = typeof cred.token === "string" ? cred.token.trim() : "";
55-
if (!token) {
56-
return null;
57-
}
58-
const expires =
59-
typeof (cred as { expires?: unknown }).expires === "number"
60-
? (cred as { expires: number }).expires
61-
: Number.NaN;
62-
if (Number.isFinite(expires) && expires > 0 && Date.now() >= expires) {
63-
return null;
64-
}
65-
return { type: "api_key", key: token };
66-
}
67-
68-
if (cred.type === "oauth") {
69-
const accessRaw = (cred as { access?: unknown }).access;
70-
const refreshRaw = (cred as { refresh?: unknown }).refresh;
71-
const expiresRaw = (cred as { expires?: unknown }).expires;
72-
73-
const access = typeof accessRaw === "string" ? accessRaw.trim() : "";
74-
const refresh = typeof refreshRaw === "string" ? refreshRaw.trim() : "";
75-
const expires = typeof expiresRaw === "number" ? expiresRaw : Number.NaN;
76-
77-
if (!access || !refresh || !Number.isFinite(expires) || expires <= 0) {
78-
return null;
79-
}
80-
return { type: "oauth", access, refresh, expires };
81-
}
82-
83-
return null;
84-
}
85-
86-
/**
87-
* Check if two auth.json credentials are equivalent.
88-
*/
89-
function credentialsEqual(a: AuthJsonCredential | undefined, b: AuthJsonCredential): boolean {
90-
if (!a || typeof a !== "object") {
91-
return false;
92-
}
93-
if (a.type !== b.type) {
94-
return false;
95-
}
96-
97-
if (a.type === "api_key" && b.type === "api_key") {
98-
return a.key === b.key;
99-
}
100-
101-
if (a.type === "oauth" && b.type === "oauth") {
102-
return a.access === b.access && a.refresh === b.refresh && a.expires === b.expires;
103-
}
104-
105-
return false;
106-
}
107-
10831
/**
10932
* pi-coding-agent's ModelRegistry/AuthStorage expects credentials in auth.json.
11033
*
@@ -123,31 +46,16 @@ export async function ensurePiAuthJsonFromAuthProfiles(agentDir: string): Promis
12346
}> {
12447
const store = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false });
12548
const authPath = path.join(agentDir, "auth.json");
126-
127-
// Group profiles by provider, taking the first valid profile for each
128-
const providerCredentials = new Map<string, AuthJsonCredential>();
129-
130-
for (const [, cred] of Object.entries(store.profiles)) {
131-
const provider = normalizeProviderId(String(cred.provider ?? "")).trim();
132-
if (!provider || providerCredentials.has(provider)) {
133-
continue;
134-
}
135-
136-
const converted = convertCredential(cred);
137-
if (converted) {
138-
providerCredentials.set(provider, converted);
139-
}
140-
}
141-
142-
if (providerCredentials.size === 0) {
49+
const providerCredentials = resolvePiCredentialMapFromStore(store);
50+
if (Object.keys(providerCredentials).length === 0) {
14351
return { wrote: false, authPath };
14452
}
14553

14654
const existing = await readAuthJson(authPath);
14755
let changed = false;
14856

149-
for (const [provider, cred] of providerCredentials) {
150-
if (!credentialsEqual(existing[provider], cred)) {
57+
for (const [provider, cred] of Object.entries(providerCredentials)) {
58+
if (!piCredentialsEqual(existing[provider], cred)) {
15159
existing[provider] = cred;
15260
changed = true;
15361
}

0 commit comments

Comments
 (0)