Skip to content

Commit b20e61e

Browse files
committed
fix(coding-agent): address PR code-yeongyu#194 broker review P1/P2 findings
CAS-guard OAuth disable against updatedAt snapshots, preserve OAuth extras (projectId) through vault round-trips, resolve pooled OAuth via provider getRequestAuth/getApiKey, count pools in hasAuth, redact disabled.cause on upsert/import, prune expired leases, restore models.json oauth:"radius" registration, and normalize broker login provider aliases.
1 parent 16e0011 commit b20e61e

7 files changed

Lines changed: 387 additions & 33 deletions

File tree

packages/coding-agent/src/cli/auth-broker-cli.ts

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { chmod, lstat, mkdir, open, readFile, rename, stat, writeFile } from "no
33
import { dirname, join, resolve } from "node:path";
44
import { createInterface } from "node:readline";
55
import type { OAuthCredentials, OAuthLoginCallbacks } from "@earendil-works/pi-ai/compat";
6-
import { getOAuthProvider } from "@earendil-works/pi-ai/oauth";
6+
import { getOAuthProvider, resolveOAuthStorageProvider } from "@earendil-works/pi-ai/oauth";
77
import { getAgentDir, VERSION } from "../config.ts";
88
import { AuthBrokerService, SqliteCredentialVault } from "../core/auth-broker.ts";
99
import { AuthBrokerRefresher } from "../core/auth-broker-refresher.ts";
@@ -244,13 +244,14 @@ async function loginCommand(command: ParsedCommand, agentDir: string): Promise<A
244244
if (provider === undefined) throw new AuthBrokerCommandError(`Unknown OAuth provider: ${providerId}`);
245245
if (command.dryRun) return { exitCode: 0, stderr: "", stdout: `Would start OAuth login for ${providerId}\n` };
246246
const credentials = await provider.login(loginCallbacks());
247+
const storageProvider = resolveOAuthStorageProvider(providerId);
247248
const vault = SqliteCredentialVault.open(vaultPath(agentDir));
248249
try {
249-
vault.upsertCredential(oauthRecord(providerId, command.identity ?? `oauth:${providerId}`, credentials));
250+
vault.upsertCredential(oauthRecord(storageProvider, command.identity ?? `oauth:${storageProvider}`, credentials));
250251
} finally {
251252
vault.close();
252253
}
253-
return { exitCode: 0, stderr: "", stdout: `Logged in to ${providerId}\n` };
254+
return { exitCode: 0, stderr: "", stdout: `Logged in to ${storageProvider}\n` };
254255
}
255256

256257
async function serveCommand(command: ParsedCommand, agentDir: string): Promise<AuthBrokerCommandExecution> {
@@ -310,24 +311,43 @@ async function prompt(message: string): Promise<string> {
310311
}
311312
}
312313

314+
const OAUTH_CORE_FIELDS = new Set(["access", "refresh", "expires", "type"]);
315+
316+
function oauthExtras(credentials: OAuthCredentials): Record<string, unknown> | undefined {
317+
const extras: Record<string, unknown> = {};
318+
for (const [key, value] of Object.entries(credentials)) {
319+
if (OAUTH_CORE_FIELDS.has(key)) continue;
320+
if (value !== undefined) extras[key] = value;
321+
}
322+
return Object.keys(extras).length > 0 ? extras : undefined;
323+
}
324+
325+
function oauthCredentialsFromMaterial(material: Extract<CredentialMaterial, { type: "oauth" }>): OAuthCredentials {
326+
return {
327+
access: material.accessToken,
328+
expires: material.expiresAt,
329+
refresh: material.refreshToken,
330+
...(material.extras ?? {}),
331+
};
332+
}
333+
313334
const refreshOAuthCredential = async (record: CredentialRecord): Promise<CredentialMaterial> => {
314335
if (record.material.type !== "oauth") throw new AuthBrokerCommandError("Only OAuth credentials can be refreshed");
315336
const provider = getOAuthProvider(record.pool.provider);
316337
if (provider === undefined) throw new AuthBrokerCommandError(`Unknown OAuth provider: ${record.pool.provider}`);
317-
const refreshed = await provider.refreshToken({
318-
access: record.material.accessToken,
319-
expires: record.material.expiresAt,
320-
refresh: record.material.refreshToken,
321-
});
338+
const refreshed = await provider.refreshToken(oauthCredentialsFromMaterial(record.material));
339+
const extras = oauthExtras(refreshed) ?? record.material.extras;
322340
return {
323341
accessToken: refreshed.access,
324342
expiresAt: refreshed.expires,
325343
refreshToken: refreshed.refresh,
326344
type: "oauth",
345+
...(extras === undefined ? {} : { extras }),
327346
};
328347
};
329348

