feat(ai): added credential disable tracking with auditability improvements

- Added `disabledCause` parameter to credential deletion methods to track reason credentials are disabled.
- Changed credential disabling mechanism from boolean `disabled` flag to `disabled_cause` text field for better auditability.
- Fixed credential purging to respect disabled credentials during email deduplication operations.
- Refactored `replaceAuthCredentialsForProvider()` to update matching credentials instead of deleting all, preserving credential history.
This commit is contained in:
can1357
2026-03-06 16:05:04 +01:00
parent 8f88e8c82e
commit 5293eaff29
5 changed files with 175 additions and 50 deletions
+3 -1
View File
@@ -1,7 +1,6 @@
# Changelog
## [Unreleased]
### Breaking Changes
- Changed `reasoning` parameter from `ThinkingLevel | undefined` to `Effort | undefined` in `SimpleStreamOptions`; 'off' is no longer valid (omit the field instead)
@@ -32,6 +31,8 @@
### Changed
- Changed credential disabling mechanism from boolean `disabled` flag to `disabled_cause` text field for tracking why credentials were disabled
- Changed `deleteAuthCredential()` and `deleteAuthCredentialsForProvider()` methods to require a `disabledCause` parameter explaining the reason for disabling
- Changed Gemini model parsing to strip `-preview` suffix for consistent model identification
- Changed OpenAI Codex websocket error handling to detect fatal connection errors and immediately fall back to SSE without retrying
- Changed OpenAI Codex to always use websockets v2 protocol (removed v1 support)
@@ -65,6 +66,7 @@
### Fixed
- Fixed credential purging to respect disabled credentials when deduplicating by email, preventing re-enablement of intentionally disabled credentials
- Fixed OpenAI Codex websocket error reporting to include detailed error messages from error events
- Fixed conversation history reconstruction to support incremental updates from multiple assistant messages while maintaining backward compatibility with full-snapshot payloads
- Fixed OpenAI Codex to reject unsupported effort levels instead of silently clamping them, providing clear error messages about supported efforts
+87 -35
View File
@@ -108,6 +108,7 @@ export interface StoredAuthCredential {
id: number;
provider: string;
credential: AuthCredential;
disabledCause: string | null;
}
// ─────────────────────────────────────────────────────────────────────────────
@@ -484,7 +485,7 @@ export class AuthStorage {
}
if (removed.length > 0) {
for (const entry of removed) {
this.#store.deleteAuthCredential(entry.id);
this.#store.deleteAuthCredential(entry.id, "deduplicated duplicate credential");
}
this.#resetProviderAssignments(provider);
}
@@ -659,10 +660,10 @@ export class AuthStorage {
* The credential remains in the database but is excluded from active queries.
* Cleans up provider entry if last credential disabled.
*/
#removeCredentialAt(provider: string, index: number): void {
#disableCredentialAt(provider: string, index: number, disabledCause: string): void {
const entries = this.#getStoredCredentials(provider);
if (index < 0 || index >= entries.length) return;
this.#store.deleteAuthCredential(entries[index].id);
this.#store.deleteAuthCredential(entries[index].id, disabledCause);
const updated = entries.filter((_value, idx) => idx !== index);
this.#setStoredCredentials(provider, updated);
this.#resetProviderAssignments(provider);
@@ -693,7 +694,7 @@ export class AuthStorage {
* Remove credential for a provider.
*/
async remove(provider: string): Promise<void> {
this.#store.deleteAuthCredentialsForProvider(provider);
this.#store.deleteAuthCredentialsForProvider(provider, "deleted by user");
this.#data.delete(provider);
this.#resetProviderAssignments(provider);
}
@@ -1858,8 +1859,8 @@ export class AuthStorage {
});
if (isDefinitiveFailure) {
// Permanently remove invalid credentials
this.#removeCredentialAt(provider, selection.index);
// Permanently disable invalid credentials with an explicit cause for inspection/debugging
this.#disableCredentialAt(provider, selection.index, `oauth refresh failed: ${errorMsg}`);
if (this.#getCredentialsForProvider(provider).some(credential => credential.type === "oauth")) {
return this.getApiKey(provider, sessionId, options);
}
@@ -1950,6 +1951,7 @@ type AuthRow = {
provider: string;
credential_type: string;
data: string;
disabled_cause: string | null;
};
function serializeCredential(
@@ -1993,6 +1995,26 @@ function deserializeCredential(row: AuthRow): AuthCredential | null {
return null;
}
function normalizeDisabledCause(disabledCause: string): string {
const normalized = disabledCause.trim();
return normalized.length > 0 ? normalized : "disabled";
}
function toStoredAuthCredential(row: AuthRow, credential: AuthCredential): StoredAuthCredential {
return { id: row.id, provider: row.provider, credential, disabledCause: row.disabled_cause };
}
/** Returns a stable identity string for matching credentials across replace operations. */
function credentialIdentity(credential: AuthCredential): string | null {
if (credential.type === "api_key") return `api_key:${credential.key}`;
if (credential.type === "oauth") {
if (credential.accountId) return `account:${credential.accountId}`;
const [email] = extractCredentialEmails(credential);
if (email) return `email:${email}`;
}
return null;
}
/** Extracts normalized email identifiers from a credential, including JWT profile claims. */
function extractCredentialEmails(credential: AuthCredential): string[] {
if (credential.type !== "oauth") return [];
@@ -2053,13 +2075,13 @@ export class AuthCredentialStore {
this.#initializeSchema();
this.#listActiveStmt = this.#db.prepare(
"SELECT id, provider, credential_type, data FROM auth_credentials WHERE disabled = 0 ORDER BY id ASC",
"SELECT id, provider, credential_type, data, disabled_cause FROM auth_credentials WHERE disabled_cause IS NULL ORDER BY id ASC",
);
this.#listActiveByProviderStmt = this.#db.prepare(
"SELECT id, provider, credential_type, data FROM auth_credentials WHERE provider = ? AND disabled = 0 ORDER BY id ASC",
"SELECT id, provider, credential_type, data, disabled_cause FROM auth_credentials WHERE provider = ? AND disabled_cause IS NULL ORDER BY id ASC",
);
this.#listDisabledByProviderStmt = this.#db.prepare(
"SELECT id, credential_type, data FROM auth_credentials WHERE provider = ? AND disabled = 1 ORDER BY id ASC",
"SELECT id, provider, credential_type, data, disabled_cause FROM auth_credentials WHERE provider = ? AND disabled_cause IS NOT NULL ORDER BY id ASC",
);
this.#insertStmt = this.#db.prepare(
"INSERT INTO auth_credentials (provider, credential_type, data) VALUES (?, ?, ?) RETURNING id",
@@ -2068,10 +2090,10 @@ export class AuthCredentialStore {
"UPDATE auth_credentials SET credential_type = ?, data = ?, updated_at = unixepoch() WHERE id = ?",
);
this.#deleteStmt = this.#db.prepare(
"UPDATE auth_credentials SET disabled = 1, updated_at = unixepoch() WHERE id = ?",
"UPDATE auth_credentials SET disabled_cause = ?, updated_at = unixepoch() WHERE id = ?",
);
this.#deleteByProviderStmt = this.#db.prepare(
"UPDATE auth_credentials SET disabled = 1, updated_at = unixepoch() WHERE provider = ?",
"UPDATE auth_credentials SET disabled_cause = ?, updated_at = unixepoch() WHERE provider = ? AND disabled_cause IS NULL",
);
this.#hardDeleteStmt = this.#db.prepare("DELETE FROM auth_credentials WHERE id = ?");
this.#getCacheStmt = this.#db.prepare("SELECT value FROM cache WHERE key = ? AND expires_at > unixepoch()");
@@ -2112,7 +2134,7 @@ CREATE TABLE IF NOT EXISTS auth_credentials (
provider TEXT NOT NULL,
credential_type TEXT NOT NULL,
data TEXT NOT NULL,
disabled INTEGER NOT NULL DEFAULT 0,
disabled_cause TEXT DEFAULT NULL,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
@@ -2126,10 +2148,18 @@ CREATE TABLE IF NOT EXISTS cache (
CREATE INDEX IF NOT EXISTS idx_cache_expires ON cache(expires_at);
`);
// Migration: add disabled column if missing (for databases created by old CliAuthStorage)
const cols = this.#db.prepare("PRAGMA table_info(auth_credentials)").all() as Array<{ name?: string }>;
if (!cols.some(c => c.name === "disabled")) {
this.#db.exec("ALTER TABLE auth_credentials ADD COLUMN disabled INTEGER NOT NULL DEFAULT 0");
const hasDisabledCause = cols.some(c => c.name === "disabled_cause");
const hasDisabled = cols.some(c => c.name === "disabled");
if (!hasDisabledCause) {
this.#db.exec("ALTER TABLE auth_credentials ADD COLUMN disabled_cause TEXT DEFAULT NULL");
}
if (hasDisabled) {
this.#db.exec(`
UPDATE auth_credentials
SET disabled_cause = COALESCE(disabled_cause, 'disabled')
WHERE disabled = 1 AND disabled_cause IS NULL
`);
}
}
@@ -2145,26 +2175,52 @@ CREATE INDEX IF NOT EXISTS idx_cache_expires ON cache(expires_at);
for (const row of rows) {
const credential = deserializeCredential(row);
if (!credential) continue;
results.push({ id: row.id, provider: row.provider, credential });
results.push(toStoredAuthCredential(row, credential));
}
return results;
}
replaceAuthCredentialsForProvider(provider: string, credentials: AuthCredential[]): StoredAuthCredential[] {
const replace = this.#db.transaction((providerName: string, items: AuthCredential[]) => {
this.#deleteByProviderStmt.run(providerName);
const inserted: StoredAuthCredential[] = [];
const existingRows = this.#listActiveByProviderStmt.all(providerName) as AuthRow[];
const existing: Array<{ id: number; credential: AuthCredential; identity: string | null }> = [];
for (const row of existingRows) {
const credential = deserializeCredential(row);
if (!credential) continue;
existing.push({ id: row.id, credential, identity: credentialIdentity(credential) });
}
const result: StoredAuthCredential[] = [];
const matchedExistingIds = new Set<number>();
for (const credential of items) {
const serialized = serializeCredential(credential);
if (!serialized) continue;
const row = this.#insertStmt.get(providerName, serialized.credentialType, serialized.data) as
| { id?: number }
| undefined;
if (row?.id) {
inserted.push({ id: row.id, provider: providerName, credential });
const identity = credentialIdentity(credential);
const match = identity
? existing.find(e => e.identity === identity && !matchedExistingIds.has(e.id))
: null;
if (match) {
matchedExistingIds.add(match.id);
this.#updateStmt.run(serialized.credentialType, serialized.data, match.id);
result.push({ id: match.id, provider: providerName, credential, disabledCause: null });
} else {
const row = this.#insertStmt.get(providerName, serialized.credentialType, serialized.data) as
| { id?: number }
| undefined;
if (row?.id) {
result.push({ id: row.id, provider: providerName, credential, disabledCause: null });
}
}
}
return inserted;
for (const row of existing) {
if (!matchedExistingIds.has(row.id)) {
this.#deleteStmt.run("replaced by newer credential", row.id);
}
}
return result;
});
const result = replace(provider, credentials);
@@ -2187,13 +2243,9 @@ CREATE INDEX IF NOT EXISTS idx_cache_expires ON cache(expires_at);
}
if (activeEmails.size === 0) return;
const disabledRows = this.#listDisabledByProviderStmt.all(provider) as Array<{
id: number;
credential_type: string;
data: string;
}>;
const disabledRows = this.#listDisabledByProviderStmt.all(provider) as AuthRow[];
for (const row of disabledRows) {
const credential = deserializeCredential({ ...row, provider });
const credential = deserializeCredential(row);
if (!credential) {
this.#hardDeleteStmt.run(row.id);
continue;
@@ -2224,17 +2276,17 @@ CREATE INDEX IF NOT EXISTS idx_cache_expires ON cache(expires_at);
}
}
deleteAuthCredential(id: number): void {
deleteAuthCredential(id: number, disabledCause: string): void {
try {
this.#deleteStmt.run(id);
this.#deleteStmt.run(normalizeDisabledCause(disabledCause), id);
} catch {
// Ignore delete failures
}
}
deleteAuthCredentialsForProvider(provider: string): void {
deleteAuthCredentialsForProvider(provider: string, disabledCause: string): void {
try {
this.#deleteByProviderStmt.run(provider);
this.#deleteByProviderStmt.run(normalizeDisabledCause(disabledCause), provider);
} catch {
// Ignore delete failures
}
@@ -2328,7 +2380,7 @@ CREATE INDEX IF NOT EXISTS idx_cache_expires ON cache(expires_at);
* Delete all credentials for a provider.
*/
deleteProvider(provider: string): void {
this.deleteAuthCredentialsForProvider(provider);
this.deleteAuthCredentialsForProvider(provider, "deleted by user");
}
close(): void {
@@ -48,6 +48,20 @@ function countCredentialRows(dbPath: string, provider: string): number {
}
}
function readDisabledCauses(dbPath: string, provider: string): string[] {
const db = new Database(dbPath, { readonly: true });
try {
const rows = db
.prepare(
"SELECT disabled_cause FROM auth_credentials WHERE provider = ? AND disabled_cause IS NOT NULL ORDER BY id ASC",
)
.all(provider) as Array<{ disabled_cause?: string | null }>;
return rows.flatMap(row => (typeof row.disabled_cause === "string" ? [row.disabled_cause] : []));
} finally {
db.close();
}
}
describe("AuthStorage openai-codex email dedupe", () => {
let tempDir = "";
let dbPath = "";
@@ -171,4 +185,58 @@ describe("AuthStorage openai-codex email dedupe", () => {
const credentials = store.listAuthCredentials("openai-codex");
expect(credentials).toHaveLength(2);
});
it("stores the disable cause when a credential is soft-disabled", async () => {
if (!store || !dbPath) throw new Error("test setup failed");
store.replaceAuthCredentialsForProvider("openai-codex", [
createCredential({ suffix: "only", accountId: "account-a", email: "only@example.com" }),
]);
const [credential] = store.listAuthCredentials("openai-codex");
if (!credential) throw new Error("expected stored credential");
const disabledCause = "oauth refresh failed: invalid_grant";
store.deleteAuthCredential(credential.id, disabledCause);
expect(store.listAuthCredentials("openai-codex")).toHaveLength(0);
expect(readDisabledCauses(dbPath, "openai-codex")).toEqual([disabledCause]);
});
it("backfills a default disabled cause when migrating legacy disabled rows", async () => {
if (!tempDir) throw new Error("test setup failed");
const legacyDbPath = path.join(tempDir, "legacy-agent.db");
const legacyDb = new Database(legacyDbPath);
legacyDb.exec(`
CREATE TABLE auth_credentials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
provider TEXT NOT NULL,
credential_type TEXT NOT NULL,
data TEXT NOT NULL,
disabled INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
`);
legacyDb
.prepare("INSERT INTO auth_credentials (provider, credential_type, data, disabled) VALUES (?, ?, ?, ?)")
.run(
"openai-codex",
"oauth",
JSON.stringify(
createCredential({ suffix: "legacy", accountId: "legacy-account", email: "legacy@example.com" }),
),
1,
);
legacyDb.close();
const migratedStore = await AuthCredentialStore.open(legacyDbPath);
try {
expect(migratedStore.listAuthCredentials("openai-codex")).toHaveLength(0);
expect(readDisabledCauses(legacyDbPath, "openai-codex")).toEqual(["disabled"]);
} finally {
migratedStore.close();
}
});
});
+3
View File
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
### Breaking Changes
- Changed `ThinkingLevel` type to be imported from `@oh-my-pi/pi-agent-core` instead of `@oh-my-pi/pi-ai`
@@ -27,6 +28,8 @@
### Changed
- Changed credential deletion to disable credentials with persisted cause instead of permanent deletion
- Added `disabledCause` parameter to credential deletion methods to track reason for disabling
- Changed thinking level parsing to use `parseEffort()` from local thinking module instead of `parseThinkingLevel()` from pi-ai
- Changed model list display to show supported thinking efforts (e.g., "low,medium,high") instead of yes/no reasoning indicator
- Changed footer and status line to check `model.thinking` instead of `model.reasoning` for thinking level display
@@ -276,22 +276,20 @@ CREATE TABLE settings (
* @returns Array of stored credentials with their database IDs
*/
listAuthCredentials(provider?: string, includeDisabled = false): StoredAuthCredential[] {
// AuthCredentialStore doesn't expose includeDisabled yet, so we filter if needed
const credentials = this.#authStore.listAuthCredentials(provider);
if (!includeDisabled) return credentials;
// For now, includeDisabled requires direct DB access
// This is only used internally, so it's acceptable
const stmt = this.#db.prepare(
provider
? "SELECT id, provider, credential_type, data FROM auth_credentials WHERE provider = ? ORDER BY id ASC"
: "SELECT id, provider, credential_type, data FROM auth_credentials ORDER BY id ASC",
? "SELECT id, provider, credential_type, data, disabled_cause FROM auth_credentials WHERE provider = ? ORDER BY id ASC"
: "SELECT id, provider, credential_type, data, disabled_cause FROM auth_credentials ORDER BY id ASC",
);
const rows = (provider ? stmt.all(provider) : stmt.all()) as Array<{
id: number;
provider: string;
credential_type: string;
data: string;
disabled_cause: string | null;
}>;
const results: StoredAuthCredential[] = [];
@@ -309,7 +307,7 @@ CREATE TABLE settings (
continue;
}
results.push({ id: row.id, provider: row.provider, credential });
results.push({ id: row.id, provider: row.provider, credential, disabledCause: row.disabled_cause });
} catch {}
}
return results;
@@ -336,19 +334,21 @@ CREATE TABLE settings (
}
/**
* Deletes an auth credential by ID.
* @param id - Database row ID of the credential to delete
* Disables an auth credential by ID with a persisted cause.
* @param id - Database row ID of the credential to disable
* @param disabledCause - Human-readable cause stored with the disabled row
*/
deleteAuthCredential(id: number): void {
this.#authStore.deleteAuthCredential(id);
deleteAuthCredential(id: number, disabledCause: string): void {
this.#authStore.deleteAuthCredential(id, disabledCause);
}
/**
* Deletes all auth credentials for a provider.
* @param provider - Provider name whose credentials should be deleted
* Disables all auth credentials for a provider with a persisted cause.
* @param provider - Provider name whose credentials should be disabled
* @param disabledCause - Human-readable cause stored with the disabled rows
*/
deleteAuthCredentialsForProvider(provider: string): void {
this.#authStore.deleteAuthCredentialsForProvider(provider);
deleteAuthCredentialsForProvider(provider: string, disabledCause: string): void {
this.#authStore.deleteAuthCredentialsForProvider(provider, disabledCause);
}
/**