330349
function oauthRecord(provider: string, identityKey: string, credentials: OAuthCredentials): CredentialRecord {
350+
const extras = oauthExtras(credentials);
331351
return {
332352
createdAt: new Date().toISOString(),
333353
credentialId: randomUUID(),
@@ -337,6 +357,7 @@ function oauthRecord(provider: string, identityKey: string, credentials: OAuthCr
337357
expiresAt: credentials.expires,
338358
refreshToken: credentials.refresh,
339359
type: "oauth",
360+
...(extras === undefined ? {} : { extras }),
340361
},
341362
pool: { provider, type: "oauth" },
342363
updatedAt: new Date().toISOString(),

packages/coding-agent/src/core/auth-broker.ts

Lines changed: 90 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ export class SqliteCredentialVault implements CredentialVault {
8181
record.createdAt,
8282
record.updatedAt,
8383
record.disabled?.at ?? null,
84-
record.disabled?.cause ?? null,
84+
record.disabled === undefined ? null : redactDisableCause(record.disabled.cause),
8585
);
8686
}
8787

@@ -92,6 +92,24 @@ export class SqliteCredentialVault implements CredentialVault {
9292
if (result.changes !== 1) throw new AuthBrokerError("Credential was not found");
9393
}
9494

95+
/**
96+
* CAS-guarded disable: only marks the credential disabled when `expectedUpdatedAt`
97+
* still matches. Returns false when a concurrent re-login/refresh rotated the row.
98+
*/
99+
disableCredentialIfUnchanged(
100+
credentialId: string,
101+
expectedUpdatedAt: string,
102+
cause: string,
103+
at = new Date().toISOString(),
104+
): boolean {
105+
const result = this.db
106+
.prepare(
107+
"UPDATE credentials SET disabled_at=?, disabled_cause=?, updated_at=? WHERE credential_id=? AND disabled_at IS NULL AND updated_at=?",
108+
)
109+
.run(at, redactDisableCause(cause), at, credentialId, expectedUpdatedAt);
110+
return result.changes === 1;
111+
}
112+
95113
applyRefresh(
96114
credentialId: string,
97115
expectedUpdatedAt: string,
@@ -127,9 +145,12 @@ export class SqliteCredentialVault implements CredentialVault {
127145
return this.transaction(() => {
128146
const record = this.select(request);
129147
const leaseId = randomUUID();
148+
const issuedAt = new Date().toISOString();
149+
const expiresAt = new Date(Date.now() + 15 * 60_000).toISOString();
150+
this.pruneExpiredLeases();
130151
this.db
131152
.prepare(
132-
"INSERT INTO leases (lease_id, credential_id, authentication_hash, selector, pool, session_id) VALUES (?, ?, ?, ?, ?, ?)",
153+
"INSERT INTO leases (lease_id, credential_id, authentication_hash, selector, pool, session_id, issued_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
133154
)
134155
.run(
135156
leaseId,
@@ -138,6 +159,8 @@ export class SqliteCredentialVault implements CredentialVault {
138159
JSON.stringify(request.selector),
139160
JSON.stringify(request.pool),
140161
request.sessionId ?? null,
162+
issuedAt,
163+
expiresAt,
141164
);
142165
return {
143166
credentialId: record.credentialId,
@@ -152,11 +175,13 @@ export class SqliteCredentialVault implements CredentialVault {
152175
consumeSelectionLease(request: ConsumeSelectionLeaseRequest): SelectionLease {
153176
return this.transaction(() => {
154177
const leaseRow = this.db
155-
.prepare("SELECT authentication_hash, credential_id, consumed_at FROM leases WHERE lease_id=?")
178+
.prepare("SELECT authentication_hash, credential_id, consumed_at, expires_at FROM leases WHERE lease_id=?")
156179
.get(request.leaseId);
157180
const lease = leaseRow === undefined ? undefined : parseLeaseRow(leaseRow);
158181
if (lease === undefined || lease.consumed_at !== null)
159182
throw new AuthBrokerError("Selection lease is no longer available");
183+
if (lease.expires_at !== "" && lease.expires_at < new Date().toISOString())
184+
throw new AuthBrokerError("Selection lease is no longer available");
160185
if (!safeEqual(lease.authentication_hash, digest(request.authentication)))
161186
throw new AuthBrokerError("Selection lease authentication failed");
162187
const consumedAt = new Date().toISOString();
@@ -218,8 +243,53 @@ export class SqliteCredentialVault implements CredentialVault {
218243

219244
private migrate(): void {
220245
this.db.exec(`CREATE TABLE IF NOT EXISTS credentials (credential_id TEXT PRIMARY KEY, provider TEXT NOT NULL, type TEXT NOT NULL CHECK(type IN ('api_key','oauth')), identity_key TEXT NOT NULL, material TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, disabled_at TEXT, disabled_cause TEXT, UNIQUE(provider, type, identity_key));
221-
CREATE TABLE IF NOT EXISTS leases (lease_id TEXT PRIMARY KEY, credential_id TEXT NOT NULL REFERENCES credentials(credential_id), authentication_hash TEXT NOT NULL, selector TEXT NOT NULL, pool TEXT NOT NULL, session_id TEXT, consumed_at TEXT, outcome TEXT);
246+
CREATE TABLE IF NOT EXISTS leases (lease_id TEXT PRIMARY KEY, credential_id TEXT NOT NULL REFERENCES credentials(credential_id), authentication_hash TEXT NOT NULL, selector TEXT NOT NULL, pool TEXT NOT NULL, session_id TEXT, issued_at TEXT NOT NULL DEFAULT '', expires_at TEXT NOT NULL DEFAULT '', consumed_at TEXT, outcome TEXT);
222247
CREATE TABLE IF NOT EXISTS state (key TEXT PRIMARY KEY, value TEXT NOT NULL);`);
248+
this.ensureLeaseColumns();
249+
}
250+
251+
private ensureLeaseColumns(): void {
252+
const columns = this.db
253+
.prepare("PRAGMA table_info(leases)")
254+
.all()
255+
.map((row) => (row as { name?: unknown }).name)
256+
.filter((name): name is string => typeof name === "string");
257+
if (!columns.includes("issued_at")) this.db.exec("ALTER TABLE leases ADD COLUMN issued_at TEXT NOT NULL DEFAULT ''");
258+
if (!columns.includes("expires_at")) this.db.exec("ALTER TABLE leases ADD COLUMN expires_at TEXT NOT NULL DEFAULT ''");
259+
this.db.exec("CREATE INDEX IF NOT EXISTS leases_expires_at_idx ON leases(expires_at)");
260+
}
261+
262+
/** Drop expired unconsumed leases and old consumed leases past retention. */
263+
pruneExpiredLeases(now = new Date(), retainConsumedMs = 24 * 60 * 60 * 1000): number {
264+
const nowIso = now.toISOString();
265+
const retainBefore = new Date(now.getTime() - retainConsumedMs).toISOString();
266+
const run = () => {
267+
const unconsumed = Number(
268+
this.db
269+
.prepare("DELETE FROM leases WHERE consumed_at IS NULL AND expires_at != '' AND expires_at < ?")
270+
.run(nowIso).changes,
271+
);
272+
const consumed = Number(
273+
this.db
274+
.prepare("DELETE FROM leases WHERE consumed_at IS NOT NULL AND consumed_at < ?")
275+
.run(retainBefore).changes,
276+
);
277+
return unconsumed + consumed;
278+
};
279+
// Allow callers already inside a BEGIN IMMEDIATE transaction (e.g. issueSelectionLease).
280+
try {
281+
this.db.exec("BEGIN IMMEDIATE");
282+
} catch {
283+
return run();
284+
}
285+
try {
286+
const result = run();
287+
this.db.exec("COMMIT");
288+
return result;
289+
} catch (error) {
290+
this.db.exec("ROLLBACK");
291+
throw error;
292+
}
223293
}
224294

225295
private select(request: SelectionLeaseRequest): CredentialRecord {
@@ -426,17 +496,16 @@ export class AuthBrokerService {
426496
const expiresAt = record.material.expiresAt;
427497
if (!Number.isFinite(expiresAt) || expiresAt > deadline) continue;
428498
checked += 1;
499+
const snapshotUpdatedAt = record.updatedAt;
429500
try {
430501
await this.refreshCredentialById(record.credentialId);
431502
refreshed += 1;
432503
} catch (error) {
433504
if (isDefinitiveOAuthFailure(error instanceof Error ? error.message : String(error))) {
434-
try {
435-
this.vault.disableCredential(record.credentialId, "oauth refresh failed definitively");
505+
// CAS: only disable if the row is still the same snapshot we tried to refresh.
506+
// A re-login that rotates material keeps credential_id but bumps updatedAt.
507+
if (this.vault.disableCredentialIfUnchanged(record.credentialId, snapshotUpdatedAt, "oauth refresh failed definitively")) {
436508
disabled += 1;
437-
} catch {
438-
// A peer/login rotated the row since the snapshot; the live
439-
// credential is intentionally kept. Leave it for the next sweep.
440509
}
441510
}
442511
}
@@ -537,23 +606,34 @@ function parseMaterial(value: unknown): CredentialMaterial {
537606
typeof material.accessToken === "string" &&
538607
typeof material.refreshToken === "string" &&
539608
typeof material.expiresAt === "number"
540-
)
609+
) {
610+
const extras =
611+
material.extras !== undefined &&
612+
typeof material.extras === "object" &&
613+
material.extras !== null &&
614+
!Array.isArray(material.extras)
615+
? (material.extras as Record<string, unknown>)
616+
: undefined;
541617
return {
542618
accessToken: material.accessToken,
543619
expiresAt: material.expiresAt,
544620
refreshToken: material.refreshToken,
545621
type: "oauth",
622+
...(extras === undefined ? {} : { extras }),
546623
};
624+
}
547625
throw new AuthBrokerError("Invalid broker database row");
548626
}
549627
function parseLeaseRow(row: Record<string, unknown>): {
550628
readonly authentication_hash: string;
551629
readonly credential_id: string;
552630
readonly consumed_at: string | null;
631+
readonly expires_at: string;
553632
} {
554633
return {
555634
authentication_hash: readString(row, "authentication_hash"),
556635
credential_id: readString(row, "credential_id"),
557636
consumed_at: readNullableString(row, "consumed_at"),
637+
expires_at: typeof row.expires_at === "string" ? row.expires_at : "",
558638
};
559639
}

packages/coding-agent/src/core/auth-multi-account.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ export type OAuthCredentialMaterial = {
3232
readonly expiresAt: number;
3333
readonly refreshToken: string;
3434
readonly type: "oauth";
35+
/** Provider-specific OAuth fields (e.g. Google projectId) preserved for refresh/request auth. */
36+
readonly extras?: Readonly<Record<string, unknown>>;
3537
};
3638

3739
export type CredentialMaterial = ApiKeyCredentialMaterial | OAuthCredentialMaterial;

packages/coding-agent/src/core/auth-storage.ts

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ export type PooledCredentialOptions = {
6060
export type PooledCredentialSelection = {
6161
apiKey: string;
6262
credentialId: string;
63+
headers?: Readonly<Record<string, string>>;
6364
reportOutcome: (status: UsageReport["status"]) => void;
6465
};
6566

@@ -393,6 +394,15 @@ export class AuthStorage {
393394
if (this.runtimeOverrides.has(storageProvider)) return true;
394395
if (this.data[storageProvider]) return true;
395396
if (getEnvApiKey(provider)) return true;
397+
// Pool-only setups are usable via selectPooledCredential / getApiKeyAndHeaders.
398+
if (
399+
this.credentialVault !== undefined &&
400+
this.credentialVault
401+
.metadataSnapshot()
402+
.credentials.some((credential) => credential.pool.provider === storageProvider && credential.disabled === undefined)
403+
) {
404+
return true;
405+
}
396406
return false;
397407
}
398408

@@ -588,10 +598,10 @@ export class AuthStorage {
588598
: { apiKey: provider.getApiKey(credentials) };
589599
}
590600

591-
selectPooledCredential(
601+
async selectPooledCredential(
592602
providerId: string,
593603
options: PooledCredentialOptions = {},
594-
): PooledCredentialSelection | undefined {
604+
): Promise<PooledCredentialSelection | undefined> {
595605
const storageProvider = resolveOAuthStorageProvider(providerId);
596606
if (
597607
this.runtimeOverrides.has(storageProvider) ||
@@ -613,7 +623,7 @@ export class AuthStorage {
613623
authentication: "local-auth-storage",
614624
leaseId: pending.leaseId,
615625
});
616-
return selectionFromLease(lease);
626+
return await selectionFromLease(providerId, lease);
617627
}
618628

619629
/**
@@ -624,11 +634,41 @@ export class AuthStorage {
624634
}
625635
}
626636

627-
function selectionFromLease(lease: SelectionLease): PooledCredentialSelection {
628-
const apiKey = lease.material.type === "api_key" ? lease.material.apiKey : lease.material.accessToken;
637+
async function selectionFromLease(
638+
providerId: string,
639+
lease: SelectionLease,
640+
): Promise<PooledCredentialSelection> {
641+
if (lease.material.type === "api_key") {
642+
return {
643+
apiKey: lease.material.apiKey,
644+
credentialId: lease.credentialId,
645+
reportOutcome: (status) => {
646+
lease.reportOutcome({ observedAt: new Date().toISOString(), status });
647+
},
648+
};
649+
}
650+
const provider = getOAuthProvider(providerId) ?? getOAuthProvider(lease.pool.provider);
651+
const credentials: OAuthCredentials = {
652+
access: lease.material.accessToken,
653+
expires: lease.material.expiresAt,
654+
refresh: lease.material.refreshToken,
655+
...(lease.material.extras ?? {}),
656+
};
657+
let apiKey = lease.material.accessToken;
658+
let headers: Readonly<Record<string, string>> | undefined;
659+
if (provider !== undefined) {
660+
if (supportsRequestAuth(provider)) {
661+
const auth = await provider.getRequestAuth(credentials);
662+
apiKey = auth.apiKey;
663+
headers = auth.headers;
664+
} else {
665+
apiKey = provider.getApiKey(credentials);
666+
}
667+
}
629668
return {
630669
apiKey,
631670
credentialId: lease.credentialId,
671+
...(headers === undefined ? {} : { headers }),
632672
reportOutcome: (status) => {
633673
lease.reportOutcome({ observedAt: new Date().toISOString(), status });
634674
},

0 commit comments

Comments
 (0)