open-multi-agent-kit 1.2.3 → 1.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +2 -2
  3. package/dist/cli/help.d.ts.map +1 -1
  4. package/dist/cli/help.js +2 -0
  5. package/dist/cli/help.js.map +1 -1
  6. package/dist/commands/doctor-provider.d.ts.map +1 -1
  7. package/dist/commands/doctor-provider.js +2 -13
  8. package/dist/commands/doctor-provider.js.map +1 -1
  9. package/dist/commands/provider-adopt-cli.d.ts +24 -0
  10. package/dist/commands/provider-adopt-cli.d.ts.map +1 -0
  11. package/dist/commands/provider-adopt-cli.js +182 -0
  12. package/dist/commands/provider-adopt-cli.js.map +1 -0
  13. package/dist/commands/provider-adopt-status.d.ts +21 -0
  14. package/dist/commands/provider-adopt-status.d.ts.map +1 -0
  15. package/dist/commands/provider-adopt-status.js +84 -0
  16. package/dist/commands/provider-adopt-status.js.map +1 -0
  17. package/dist/commands/provider-known-apis.d.ts +2 -0
  18. package/dist/commands/provider-known-apis.d.ts.map +1 -0
  19. package/dist/commands/provider-known-apis.js +28 -0
  20. package/dist/commands/provider-known-apis.js.map +1 -0
  21. package/dist/commands/run-command.d.ts.map +1 -1
  22. package/dist/commands/run-command.js +4 -0
  23. package/dist/commands/run-command.js.map +1 -1
  24. package/dist/core/agent-session.d.ts.map +1 -1
  25. package/dist/core/agent-session.js +6 -0
  26. package/dist/core/agent-session.js.map +1 -1
  27. package/dist/core/auth-storage.d.ts +16 -16
  28. package/dist/core/auth-storage.d.ts.map +1 -1
  29. package/dist/core/auth-storage.js +94 -111
  30. package/dist/core/auth-storage.js.map +1 -1
  31. package/dist/core/compaction/compaction.d.ts.map +1 -1
  32. package/dist/core/compaction/compaction.js +13 -10
  33. package/dist/core/compaction/compaction.js.map +1 -1
  34. package/dist/core/compaction/inert-tail.d.ts +23 -0
  35. package/dist/core/compaction/inert-tail.d.ts.map +1 -0
  36. package/dist/core/compaction/inert-tail.js +108 -0
  37. package/dist/core/compaction/inert-tail.js.map +1 -0
  38. package/dist/core/compaction/provenance.d.ts +30 -0
  39. package/dist/core/compaction/provenance.d.ts.map +1 -0
  40. package/dist/core/compaction/provenance.js +59 -0
  41. package/dist/core/compaction/provenance.js.map +1 -0
  42. package/dist/core/compaction/transaction.d.ts.map +1 -1
  43. package/dist/core/compaction/transaction.js +4 -1
  44. package/dist/core/compaction/transaction.js.map +1 -1
  45. package/dist/core/external-credential-sources.d.ts +48 -0
  46. package/dist/core/external-credential-sources.d.ts.map +1 -0
  47. package/dist/core/external-credential-sources.js +176 -0
  48. package/dist/core/external-credential-sources.js.map +1 -0
  49. package/dist/core/model-registry.d.ts +11 -0
  50. package/dist/core/model-registry.d.ts.map +1 -1
  51. package/dist/core/model-registry.js +28 -14
  52. package/dist/core/model-registry.js.map +1 -1
  53. package/dist/core/oauth-account-import.d.ts +52 -0
  54. package/dist/core/oauth-account-import.d.ts.map +1 -0
  55. package/dist/core/oauth-account-import.js +125 -0
  56. package/dist/core/oauth-account-import.js.map +1 -0
  57. package/dist/core/oauth-credential.d.ts +22 -0
  58. package/dist/core/oauth-credential.d.ts.map +1 -0
  59. package/dist/core/oauth-credential.js +36 -0
  60. package/dist/core/oauth-credential.js.map +1 -0
  61. package/dist/core/session-compaction-service.d.ts +2 -2
  62. package/dist/core/session-compaction-service.d.ts.map +1 -1
  63. package/dist/core/session-compaction-service.js +20 -57
  64. package/dist/core/session-compaction-service.js.map +1 -1
  65. package/dist/core/verified-run/owned-execution.d.ts.map +1 -1
  66. package/dist/core/verified-run/owned-execution.js +13 -1
  67. package/dist/core/verified-run/owned-execution.js.map +1 -1
  68. package/docs/compaction.md +2 -0
  69. package/docs/extensions.md +1 -1
  70. package/examples/extensions/custom-provider-anthropic/package-lock.json +2 -2
  71. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  72. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  73. package/examples/extensions/gondolin/package-lock.json +2 -2
  74. package/examples/extensions/gondolin/package.json +1 -1
  75. package/examples/extensions/sandbox/package-lock.json +2 -2
  76. package/examples/extensions/sandbox/package.json +1 -1
  77. package/examples/extensions/with-deps/package-lock.json +2 -2
  78. package/examples/extensions/with-deps/package.json +1 -1
  79. package/npm-shrinkwrap.json +18 -18
  80. package/package.json +6 -6
@@ -6,24 +6,14 @@
6
6
  * try to refresh tokens simultaneously.
7
7
  */
8
8
  import { type OAuthCredentials, type OAuthLoginCallbacks, type OAuthProviderId, type OAuthProviderInterface } from "omk-ai";
9
+ import { type OAuthAccountImport, type OAuthAccountSummary } from "./oauth-account-import.ts";
10
+ import { type OAuthCredential } from "./oauth-credential.ts";
9
11
  export type ApiKeyCredential = {
10
12
  type: "api_key";
11
13
  key: string;
12
14
  };
13
- export type OAuthCredential = {
14
- type: "oauth";
15
- /** Complete account list when more than one subscription account is configured. */
16
- accounts?: OAuthCredentials[];
17
- /** Index of the account explicitly selected for this provider. */
18
- activeAccount?: number;
19
- /** Legacy cursor accepted when migrating older auth.json files. */
20
- nextAccount?: number;
21
- } & OAuthCredentials;
22
- export interface OAuthAccountSummary {
23
- index: number;
24
- label: string;
25
- selected: boolean;
26
- }
15
+ export type { OAuthAccountImport, OAuthAccountSummary } from "./oauth-account-import.ts";
16
+ export type { OAuthCredential } from "./oauth-credential.ts";
27
17
  export type AuthCredential = ApiKeyCredential | OAuthCredential;
28
18
  export type AuthStorageData = Record<string, AuthCredential>;
29
19
  export type AuthStatus = {
@@ -119,8 +109,14 @@ export declare class AuthStorage {
119
109
  listOAuthAccounts(provider: string): OAuthAccountSummary[];
120
110
  /** Return the human-readable label of the selected OAuth account. */
121
111
  getOAuthAccountLabel(provider: string): string | undefined;
122
- /** Explicitly select and persist the OAuth account used by this provider. */
112
+ /**
113
+ * Explicitly select and persist the OAuth account used by this provider.
114
+ */
123
115
  selectOAuthAccount(provider: string, accountIndex: number): void;
116
+ /** Match, merge and write inside the storage lock (`omk provider adopt`). The active selection never moves. */
117
+ addOAuthAccount(provider: string, credentials: OAuthCredentials): OAuthAccountImport;
118
+ /** Predict what `addOAuthAccount` would do, without writing. */
119
+ previewOAuthAccountImport(provider: string, credentials: OAuthCredentials): OAuthAccountImport;
124
120
  /**
125
121
  * Return the explicitly selected OAuth credentials without refreshing tokens.
126
122
  * Used by provider model customization hooks.
@@ -135,6 +131,11 @@ export declare class AuthStorage {
135
131
  */
136
132
  getAll(): AuthStorageData;
137
133
  drainErrors(): Error[];
134
+ /**
135
+ * True when the credential store could not be read (for example, lock contention with another
136
+ * session). Callers must not substitute a different credential source while this is set.
137
+ */
138
+ hasLoadError(): boolean;
138
139
  /**
139
140
  * Login to an OAuth provider. Repeated logins append distinct accounts and
140
141
  * update an existing account when the provider exposes a stable identity.
@@ -179,5 +180,4 @@ export declare class AuthStorage {
179
180
  */
180
181
  getOAuthProviders(): OAuthProviderInterface[];
181
182
  }
182
- export {};
183
183
  //# sourceMappingURL=auth-storage.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"auth-storage.d.ts","sourceRoot":"","sources":["../../src/core/auth-storage.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Credential storage for API keys and OAuth tokens.\n * Handles loading, saving, and refreshing credentials from auth.json.\n *\n * Uses file locking to prevent race conditions when multiple OMK instances\n * try to refresh tokens simultaneously.\n */\n\nimport { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from \"fs\";\nimport {\n\tfindEnvKeys,\n\tgetEnvApiKey,\n\ttype OAuthCredentials,\n\ttype OAuthLoginCallbacks,\n\ttype OAuthProviderId,\n\ttype OAuthProviderInterface,\n} from \"omk-ai\";\nimport { getOAuthApiKey, getOAuthProvider, getOAuthProviders } from \"omk-ai/oauth\";\nimport { dirname, join } from \"path\";\nimport lockfile from \"proper-lockfile\";\nimport { getAgentDir } from \"../config.ts\";\nimport { stripAnsi } from \"../utils/ansi.ts\";\nimport { normalizePath } from \"../utils/paths.ts\";\nimport { resolveConfigValue } from \"./resolve-config-value.ts\";\n\nconst RETIRED_GROK_OAUTH_PROXY = \"grok-oauth-proxy\";\n\nfunction omitRetiredGrokOAuthProxy(data: AuthStorageData): AuthStorageData {\n\tif (!(RETIRED_GROK_OAUTH_PROXY in data)) return data;\n\tconst { [RETIRED_GROK_OAUTH_PROXY]: _retired, ...kept } = data;\n\treturn kept;\n}\n\nexport type ApiKeyCredential = {\n\ttype: \"api_key\";\n\tkey: string;\n};\n\nexport type OAuthCredential = {\n\ttype: \"oauth\";\n\t/** Complete account list when more than one subscription account is configured. */\n\taccounts?: OAuthCredentials[];\n\t/** Index of the account explicitly selected for this provider. */\n\tactiveAccount?: number;\n\t/** Legacy cursor accepted when migrating older auth.json files. */\n\tnextAccount?: number;\n} & OAuthCredentials;\n\nexport interface OAuthAccountSummary {\n\tindex: number;\n\tlabel: string;\n\tselected: boolean;\n}\n\nexport type AuthCredential = ApiKeyCredential | OAuthCredential;\n\nexport type AuthStorageData = Record<string, AuthCredential>;\n\nconst OAUTH_ACCOUNT_ID_FIELDS = [\"accountId\", \"email\", \"userId\", \"username\"] as const;\nconst OAUTH_ACCOUNT_LABEL_FIELDS = [\"email\", \"username\"] as const;\nconst OAUTH_ACCOUNT_LABEL_MAX_LENGTH = 96;\nconst UNSAFE_ACCOUNT_LABEL_PATTERN =\n\t/[\\u0000-\\u0008\\u000b\\u000c\\u000e-\\u001f\\u007f-\\u009f\\u200b-\\u200f\\u202a-\\u202e\\u2060-\\u206f\\ufeff]/g;\n\nfunction sanitizeOAuthAccountLabel(value: string): string | undefined {\n\tconst sanitized = stripAnsi(value)\n\t\t.replace(/[\\t\\r\\n]+/g, \" \")\n\t\t.replace(UNSAFE_ACCOUNT_LABEL_PATTERN, \"\")\n\t\t.replace(/\\s+/g, \" \")\n\t\t.trim()\n\t\t.slice(0, OAUTH_ACCOUNT_LABEL_MAX_LENGTH);\n\treturn sanitized || undefined;\n}\n\nfunction stripOAuthStorageMetadata(credential: OAuthCredential): OAuthCredentials {\n\tconst {\n\t\ttype: _type,\n\t\taccounts: _accounts,\n\t\tactiveAccount: _activeAccount,\n\t\tnextAccount: _nextAccount,\n\t\t...credentials\n\t} = credential;\n\treturn credentials;\n}\n\nfunction getOAuthAccounts(credential: OAuthCredential): OAuthCredentials[] {\n\tif (Array.isArray(credential.accounts) && credential.accounts.length > 0) {\n\t\treturn credential.accounts;\n\t}\n\treturn [stripOAuthStorageMetadata(credential)];\n}\n\nfunction normalizeOAuthAccountIndex(index: unknown, accountCount: number): number {\n\tif (accountCount <= 0 || typeof index !== \"number\" || !Number.isInteger(index)) {\n\t\treturn 0;\n\t}\n\treturn ((index % accountCount) + accountCount) % accountCount;\n}\n\nfunction getSelectedOAuthAccountIndex(credential: OAuthCredential, accountCount: number): number {\n\treturn normalizeOAuthAccountIndex(credential.activeAccount ?? credential.nextAccount, accountCount);\n}\n\nfunction createOAuthCredential(accounts: OAuthCredentials[], activeAccount = 0): OAuthCredential {\n\tconst selectedIndex = normalizeOAuthAccountIndex(activeAccount, accounts.length);\n\tconst selected = accounts[selectedIndex];\n\tif (!selected) {\n\t\tthrow new Error(\"OAuth credential must contain at least one account\");\n\t}\n\tif (accounts.length === 1) {\n\t\treturn { ...selected, type: \"oauth\" };\n\t}\n\treturn {\n\t\t...selected,\n\t\ttype: \"oauth\",\n\t\taccounts,\n\t\tactiveAccount: selectedIndex,\n\t};\n}\n\nfunction getOAuthAccountIdentity(credentials: OAuthCredentials): string | undefined {\n\tconst accountId = credentials.accountId;\n\tconst orgId = credentials.orgId;\n\tif (typeof accountId === \"string\" && accountId.trim() && typeof orgId === \"string\" && orgId.trim()) {\n\t\treturn `accountId:${accountId.trim().toLowerCase()}:orgId:${orgId.trim().toLowerCase()}`;\n\t}\n\tfor (const field of OAUTH_ACCOUNT_ID_FIELDS) {\n\t\tconst value = credentials[field];\n\t\tif (typeof value === \"string\" && value.trim()) {\n\t\t\treturn `${field}:${value.trim().toLowerCase()}`;\n\t\t}\n\t}\n\treturn undefined;\n}\n\nfunction getOAuthAccountDisplayLabel(providerId: string, credentials: OAuthCredentials): string | undefined {\n\tfor (const field of OAUTH_ACCOUNT_LABEL_FIELDS) {\n\t\tconst value = credentials[field];\n\t\tif (typeof value === \"string\" && value.trim()) {\n\t\t\tconst label = sanitizeOAuthAccountLabel(value);\n\t\t\tif (!label) continue;\n\t\t\tconst orgName =\n\t\t\t\ttypeof credentials.orgName === \"string\" ? sanitizeOAuthAccountLabel(credentials.orgName) : undefined;\n\t\t\treturn sanitizeOAuthAccountLabel(orgName ? `${label} (${orgName})` : label);\n\t\t}\n\t}\n\ttry {\n\t\tconst label = getOAuthProvider(providerId)?.getAccountLabel?.(credentials);\n\t\treturn typeof label === \"string\" ? sanitizeOAuthAccountLabel(label) : undefined;\n\t} catch {\n\t\treturn undefined;\n\t}\n}\n\nfunction findMatchingOAuthAccount(accounts: OAuthCredentials[], credentials: OAuthCredentials): number {\n\tconst identity = getOAuthAccountIdentity(credentials);\n\tif (identity) {\n\t\tconst exactMatch = accounts.findIndex((account) => getOAuthAccountIdentity(account) === identity);\n\t\tif (exactMatch >= 0) return exactMatch;\n\t}\n\n\t// Let newly enriched credentials update older entries that predate email/org metadata.\n\tfor (const field of OAUTH_ACCOUNT_ID_FIELDS) {\n\t\tconst value = credentials[field];\n\t\tif (typeof value !== \"string\" || !value.trim()) continue;\n\t\tconst normalizedValue = value.trim().toLowerCase();\n\t\tconst orgId = typeof credentials.orgId === \"string\" ? credentials.orgId.trim().toLowerCase() : \"\";\n\t\tconst compatibleMatch = accounts.findIndex((account) => {\n\t\t\tconst existingValue = account[field];\n\t\t\tif (typeof existingValue !== \"string\" || existingValue.trim().toLowerCase() !== normalizedValue) return false;\n\t\t\tconst existingOrgId = typeof account.orgId === \"string\" ? account.orgId.trim().toLowerCase() : \"\";\n\t\t\treturn !orgId || !existingOrgId || orgId === existingOrgId;\n\t\t});\n\t\tif (compatibleMatch >= 0) return compatibleMatch;\n\t}\n\n\treturn accounts.findIndex((account) => account.refresh === credentials.refresh);\n}\n\nexport type AuthStatus = {\n\tconfigured: boolean;\n\tsource?: \"stored\" | \"runtime\" | \"environment\" | \"fallback\" | \"models_json_key\" | \"models_json_command\";\n\tlabel?: string;\n};\n\ntype LockResult<T> = {\n\tresult: T;\n\tnext?: string;\n};\n\nconst AUTH_FILE_WRITE_OPTIONS = { encoding: \"utf-8\", mode: 0o600 } as const;\n\nexport interface AuthStorageBackend {\n\twithLock<T>(fn: (current: string | undefined) => LockResult<T>): T;\n\twithLockAsync<T>(fn: (current: string | undefined) => Promise<LockResult<T>>): Promise<T>;\n}\n\nexport class FileAuthStorageBackend implements AuthStorageBackend {\n\tprivate authPath: string;\n\n\tconstructor(authPath: string = join(getAgentDir(), \"auth.json\")) {\n\t\tthis.authPath = normalizePath(authPath);\n\t}\n\n\tprivate ensureParentDir(): void {\n\t\tconst dir = dirname(this.authPath);\n\t\tif (!existsSync(dir)) {\n\t\t\tmkdirSync(dir, { recursive: true, mode: 0o700 });\n\t\t}\n\t}\n\n\tprivate ensureFileExists(): void {\n\t\tif (!existsSync(this.authPath)) {\n\t\t\twriteFileSync(this.authPath, \"{}\", AUTH_FILE_WRITE_OPTIONS);\n\t\t\tchmodSync(this.authPath, 0o600);\n\t\t}\n\t}\n\n\tprivate acquireLockSyncWithRetry(path: string): () => void {\n\t\tconst maxAttempts = 10;\n\t\tconst delayMs = 20;\n\t\tlet lastError: unknown;\n\n\t\tfor (let attempt = 1; attempt <= maxAttempts; attempt++) {\n\t\t\ttry {\n\t\t\t\treturn lockfile.lockSync(path, { realpath: false });\n\t\t\t} catch (error) {\n\t\t\t\tconst code =\n\t\t\t\t\ttypeof error === \"object\" && error !== null && \"code\" in error\n\t\t\t\t\t\t? String((error as { code?: unknown }).code)\n\t\t\t\t\t\t: undefined;\n\t\t\t\tif (code !== \"ELOCKED\" || attempt === maxAttempts) {\n\t\t\t\t\tthrow error;\n\t\t\t\t}\n\t\t\t\tlastError = error;\n\t\t\t\tconst start = Date.now();\n\t\t\t\twhile (Date.now() - start < delayMs) {\n\t\t\t\t\t// Sleep synchronously to avoid changing callers to async.\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tthrow (lastError as Error) ?? new Error(\"Failed to acquire auth storage lock\");\n\t}\n\n\twithLock<T>(fn: (current: string | undefined) => LockResult<T>): T {\n\t\tthis.ensureParentDir();\n\t\tthis.ensureFileExists();\n\n\t\tlet release: (() => void) | undefined;\n\t\ttry {\n\t\t\trelease = this.acquireLockSyncWithRetry(this.authPath);\n\t\t\tconst current = existsSync(this.authPath) ? readFileSync(this.authPath, \"utf-8\") : undefined;\n\t\t\tconst { result, next } = fn(current);\n\t\t\tif (next !== undefined) {\n\t\t\t\twriteFileSync(this.authPath, next, AUTH_FILE_WRITE_OPTIONS);\n\t\t\t\tchmodSync(this.authPath, 0o600);\n\t\t\t}\n\t\t\treturn result;\n\t\t} finally {\n\t\t\tif (release) {\n\t\t\t\trelease();\n\t\t\t}\n\t\t}\n\t}\n\n\tasync withLockAsync<T>(fn: (current: string | undefined) => Promise<LockResult<T>>): Promise<T> {\n\t\tthis.ensureParentDir();\n\t\tthis.ensureFileExists();\n\n\t\tlet release: (() => Promise<void>) | undefined;\n\t\tlet lockCompromised = false;\n\t\tlet lockCompromisedError: Error | undefined;\n\t\tconst throwIfCompromised = () => {\n\t\t\tif (lockCompromised) {\n\t\t\t\tthrow lockCompromisedError ?? new Error(\"Auth storage lock was compromised\");\n\t\t\t}\n\t\t};\n\n\t\ttry {\n\t\t\trelease = await lockfile.lock(this.authPath, {\n\t\t\t\tretries: {\n\t\t\t\t\tretries: 10,\n\t\t\t\t\tfactor: 2,\n\t\t\t\t\tminTimeout: 100,\n\t\t\t\t\tmaxTimeout: 10000,\n\t\t\t\t\trandomize: true,\n\t\t\t\t},\n\t\t\t\tstale: 30000,\n\t\t\t\tonCompromised: (err) => {\n\t\t\t\t\tlockCompromised = true;\n\t\t\t\t\tlockCompromisedError = err;\n\t\t\t\t},\n\t\t\t});\n\n\t\t\tthrowIfCompromised();\n\t\t\tconst current = existsSync(this.authPath) ? readFileSync(this.authPath, \"utf-8\") : undefined;\n\t\t\tconst { result, next } = await fn(current);\n\t\t\tthrowIfCompromised();\n\t\t\tif (next !== undefined) {\n\t\t\t\twriteFileSync(this.authPath, next, AUTH_FILE_WRITE_OPTIONS);\n\t\t\t\tchmodSync(this.authPath, 0o600);\n\t\t\t}\n\t\t\tthrowIfCompromised();\n\t\t\treturn result;\n\t\t} finally {\n\t\t\tif (release) {\n\t\t\t\ttry {\n\t\t\t\t\tawait release();\n\t\t\t\t} catch {\n\t\t\t\t\t// Ignore unlock errors when lock is compromised.\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nexport class InMemoryAuthStorageBackend implements AuthStorageBackend {\n\tprivate value: string | undefined;\n\n\twithLock<T>(fn: (current: string | undefined) => LockResult<T>): T {\n\t\tconst { result, next } = fn(this.value);\n\t\tif (next !== undefined) {\n\t\t\tthis.value = next;\n\t\t}\n\t\treturn result;\n\t}\n\n\tasync withLockAsync<T>(fn: (current: string | undefined) => Promise<LockResult<T>>): Promise<T> {\n\t\tconst { result, next } = await fn(this.value);\n\t\tif (next !== undefined) {\n\t\t\tthis.value = next;\n\t\t}\n\t\treturn result;\n\t}\n}\n\n/**\n * Credential storage backed by a JSON file.\n */\nexport class AuthStorage {\n\tprivate data: AuthStorageData = {};\n\tprivate runtimeOverrides: Map<string, string> = new Map();\n\tprivate fallbackResolver?: (provider: string) => string | undefined;\n\tprivate loadError: Error | null = null;\n\tprivate errors: Error[] = [];\n\tprivate storage: AuthStorageBackend;\n\n\tprivate constructor(storage: AuthStorageBackend) {\n\t\tthis.storage = storage;\n\t\tthis.reload();\n\t}\n\n\tstatic create(authPath?: string): AuthStorage {\n\t\treturn new AuthStorage(new FileAuthStorageBackend(authPath ?? join(getAgentDir(), \"auth.json\")));\n\t}\n\n\tstatic fromStorage(storage: AuthStorageBackend): AuthStorage {\n\t\treturn new AuthStorage(storage);\n\t}\n\n\tstatic inMemory(data: AuthStorageData = {}): AuthStorage {\n\t\tconst storage = new InMemoryAuthStorageBackend();\n\t\tstorage.withLock(() => ({ result: undefined, next: JSON.stringify(data, null, 2) }));\n\t\treturn AuthStorage.fromStorage(storage);\n\t}\n\n\t/**\n\t * Set a runtime API key override (not persisted to disk).\n\t * Used for CLI --api-key flag.\n\t */\n\tsetRuntimeApiKey(provider: string, apiKey: string): void {\n\t\tthis.runtimeOverrides.set(provider, apiKey);\n\t}\n\n\t/**\n\t * Remove a runtime API key override.\n\t */\n\tremoveRuntimeApiKey(provider: string): void {\n\t\tthis.runtimeOverrides.delete(provider);\n\t}\n\n\t/**\n\t * Set a fallback resolver for API keys not found in auth.json or env vars.\n\t * Used for custom provider keys from models.json.\n\t */\n\tsetFallbackResolver(resolver: (provider: string) => string | undefined): void {\n\t\tthis.fallbackResolver = resolver;\n\t}\n\n\tprivate recordError(error: unknown): void {\n\t\tconst normalizedError = error instanceof Error ? error : new Error(String(error));\n\t\tthis.errors.push(normalizedError);\n\t}\n\n\tprivate parseStorageData(content: string | undefined): AuthStorageData {\n\t\tif (!content) {\n\t\t\treturn {};\n\t\t}\n\t\ttry {\n\t\t\treturn omitRetiredGrokOAuthProxy(JSON.parse(content) as AuthStorageData);\n\t\t} catch (error) {\n\t\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\t\tthrow new Error(`Failed to parse auth storage: ${message}`);\n\t\t}\n\t}\n\n\t/**\n\t * Reload credentials from storage.\n\t */\n\treload(): void {\n\t\tlet content: string | undefined;\n\t\ttry {\n\t\t\tthis.storage.withLock((current) => {\n\t\t\t\tcontent = current;\n\t\t\t\treturn { result: undefined };\n\t\t\t});\n\t\t\tthis.data = this.parseStorageData(content);\n\t\t\tthis.loadError = null;\n\t\t} catch (error) {\n\t\t\tthis.loadError = error as Error;\n\t\t\tthis.recordError(error);\n\t\t}\n\t}\n\n\tprivate persistProviderChange(provider: string, credential: AuthCredential | undefined): void {\n\t\tif (this.loadError) {\n\t\t\treturn;\n\t\t}\n\n\t\ttry {\n\t\t\tthis.storage.withLock((current) => {\n\t\t\t\tconst currentData = this.parseStorageData(current);\n\t\t\t\tconst merged: AuthStorageData = { ...currentData };\n\t\t\t\tif (provider === RETIRED_GROK_OAUTH_PROXY) {\n\t\t\t\t\tdelete merged[provider];\n\t\t\t\t} else if (credential) {\n\t\t\t\t\tmerged[provider] = credential;\n\t\t\t\t} else {\n\t\t\t\t\tdelete merged[provider];\n\t\t\t\t}\n\t\t\t\treturn { result: undefined, next: JSON.stringify(merged, null, 2) };\n\t\t\t});\n\t\t} catch (error) {\n\t\t\tthis.recordError(error);\n\t\t}\n\t}\n\n\t/**\n\t * Get credential for a provider.\n\t */\n\tget(provider: string): AuthCredential | undefined {\n\t\tif (provider === RETIRED_GROK_OAUTH_PROXY) return undefined;\n\t\treturn this.data[provider] ?? undefined;\n\t}\n\n\t/**\n\t * Set credential for a provider.\n\t */\n\tset(provider: string, credential: AuthCredential): void {\n\t\tif (provider === RETIRED_GROK_OAUTH_PROXY) {\n\t\t\tdelete this.data[provider];\n\t\t\tthis.persistProviderChange(provider, undefined);\n\t\t\treturn;\n\t\t}\n\t\tthis.data[provider] = credential;\n\t\tthis.persistProviderChange(provider, credential);\n\t}\n\n\t/**\n\t * Remove credential for a provider.\n\t */\n\tremove(provider: string): void {\n\t\tdelete this.data[provider];\n\t\tthis.persistProviderChange(provider, undefined);\n\t}\n\n\t/**\n\t * List all providers with credentials.\n\t */\n\tlist(): string[] {\n\t\treturn Object.keys(this.data).filter((provider) => provider !== RETIRED_GROK_OAUTH_PROXY);\n\t}\n\n\t/**\n\t * Check if credentials exist for a provider in auth.json.\n\t */\n\thas(provider: string): boolean {\n\t\tif (provider === RETIRED_GROK_OAUTH_PROXY) return false;\n\t\treturn provider in this.data;\n\t}\n\n\t/**\n\t * Check if any form of auth is configured for a provider.\n\t * Unlike getApiKey(), this doesn't refresh OAuth tokens.\n\t */\n\thasAuth(provider: string): boolean {\n\t\tif (provider === RETIRED_GROK_OAUTH_PROXY) return false;\n\t\tif (this.runtimeOverrides.has(provider)) return true;\n\t\tif (this.data[provider]) return true;\n\t\tif (getEnvApiKey(provider)) return true;\n\t\tif (this.fallbackResolver?.(provider)) return true;\n\t\treturn false;\n\t}\n\n\t/** Return the number of stored OAuth accounts for a provider. */\n\tgetOAuthAccountCount(provider: string): number {\n\t\tconst credential = this.data[provider];\n\t\treturn credential?.type === \"oauth\" ? getOAuthAccounts(credential).length : 0;\n\t}\n\n\t/** Return safe, human-readable labels for every stored OAuth account. */\n\tlistOAuthAccounts(provider: string): OAuthAccountSummary[] {\n\t\tconst credential = this.data[provider];\n\t\tif (credential?.type !== \"oauth\") return [];\n\t\tconst accounts = getOAuthAccounts(credential);\n\t\tconst selectedIndex = getSelectedOAuthAccountIndex(credential, accounts.length);\n\t\treturn accounts.map((account, index) => ({\n\t\t\tindex,\n\t\t\tlabel: getOAuthAccountDisplayLabel(provider, account) ?? `Account ${index + 1}`,\n\t\t\tselected: index === selectedIndex,\n\t\t}));\n\t}\n\n\t/** Return the human-readable label of the selected OAuth account. */\n\tgetOAuthAccountLabel(provider: string): string | undefined {\n\t\treturn this.listOAuthAccounts(provider).find((account) => account.selected)?.label;\n\t}\n\n\t/** Explicitly select and persist the OAuth account used by this provider. */\n\tselectOAuthAccount(provider: string, accountIndex: number): void {\n\t\tthis.storage.withLock((current) => {\n\t\t\tconst currentData = this.parseStorageData(current);\n\t\t\tconst credential = currentData[provider];\n\t\t\tif (credential?.type !== \"oauth\") {\n\t\t\t\tthrow new Error(`No OAuth accounts configured for ${provider}`);\n\t\t\t}\n\t\t\tconst accounts = [...getOAuthAccounts(credential)];\n\t\t\tif (!Number.isInteger(accountIndex) || accountIndex < 0 || accountIndex >= accounts.length) {\n\t\t\t\tthrow new Error(`Invalid OAuth account index ${accountIndex} for ${provider}`);\n\t\t\t}\n\t\t\tconst merged: AuthStorageData = {\n\t\t\t\t...currentData,\n\t\t\t\t[provider]: createOAuthCredential(accounts, accountIndex),\n\t\t\t};\n\t\t\tthis.data = merged;\n\t\t\tthis.loadError = null;\n\t\t\treturn { result: undefined, next: JSON.stringify(merged, null, 2) };\n\t\t});\n\t}\n\n\t/**\n\t * Return the explicitly selected OAuth credentials without refreshing tokens.\n\t * Used by provider model customization hooks.\n\t */\n\tgetOAuthCredentials(provider: string): OAuthCredentials | undefined {\n\t\tconst credential = this.data[provider];\n\t\tif (credential?.type !== \"oauth\") {\n\t\t\treturn undefined;\n\t\t}\n\t\tconst accounts = getOAuthAccounts(credential);\n\t\treturn accounts[getSelectedOAuthAccountIndex(credential, accounts.length)];\n\t}\n\n\t/**\n\t * Return auth status without exposing credential values or refreshing tokens.\n\t */\n\tgetAuthStatus(provider: string): AuthStatus {\n\t\tif (provider === RETIRED_GROK_OAUTH_PROXY) {\n\t\t\treturn { configured: false };\n\t\t}\n\t\tif (this.data[provider]) {\n\t\t\treturn { configured: true, source: \"stored\" };\n\t\t}\n\n\t\tif (this.runtimeOverrides.has(provider)) {\n\t\t\treturn { configured: false, source: \"runtime\", label: \"--api-key\" };\n\t\t}\n\n\t\tconst envKeys = findEnvKeys(provider);\n\t\tif (envKeys?.[0]) {\n\t\t\treturn { configured: false, source: \"environment\", label: envKeys[0] };\n\t\t}\n\n\t\tif (this.fallbackResolver?.(provider)) {\n\t\t\treturn { configured: false, source: \"fallback\", label: \"custom provider config\" };\n\t\t}\n\n\t\treturn { configured: false };\n\t}\n\n\t/**\n\t * Get all credentials (for passing to getOAuthApiKey).\n\t */\n\tgetAll(): AuthStorageData {\n\t\treturn omitRetiredGrokOAuthProxy({ ...this.data });\n\t}\n\n\tdrainErrors(): Error[] {\n\t\tconst drained = [...this.errors];\n\t\tthis.errors = [];\n\t\treturn drained;\n\t}\n\n\t/**\n\t * Login to an OAuth provider. Repeated logins append distinct accounts and\n\t * update an existing account when the provider exposes a stable identity.\n\t */\n\tasync login(providerId: OAuthProviderId, callbacks: OAuthLoginCallbacks): Promise<void> {\n\t\tconst provider = getOAuthProvider(providerId);\n\t\tif (!provider) {\n\t\t\tthrow new Error(`Unknown OAuth provider: ${providerId}`);\n\t\t}\n\n\t\tconst credentials = await provider.login(callbacks);\n\t\tif (this.loadError) {\n\t\t\tthis.data[providerId] = { type: \"oauth\", ...credentials };\n\t\t\treturn;\n\t\t}\n\n\t\ttry {\n\t\t\tthis.storage.withLock((current) => {\n\t\t\t\tconst currentData = this.parseStorageData(current);\n\t\t\t\tconst existing = currentData[providerId];\n\t\t\t\tlet credential: OAuthCredential;\n\n\t\t\t\tif (existing?.type === \"oauth\") {\n\t\t\t\t\tconst accounts = [...getOAuthAccounts(existing)];\n\t\t\t\t\tconst matchingIndex = findMatchingOAuthAccount(accounts, credentials);\n\t\t\t\t\tlet activeAccount: number;\n\t\t\t\t\tif (matchingIndex >= 0) {\n\t\t\t\t\t\taccounts[matchingIndex] = credentials;\n\t\t\t\t\t\tactiveAccount = matchingIndex;\n\t\t\t\t\t} else {\n\t\t\t\t\t\taccounts.push(credentials);\n\t\t\t\t\t\tactiveAccount = accounts.length - 1;\n\t\t\t\t\t}\n\t\t\t\t\tcredential = createOAuthCredential(accounts, activeAccount);\n\t\t\t\t} else {\n\t\t\t\t\tcredential = createOAuthCredential([credentials]);\n\t\t\t\t}\n\n\t\t\t\tconst merged: AuthStorageData = { ...currentData, [providerId]: credential };\n\t\t\t\tthis.data = merged;\n\t\t\t\tthis.loadError = null;\n\t\t\t\treturn { result: undefined, next: JSON.stringify(merged, null, 2) };\n\t\t\t});\n\t\t} catch (error) {\n\t\t\tthis.recordError(error);\n\t\t}\n\t}\n\n\t/**\n\t * Logout from a provider.\n\t */\n\tlogout(provider: string): void {\n\t\tthis.remove(provider);\n\t}\n\n\t/**\n\t * Resolve the explicitly selected account and refresh it under the storage lock.\n\t *\n\t * `minRemainingMs` lets a long operation demand headroom. An access token is\n\t * resolved once and then reused for the whole request, so a token that is\n\t * merely \"not expired yet\" can still expire mid-flight and come back as a\n\t * provider 401. Callers that run for minutes (compaction) ask for a margin.\n\t */\n\tprivate async resolveOAuthTokenWithLock(\n\t\tproviderId: OAuthProviderId,\n\t\tminRemainingMs = 0,\n\t\trejectedApiKey?: string,\n\t): Promise<{ apiKey: string; newCredentials: OAuthCredentials } | null> {\n\t\tconst provider = getOAuthProvider(providerId);\n\t\tif (!provider) {\n\t\t\treturn null;\n\t\t}\n\n\t\treturn this.storage.withLockAsync(async (current) => {\n\t\t\tconst currentData = this.parseStorageData(current);\n\t\t\tthis.data = currentData;\n\t\t\tthis.loadError = null;\n\n\t\t\tconst credential = currentData[providerId];\n\t\t\tif (credential?.type !== \"oauth\") {\n\t\t\t\treturn { result: null };\n\t\t\t}\n\n\t\t\tconst accounts = [...getOAuthAccounts(credential)];\n\t\t\tconst accountIndex = getSelectedOAuthAccountIndex(credential, accounts.length);\n\t\t\tconst account = accounts[accountIndex];\n\t\t\tif (!account) return { result: null };\n\n\t\t\tconst resolved = await this.resolveAccountToken(provider, providerId, account, minRemainingMs, rejectedApiKey);\n\t\t\tif (!resolved) return { result: null };\n\n\t\t\tif (resolved.newCredentials === account) {\n\t\t\t\treturn { result: resolved };\n\t\t\t}\n\n\t\t\tconst newCredentials = { ...account, ...resolved.newCredentials };\n\t\t\taccounts[accountIndex] = newCredentials;\n\t\t\tconst merged: AuthStorageData = {\n\t\t\t\t...currentData,\n\t\t\t\t[providerId]: createOAuthCredential(accounts, accountIndex),\n\t\t\t};\n\t\t\tthis.data = merged;\n\t\t\treturn {\n\t\t\t\tresult: { ...resolved, newCredentials },\n\t\t\t\tnext: JSON.stringify(merged, null, 2),\n\t\t\t};\n\t\t});\n\t}\n\n\t/**\n\t * Force-refresh the selected account after the provider rejected `rejectedApiKey`.\n\t *\n\t * Local expiry is not the last word: ChatGPT/Codex answers `401 token_expired`\n\t * for a token whose JWT `exp` and our stored `expires` still lie days ahead\n\t * (server-side revocation, rotation from another login). Refreshing only on\n\t * the local clock would keep replaying the dead token until that date.\n\t *\n\t * Several omk processes share auth.json. If the stored token no longer equals\n\t * the rejected one, another process already rotated it and the current token\n\t * is returned without a second refresh, which would only burn the new refresh\n\t * token. A failed refresh propagates so the caller can ask for `/login`; the\n\t * credential is left in place for that login to replace.\n\t */\n\tasync refreshRejectedOAuthToken(providerId: OAuthProviderId, rejectedApiKey: string): Promise<string | undefined> {\n\t\treturn (await this.resolveOAuthTokenWithLock(providerId, 0, rejectedApiKey))?.apiKey;\n\t}\n\n\t/**\n\t * Token for one account, refreshing when it is expired or too close to it.\n\t *\n\t * Hard expiry must refresh, and a refresh failure there is fatal. A margin\n\t * miss is only a precaution: the token is still valid, so a failed proactive\n\t * refresh falls back to it instead of breaking a working session. A token the\n\t * provider already rejected (`rejectedApiKey`) is refreshed unconditionally,\n\t * and that failure is fatal too.\n\t */\n\tprivate async resolveAccountToken(\n\t\tprovider: OAuthProviderInterface,\n\t\tproviderId: OAuthProviderId,\n\t\taccount: OAuthCredentials,\n\t\tminRemainingMs: number,\n\t\trejectedApiKey?: string,\n\t): Promise<{ apiKey: string; newCredentials: OAuthCredentials } | null> {\n\t\tconst now = Date.now();\n\t\tif (now >= account.expires) {\n\t\t\treturn await getOAuthApiKey(providerId, { [providerId]: account });\n\t\t}\n\t\tif (rejectedApiKey !== undefined && provider.getApiKey(account) === rejectedApiKey) {\n\t\t\tconst refreshed = await provider.refreshToken(account);\n\t\t\treturn { apiKey: provider.getApiKey(refreshed), newCredentials: refreshed };\n\t\t}\n\t\tif (minRemainingMs > 0 && now + minRemainingMs >= account.expires) {\n\t\t\ttry {\n\t\t\t\tconst refreshed = await provider.refreshToken(account);\n\t\t\t\treturn { apiKey: provider.getApiKey(refreshed), newCredentials: refreshed };\n\t\t\t} catch {\n\t\t\t\t// Still valid right now; prefer a usable token over a hard failure.\n\t\t\t}\n\t\t}\n\t\treturn { apiKey: provider.getApiKey(account), newCredentials: account };\n\t}\n\n\t/**\n\t * Get API key for a provider.\n\t * Priority:\n\t * 1. Runtime override (CLI --api-key)\n\t * 2. API key from auth.json\n\t * 3. OAuth token from auth.json (auto-refreshed with locking)\n\t * 4. Environment variable\n\t * 5. Fallback resolver (models.json custom providers)\n\t */\n\tasync getApiKey(\n\t\tproviderId: string,\n\t\toptions?: { includeFallback?: boolean; minRemainingMs?: number },\n\t): Promise<string | undefined> {\n\t\t// Runtime override takes highest priority\n\t\tconst runtimeKey = this.runtimeOverrides.get(providerId);\n\t\tif (runtimeKey) {\n\t\t\treturn runtimeKey;\n\t\t}\n\n\t\tconst cred = this.data[providerId];\n\n\t\tif (cred?.type === \"api_key\") {\n\t\t\treturn resolveConfigValue(cred.key);\n\t\t}\n\n\t\tif (cred?.type === \"oauth\") {\n\t\t\ttry {\n\t\t\t\tconst result = await this.resolveOAuthTokenWithLock(providerId, options?.minRemainingMs ?? 0);\n\t\t\t\tif (result) {\n\t\t\t\t\treturn result.apiKey;\n\t\t\t\t}\n\t\t\t} catch (error) {\n\t\t\t\tthis.recordError(error);\n\t\t\t\t// Credentials are preserved so a later request or /login can recover.\n\t\t\t\tthis.reload();\n\t\t\t\treturn undefined;\n\t\t\t}\n\t\t}\n\n\t\t// Fall back to environment variable\n\t\tconst envKey = getEnvApiKey(providerId);\n\t\tif (envKey) return envKey;\n\n\t\t// Fall back to custom resolver (e.g., models.json custom providers)\n\t\tif (options?.includeFallback !== false) {\n\t\t\treturn this.fallbackResolver?.(providerId) ?? undefined;\n\t\t}\n\n\t\treturn undefined;\n\t}\n\n\t/**\n\t * Get all registered OAuth providers\n\t */\n\tgetOAuthProviders() {\n\t\treturn getOAuthProviders();\n\t}\n}\n"]}
1
+ {"version":3,"file":"auth-storage.d.ts","sourceRoot":"","sources":["../../src/core/auth-storage.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Credential storage for API keys and OAuth tokens.\n * Handles loading, saving, and refreshing credentials from auth.json.\n *\n * Uses file locking to prevent race conditions when multiple OMK instances\n * try to refresh tokens simultaneously.\n */\n\nimport { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from \"fs\";\nimport {\n\tfindEnvKeys,\n\tgetEnvApiKey,\n\ttype OAuthCredentials,\n\ttype OAuthLoginCallbacks,\n\ttype OAuthProviderId,\n\ttype OAuthProviderInterface,\n} from \"omk-ai\";\nimport { getOAuthApiKey, getOAuthProvider, getOAuthProviders } from \"omk-ai/oauth\";\nimport { dirname, join } from \"path\";\nimport lockfile from \"proper-lockfile\";\nimport { getAgentDir } from \"../config.ts\";\nimport { normalizePath } from \"../utils/paths.ts\";\nimport {\n\tfindMatchingOAuthAccount,\n\tfindOAuthAccountMatch,\n\tgetOAuthAccountDisplayLabel,\n\tmergeImportedCredential,\n\ttype OAuthAccountImport,\n\ttype OAuthAccountSummary,\n\tpreviewOAuthAccountImport,\n} from \"./oauth-account-import.ts\";\nimport {\n\tcreateOAuthCredential,\n\tgetOAuthAccounts,\n\tgetSelectedOAuthAccountIndex,\n\ttype OAuthCredential,\n} from \"./oauth-credential.ts\";\nimport { resolveConfigValue } from \"./resolve-config-value.ts\";\n\nconst RETIRED_GROK_OAUTH_PROXY = \"grok-oauth-proxy\";\n\nfunction omitRetiredGrokOAuthProxy(data: AuthStorageData): AuthStorageData {\n\tif (!(RETIRED_GROK_OAUTH_PROXY in data)) return data;\n\tconst { [RETIRED_GROK_OAUTH_PROXY]: _retired, ...kept } = data;\n\treturn kept;\n}\n\nexport type ApiKeyCredential = {\n\ttype: \"api_key\";\n\tkey: string;\n};\n\nexport type { OAuthAccountImport, OAuthAccountSummary } from \"./oauth-account-import.ts\";\nexport type { OAuthCredential } from \"./oauth-credential.ts\";\n\nexport type AuthCredential = ApiKeyCredential | OAuthCredential;\n\nexport type AuthStorageData = Record<string, AuthCredential>;\n\nexport type AuthStatus = {\n\tconfigured: boolean;\n\tsource?: \"stored\" | \"runtime\" | \"environment\" | \"fallback\" | \"models_json_key\" | \"models_json_command\";\n\tlabel?: string;\n};\n\ntype LockResult<T> = {\n\tresult: T;\n\tnext?: string;\n};\n\nconst AUTH_FILE_WRITE_OPTIONS = { encoding: \"utf-8\", mode: 0o600 } as const;\n\nexport interface AuthStorageBackend {\n\twithLock<T>(fn: (current: string | undefined) => LockResult<T>): T;\n\twithLockAsync<T>(fn: (current: string | undefined) => Promise<LockResult<T>>): Promise<T>;\n}\n\nexport class FileAuthStorageBackend implements AuthStorageBackend {\n\tprivate authPath: string;\n\n\tconstructor(authPath: string = join(getAgentDir(), \"auth.json\")) {\n\t\tthis.authPath = normalizePath(authPath);\n\t}\n\n\tprivate ensureParentDir(): void {\n\t\tconst dir = dirname(this.authPath);\n\t\tif (!existsSync(dir)) {\n\t\t\tmkdirSync(dir, { recursive: true, mode: 0o700 });\n\t\t}\n\t}\n\n\tprivate ensureFileExists(): void {\n\t\tif (!existsSync(this.authPath)) {\n\t\t\twriteFileSync(this.authPath, \"{}\", AUTH_FILE_WRITE_OPTIONS);\n\t\t\tchmodSync(this.authPath, 0o600);\n\t\t}\n\t}\n\n\tprivate acquireLockSyncWithRetry(path: string): () => void {\n\t\tconst maxAttempts = 10;\n\t\tconst delayMs = 20;\n\t\tlet lastError: unknown;\n\n\t\tfor (let attempt = 1; attempt <= maxAttempts; attempt++) {\n\t\t\ttry {\n\t\t\t\treturn lockfile.lockSync(path, { realpath: false });\n\t\t\t} catch (error) {\n\t\t\t\tconst code =\n\t\t\t\t\ttypeof error === \"object\" && error !== null && \"code\" in error\n\t\t\t\t\t\t? String((error as { code?: unknown }).code)\n\t\t\t\t\t\t: undefined;\n\t\t\t\tif (code !== \"ELOCKED\" || attempt === maxAttempts) {\n\t\t\t\t\tthrow error;\n\t\t\t\t}\n\t\t\t\tlastError = error;\n\t\t\t\tconst start = Date.now();\n\t\t\t\twhile (Date.now() - start < delayMs) {\n\t\t\t\t\t// Sleep synchronously to avoid changing callers to async.\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tthrow (lastError as Error) ?? new Error(\"Failed to acquire auth storage lock\");\n\t}\n\n\twithLock<T>(fn: (current: string | undefined) => LockResult<T>): T {\n\t\tthis.ensureParentDir();\n\t\tthis.ensureFileExists();\n\n\t\tlet release: (() => void) | undefined;\n\t\ttry {\n\t\t\trelease = this.acquireLockSyncWithRetry(this.authPath);\n\t\t\tconst current = existsSync(this.authPath) ? readFileSync(this.authPath, \"utf-8\") : undefined;\n\t\t\tconst { result, next } = fn(current);\n\t\t\tif (next !== undefined) {\n\t\t\t\twriteFileSync(this.authPath, next, AUTH_FILE_WRITE_OPTIONS);\n\t\t\t\tchmodSync(this.authPath, 0o600);\n\t\t\t}\n\t\t\treturn result;\n\t\t} finally {\n\t\t\tif (release) {\n\t\t\t\trelease();\n\t\t\t}\n\t\t}\n\t}\n\n\tasync withLockAsync<T>(fn: (current: string | undefined) => Promise<LockResult<T>>): Promise<T> {\n\t\tthis.ensureParentDir();\n\t\tthis.ensureFileExists();\n\n\t\tlet release: (() => Promise<void>) | undefined;\n\t\tlet lockCompromised = false;\n\t\tlet lockCompromisedError: Error | undefined;\n\t\tconst throwIfCompromised = () => {\n\t\t\tif (lockCompromised) {\n\t\t\t\tthrow lockCompromisedError ?? new Error(\"Auth storage lock was compromised\");\n\t\t\t}\n\t\t};\n\n\t\ttry {\n\t\t\trelease = await lockfile.lock(this.authPath, {\n\t\t\t\tretries: {\n\t\t\t\t\tretries: 10,\n\t\t\t\t\tfactor: 2,\n\t\t\t\t\tminTimeout: 100,\n\t\t\t\t\tmaxTimeout: 10000,\n\t\t\t\t\trandomize: true,\n\t\t\t\t},\n\t\t\t\tstale: 30000,\n\t\t\t\tonCompromised: (err) => {\n\t\t\t\t\tlockCompromised = true;\n\t\t\t\t\tlockCompromisedError = err;\n\t\t\t\t},\n\t\t\t});\n\n\t\t\tthrowIfCompromised();\n\t\t\tconst current = existsSync(this.authPath) ? readFileSync(this.authPath, \"utf-8\") : undefined;\n\t\t\tconst { result, next } = await fn(current);\n\t\t\tthrowIfCompromised();\n\t\t\tif (next !== undefined) {\n\t\t\t\twriteFileSync(this.authPath, next, AUTH_FILE_WRITE_OPTIONS);\n\t\t\t\tchmodSync(this.authPath, 0o600);\n\t\t\t}\n\t\t\tthrowIfCompromised();\n\t\t\treturn result;\n\t\t} finally {\n\t\t\tif (release) {\n\t\t\t\ttry {\n\t\t\t\t\tawait release();\n\t\t\t\t} catch {\n\t\t\t\t\t// Ignore unlock errors when lock is compromised.\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nexport class InMemoryAuthStorageBackend implements AuthStorageBackend {\n\tprivate value: string | undefined;\n\n\twithLock<T>(fn: (current: string | undefined) => LockResult<T>): T {\n\t\tconst { result, next } = fn(this.value);\n\t\tif (next !== undefined) {\n\t\t\tthis.value = next;\n\t\t}\n\t\treturn result;\n\t}\n\n\tasync withLockAsync<T>(fn: (current: string | undefined) => Promise<LockResult<T>>): Promise<T> {\n\t\tconst { result, next } = await fn(this.value);\n\t\tif (next !== undefined) {\n\t\t\tthis.value = next;\n\t\t}\n\t\treturn result;\n\t}\n}\n\n/**\n * Credential storage backed by a JSON file.\n */\nexport class AuthStorage {\n\tprivate data: AuthStorageData = {};\n\tprivate runtimeOverrides: Map<string, string> = new Map();\n\tprivate fallbackResolver?: (provider: string) => string | undefined;\n\tprivate loadError: Error | null = null;\n\tprivate errors: Error[] = [];\n\tprivate storage: AuthStorageBackend;\n\n\tprivate constructor(storage: AuthStorageBackend) {\n\t\tthis.storage = storage;\n\t\tthis.reload();\n\t}\n\n\tstatic create(authPath?: string): AuthStorage {\n\t\treturn new AuthStorage(new FileAuthStorageBackend(authPath ?? join(getAgentDir(), \"auth.json\")));\n\t}\n\n\tstatic fromStorage(storage: AuthStorageBackend): AuthStorage {\n\t\treturn new AuthStorage(storage);\n\t}\n\n\tstatic inMemory(data: AuthStorageData = {}): AuthStorage {\n\t\tconst storage = new InMemoryAuthStorageBackend();\n\t\tstorage.withLock(() => ({ result: undefined, next: JSON.stringify(data, null, 2) }));\n\t\treturn AuthStorage.fromStorage(storage);\n\t}\n\n\t/**\n\t * Set a runtime API key override (not persisted to disk).\n\t * Used for CLI --api-key flag.\n\t */\n\tsetRuntimeApiKey(provider: string, apiKey: string): void {\n\t\tthis.runtimeOverrides.set(provider, apiKey);\n\t}\n\n\t/**\n\t * Remove a runtime API key override.\n\t */\n\tremoveRuntimeApiKey(provider: string): void {\n\t\tthis.runtimeOverrides.delete(provider);\n\t}\n\n\t/**\n\t * Set a fallback resolver for API keys not found in auth.json or env vars.\n\t * Used for custom provider keys from models.json.\n\t */\n\tsetFallbackResolver(resolver: (provider: string) => string | undefined): void {\n\t\tthis.fallbackResolver = resolver;\n\t}\n\n\tprivate recordError(error: unknown): void {\n\t\tconst normalizedError = error instanceof Error ? error : new Error(String(error));\n\t\tthis.errors.push(normalizedError);\n\t}\n\n\tprivate parseStorageData(content: string | undefined): AuthStorageData {\n\t\tif (!content) {\n\t\t\treturn {};\n\t\t}\n\t\ttry {\n\t\t\treturn omitRetiredGrokOAuthProxy(JSON.parse(content) as AuthStorageData);\n\t\t} catch (error) {\n\t\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\t\tthrow new Error(`Failed to parse auth storage: ${message}`);\n\t\t}\n\t}\n\n\t/**\n\t * Reload credentials from storage.\n\t */\n\treload(): void {\n\t\tlet content: string | undefined;\n\t\ttry {\n\t\t\tthis.storage.withLock((current) => {\n\t\t\t\tcontent = current;\n\t\t\t\treturn { result: undefined };\n\t\t\t});\n\t\t\tthis.data = this.parseStorageData(content);\n\t\t\tthis.loadError = null;\n\t\t} catch (error) {\n\t\t\tthis.loadError = error as Error;\n\t\t\tthis.recordError(error);\n\t\t}\n\t}\n\n\tprivate persistProviderChange(provider: string, credential: AuthCredential | undefined): void {\n\t\tif (this.loadError) {\n\t\t\treturn;\n\t\t}\n\n\t\ttry {\n\t\t\tthis.storage.withLock((current) => {\n\t\t\t\tconst currentData = this.parseStorageData(current);\n\t\t\t\tconst merged: AuthStorageData = { ...currentData };\n\t\t\t\tif (provider === RETIRED_GROK_OAUTH_PROXY) {\n\t\t\t\t\tdelete merged[provider];\n\t\t\t\t} else if (credential) {\n\t\t\t\t\tmerged[provider] = credential;\n\t\t\t\t} else {\n\t\t\t\t\tdelete merged[provider];\n\t\t\t\t}\n\t\t\t\treturn { result: undefined, next: JSON.stringify(merged, null, 2) };\n\t\t\t});\n\t\t} catch (error) {\n\t\t\tthis.recordError(error);\n\t\t}\n\t}\n\n\t/**\n\t * Get credential for a provider.\n\t */\n\tget(provider: string): AuthCredential | undefined {\n\t\tif (provider === RETIRED_GROK_OAUTH_PROXY) return undefined;\n\t\treturn this.data[provider] ?? undefined;\n\t}\n\n\t/**\n\t * Set credential for a provider.\n\t */\n\tset(provider: string, credential: AuthCredential): void {\n\t\tif (provider === RETIRED_GROK_OAUTH_PROXY) {\n\t\t\tdelete this.data[provider];\n\t\t\tthis.persistProviderChange(provider, undefined);\n\t\t\treturn;\n\t\t}\n\t\tthis.data[provider] = credential;\n\t\tthis.persistProviderChange(provider, credential);\n\t}\n\n\t/**\n\t * Remove credential for a provider.\n\t */\n\tremove(provider: string): void {\n\t\tdelete this.data[provider];\n\t\tthis.persistProviderChange(provider, undefined);\n\t}\n\n\t/**\n\t * List all providers with credentials.\n\t */\n\tlist(): string[] {\n\t\treturn Object.keys(this.data).filter((provider) => provider !== RETIRED_GROK_OAUTH_PROXY);\n\t}\n\n\t/**\n\t * Check if credentials exist for a provider in auth.json.\n\t */\n\thas(provider: string): boolean {\n\t\tif (provider === RETIRED_GROK_OAUTH_PROXY) return false;\n\t\treturn provider in this.data;\n\t}\n\n\t/**\n\t * Check if any form of auth is configured for a provider.\n\t * Unlike getApiKey(), this doesn't refresh OAuth tokens.\n\t */\n\thasAuth(provider: string): boolean {\n\t\tif (provider === RETIRED_GROK_OAUTH_PROXY) return false;\n\t\tif (this.runtimeOverrides.has(provider)) return true;\n\t\tif (this.data[provider]) return true;\n\t\tif (getEnvApiKey(provider)) return true;\n\t\tif (this.fallbackResolver?.(provider)) return true;\n\t\treturn false;\n\t}\n\n\t/** Return the number of stored OAuth accounts for a provider. */\n\tgetOAuthAccountCount(provider: string): number {\n\t\tconst credential = this.data[provider];\n\t\treturn credential?.type === \"oauth\" ? getOAuthAccounts(credential).length : 0;\n\t}\n\n\t/** Return safe, human-readable labels for every stored OAuth account. */\n\tlistOAuthAccounts(provider: string): OAuthAccountSummary[] {\n\t\tconst credential = this.data[provider];\n\t\tif (credential?.type !== \"oauth\") return [];\n\t\tconst accounts = getOAuthAccounts(credential);\n\t\tconst selectedIndex = getSelectedOAuthAccountIndex(credential, accounts.length);\n\t\treturn accounts.map((account, index) => ({\n\t\t\tindex,\n\t\t\tlabel: getOAuthAccountDisplayLabel(provider, account) ?? `Account ${index + 1}`,\n\t\t\tselected: index === selectedIndex,\n\t\t}));\n\t}\n\n\t/** Return the human-readable label of the selected OAuth account. */\n\tgetOAuthAccountLabel(provider: string): string | undefined {\n\t\treturn this.listOAuthAccounts(provider).find((account) => account.selected)?.label;\n\t}\n\n\t/**\n\t * Explicitly select and persist the OAuth account used by this provider.\n\t */\n\tselectOAuthAccount(provider: string, accountIndex: number): void {\n\t\tthis.storage.withLock((current) => {\n\t\t\tconst currentData = this.parseStorageData(current);\n\t\t\tconst credential = currentData[provider];\n\t\t\tif (credential?.type !== \"oauth\") {\n\t\t\t\tthrow new Error(`No OAuth accounts configured for ${provider}`);\n\t\t\t}\n\t\t\tconst accounts = [...getOAuthAccounts(credential)];\n\t\t\tif (!Number.isInteger(accountIndex) || accountIndex < 0 || accountIndex >= accounts.length) {\n\t\t\t\tthrow new Error(`Invalid OAuth account index ${accountIndex} for ${provider}`);\n\t\t\t}\n\t\t\tconst merged: AuthStorageData = {\n\t\t\t\t...currentData,\n\t\t\t\t[provider]: createOAuthCredential(accounts, accountIndex),\n\t\t\t};\n\t\t\tthis.data = merged;\n\t\t\tthis.loadError = null;\n\t\t\treturn { result: undefined, next: JSON.stringify(merged, null, 2) };\n\t\t});\n\t}\n\n\t/** Match, merge and write inside the storage lock (`omk provider adopt`). The active selection never moves. */\n\taddOAuthAccount(provider: string, credentials: OAuthCredentials): OAuthAccountImport {\n\t\tif (this.loadError) {\n\t\t\treturn { action: \"blocked\", reason: \"the credential store could not be read\" };\n\t\t}\n\n\t\ttry {\n\t\t\treturn this.storage.withLock<OAuthAccountImport>((current) => {\n\t\t\t\tconst currentData = this.parseStorageData(current);\n\t\t\t\tconst existing = currentData[provider];\n\t\t\t\tconst accounts = existing?.type === \"oauth\" ? [...getOAuthAccounts(existing)] : [];\n\t\t\t\tconst activeIndex =\n\t\t\t\t\texisting?.type === \"oauth\" ? getSelectedOAuthAccountIndex(existing, accounts.length) : 0;\n\t\t\t\tconst matchIndex = findOAuthAccountMatch(accounts, credentials);\n\n\t\t\t\tif (matchIndex >= 0) {\n\t\t\t\t\tconst current_ = accounts[matchIndex];\n\t\t\t\t\tif (current_ && typeof current_.expires === \"number\" && current_.expires > Date.now()) {\n\t\t\t\t\t\tthis.data = currentData;\n\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\tresult: {\n\t\t\t\t\t\t\t\taction: \"unchanged\",\n\t\t\t\t\t\t\t\taccountIndex: matchIndex,\n\t\t\t\t\t\t\t\treason: \"the stored account is still valid\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t};\n\t\t\t\t\t}\n\t\t\t\t\taccounts[matchIndex] = mergeImportedCredential(current_, credentials);\n\t\t\t\t\tconst merged: AuthStorageData = {\n\t\t\t\t\t\t...currentData,\n\t\t\t\t\t\t[provider]: createOAuthCredential(accounts, accounts.length === 1 ? 0 : activeIndex),\n\t\t\t\t\t};\n\t\t\t\t\tthis.data = merged;\n\t\t\t\t\tthis.loadError = null;\n\t\t\t\t\treturn {\n\t\t\t\t\t\tresult: { action: \"updated\", accountIndex: matchIndex },\n\t\t\t\t\t\tnext: JSON.stringify(merged, null, 2),\n\t\t\t\t\t};\n\t\t\t\t}\n\n\t\t\t\taccounts.push(credentials);\n\t\t\t\tconst merged: AuthStorageData = {\n\t\t\t\t\t...currentData,\n\t\t\t\t\t[provider]: createOAuthCredential(accounts, accounts.length === 1 ? 0 : activeIndex),\n\t\t\t\t};\n\t\t\t\tthis.data = merged;\n\t\t\t\tthis.loadError = null;\n\t\t\t\treturn {\n\t\t\t\t\tresult: { action: \"imported\", accountIndex: accounts.length - 1 },\n\t\t\t\t\tnext: JSON.stringify(merged, null, 2),\n\t\t\t\t};\n\t\t\t});\n\t\t} catch (error) {\n\t\t\tthis.recordError(error);\n\t\t\treturn {\n\t\t\t\taction: \"blocked\",\n\t\t\t\treason: `the credential store could not be written: ${error instanceof Error ? error.message : error}`,\n\t\t\t};\n\t\t}\n\t}\n\n\t/** Predict what `addOAuthAccount` would do, without writing. */\n\tpreviewOAuthAccountImport(provider: string, credentials: OAuthCredentials): OAuthAccountImport {\n\t\tconst existing = this.data[provider];\n\t\treturn previewOAuthAccountImport({\n\t\t\tloadError: this.loadError !== null,\n\t\t\texisting,\n\t\t\taccounts: existing?.type === \"oauth\" ? getOAuthAccounts(existing) : [],\n\t\t\tcredentials,\n\t\t});\n\t}\n\n\t/**\n\t * Return the explicitly selected OAuth credentials without refreshing tokens.\n\t * Used by provider model customization hooks.\n\t */\n\tgetOAuthCredentials(provider: string): OAuthCredentials | undefined {\n\t\tconst credential = this.data[provider];\n\t\tif (credential?.type !== \"oauth\") {\n\t\t\treturn undefined;\n\t\t}\n\t\tconst accounts = getOAuthAccounts(credential);\n\t\treturn accounts[getSelectedOAuthAccountIndex(credential, accounts.length)];\n\t}\n\n\t/**\n\t * Return auth status without exposing credential values or refreshing tokens.\n\t */\n\tgetAuthStatus(provider: string): AuthStatus {\n\t\tif (provider === RETIRED_GROK_OAUTH_PROXY) {\n\t\t\treturn { configured: false };\n\t\t}\n\t\tif (this.data[provider]) {\n\t\t\treturn { configured: true, source: \"stored\" };\n\t\t}\n\n\t\tif (this.runtimeOverrides.has(provider)) {\n\t\t\treturn { configured: false, source: \"runtime\", label: \"--api-key\" };\n\t\t}\n\n\t\tconst envKeys = findEnvKeys(provider);\n\t\tif (envKeys?.[0]) {\n\t\t\treturn { configured: false, source: \"environment\", label: envKeys[0] };\n\t\t}\n\n\t\tif (this.fallbackResolver?.(provider)) {\n\t\t\treturn { configured: false, source: \"fallback\", label: \"custom provider config\" };\n\t\t}\n\n\t\treturn { configured: false };\n\t}\n\n\t/**\n\t * Get all credentials (for passing to getOAuthApiKey).\n\t */\n\tgetAll(): AuthStorageData {\n\t\treturn omitRetiredGrokOAuthProxy({ ...this.data });\n\t}\n\n\tdrainErrors(): Error[] {\n\t\tconst drained = [...this.errors];\n\t\tthis.errors = [];\n\t\treturn drained;\n\t}\n\n\t/**\n\t * True when the credential store could not be read (for example, lock contention with another\n\t * session). Callers must not substitute a different credential source while this is set.\n\t */\n\thasLoadError(): boolean {\n\t\treturn this.loadError !== null;\n\t}\n\n\t/**\n\t * Login to an OAuth provider. Repeated logins append distinct accounts and\n\t * update an existing account when the provider exposes a stable identity.\n\t */\n\tasync login(providerId: OAuthProviderId, callbacks: OAuthLoginCallbacks): Promise<void> {\n\t\tconst provider = getOAuthProvider(providerId);\n\t\tif (!provider) {\n\t\t\tthrow new Error(`Unknown OAuth provider: ${providerId}`);\n\t\t}\n\n\t\tconst credentials = await provider.login(callbacks);\n\t\tif (this.loadError) {\n\t\t\tthis.data[providerId] = { type: \"oauth\", ...credentials };\n\t\t\treturn;\n\t\t}\n\n\t\ttry {\n\t\t\tthis.storage.withLock((current) => {\n\t\t\t\tconst currentData = this.parseStorageData(current);\n\t\t\t\tconst existing = currentData[providerId];\n\t\t\t\tlet credential: OAuthCredential;\n\n\t\t\t\tif (existing?.type === \"oauth\") {\n\t\t\t\t\tconst accounts = [...getOAuthAccounts(existing)];\n\t\t\t\t\tconst matchingIndex = findMatchingOAuthAccount(accounts, credentials);\n\t\t\t\t\tlet activeAccount: number;\n\t\t\t\t\tif (matchingIndex >= 0) {\n\t\t\t\t\t\taccounts[matchingIndex] = credentials;\n\t\t\t\t\t\tactiveAccount = matchingIndex;\n\t\t\t\t\t} else {\n\t\t\t\t\t\taccounts.push(credentials);\n\t\t\t\t\t\tactiveAccount = accounts.length - 1;\n\t\t\t\t\t}\n\t\t\t\t\tcredential = createOAuthCredential(accounts, activeAccount);\n\t\t\t\t} else {\n\t\t\t\t\tcredential = createOAuthCredential([credentials]);\n\t\t\t\t}\n\n\t\t\t\tconst merged: AuthStorageData = { ...currentData, [providerId]: credential };\n\t\t\t\tthis.data = merged;\n\t\t\t\tthis.loadError = null;\n\t\t\t\treturn { result: undefined, next: JSON.stringify(merged, null, 2) };\n\t\t\t});\n\t\t} catch (error) {\n\t\t\tthis.recordError(error);\n\t\t}\n\t}\n\n\t/**\n\t * Logout from a provider.\n\t */\n\tlogout(provider: string): void {\n\t\tthis.remove(provider);\n\t}\n\n\t/**\n\t * Resolve the explicitly selected account and refresh it under the storage lock.\n\t *\n\t * `minRemainingMs` lets a long operation demand headroom. An access token is\n\t * resolved once and then reused for the whole request, so a token that is\n\t * merely \"not expired yet\" can still expire mid-flight and come back as a\n\t * provider 401. Callers that run for minutes (compaction) ask for a margin.\n\t */\n\tprivate async resolveOAuthTokenWithLock(\n\t\tproviderId: OAuthProviderId,\n\t\tminRemainingMs = 0,\n\t\trejectedApiKey?: string,\n\t): Promise<{ apiKey: string; newCredentials: OAuthCredentials } | null> {\n\t\tconst provider = getOAuthProvider(providerId);\n\t\tif (!provider) return null;\n\n\t\treturn this.storage.withLockAsync(async (current) => {\n\t\t\tconst currentData = this.parseStorageData(current);\n\t\t\tthis.data = currentData;\n\t\t\tthis.loadError = null;\n\n\t\t\tconst credential = currentData[providerId];\n\t\t\tif (credential?.type !== \"oauth\") {\n\t\t\t\treturn { result: null };\n\t\t\t}\n\n\t\t\tconst accounts = [...getOAuthAccounts(credential)];\n\t\t\tconst accountIndex = getSelectedOAuthAccountIndex(credential, accounts.length);\n\t\t\tconst account = accounts[accountIndex];\n\t\t\tif (!account) return { result: null };\n\n\t\t\tconst resolved = await this.resolveAccountToken(provider, providerId, account, minRemainingMs, rejectedApiKey);\n\t\t\tif (!resolved) return { result: null };\n\n\t\t\tif (resolved.newCredentials === account) {\n\t\t\t\treturn { result: resolved };\n\t\t\t}\n\n\t\t\tconst newCredentials = { ...account, ...resolved.newCredentials };\n\t\t\taccounts[accountIndex] = newCredentials;\n\t\t\tconst merged: AuthStorageData = {\n\t\t\t\t...currentData,\n\t\t\t\t[providerId]: createOAuthCredential(accounts, accountIndex),\n\t\t\t};\n\t\t\tthis.data = merged;\n\t\t\treturn {\n\t\t\t\tresult: { ...resolved, newCredentials },\n\t\t\t\tnext: JSON.stringify(merged, null, 2),\n\t\t\t};\n\t\t});\n\t}\n\n\t/**\n\t * Force-refresh the selected account after the provider rejected `rejectedApiKey`.\n\t *\n\t * Local expiry is not the last word: ChatGPT/Codex answers `401 token_expired`\n\t * for a token whose JWT `exp` and our stored `expires` still lie days ahead\n\t * (server-side revocation, rotation from another login). Refreshing only on\n\t * the local clock would keep replaying the dead token until that date.\n\t *\n\t * Several omk processes share auth.json. If the stored token no longer equals\n\t * the rejected one, another process already rotated it and the current token\n\t * is returned without a second refresh, which would only burn the new refresh\n\t * token. A failed refresh propagates so the caller can ask for `/login`; the\n\t * credential is left in place for that login to replace.\n\t */\n\tasync refreshRejectedOAuthToken(providerId: OAuthProviderId, rejectedApiKey: string): Promise<string | undefined> {\n\t\treturn (await this.resolveOAuthTokenWithLock(providerId, 0, rejectedApiKey))?.apiKey;\n\t}\n\n\t/**\n\t * Token for one account, refreshing when it is expired or too close to it.\n\t *\n\t * Hard expiry must refresh, and a refresh failure there is fatal. A margin\n\t * miss is only a precaution: the token is still valid, so a failed proactive\n\t * refresh falls back to it instead of breaking a working session. A token the\n\t * provider already rejected (`rejectedApiKey`) is refreshed unconditionally,\n\t * and that failure is fatal too.\n\t */\n\tprivate async resolveAccountToken(\n\t\tprovider: OAuthProviderInterface,\n\t\tproviderId: OAuthProviderId,\n\t\taccount: OAuthCredentials,\n\t\tminRemainingMs: number,\n\t\trejectedApiKey?: string,\n\t): Promise<{ apiKey: string; newCredentials: OAuthCredentials } | null> {\n\t\tconst now = Date.now();\n\t\tif (now >= account.expires) {\n\t\t\treturn await getOAuthApiKey(providerId, { [providerId]: account });\n\t\t}\n\t\tif (rejectedApiKey !== undefined && provider.getApiKey(account) === rejectedApiKey) {\n\t\t\tconst refreshed = await provider.refreshToken(account);\n\t\t\treturn { apiKey: provider.getApiKey(refreshed), newCredentials: refreshed };\n\t\t}\n\t\tif (minRemainingMs > 0 && now + minRemainingMs >= account.expires) {\n\t\t\ttry {\n\t\t\t\tconst refreshed = await provider.refreshToken(account);\n\t\t\t\treturn { apiKey: provider.getApiKey(refreshed), newCredentials: refreshed };\n\t\t\t} catch {\n\t\t\t\t// Still valid right now; prefer a usable token over a hard failure.\n\t\t\t}\n\t\t}\n\t\treturn { apiKey: provider.getApiKey(account), newCredentials: account };\n\t}\n\n\t/**\n\t * Get API key for a provider.\n\t * Priority:\n\t * 1. Runtime override (CLI --api-key)\n\t * 2. API key from auth.json\n\t * 3. OAuth token from auth.json (auto-refreshed with locking)\n\t * 4. Environment variable\n\t * 5. Fallback resolver (models.json custom providers)\n\t */\n\tasync getApiKey(\n\t\tproviderId: string,\n\t\toptions?: { includeFallback?: boolean; minRemainingMs?: number },\n\t): Promise<string | undefined> {\n\t\t// Runtime override takes highest priority\n\t\tconst runtimeKey = this.runtimeOverrides.get(providerId);\n\t\tif (runtimeKey) {\n\t\t\treturn runtimeKey;\n\t\t}\n\n\t\t// Lock contention with another session leaves the credential map unread (reload() records the\n\t\t// failure and keeps the previous data). Retry the read here so one transient failure at startup\n\t\t// does not decide how this process authenticates for its whole lifetime.\n\t\tif (this.loadError) {\n\t\t\tthis.reload();\n\t\t}\n\n\t\tconst cred = this.data[providerId];\n\n\t\tif (cred?.type === \"api_key\") {\n\t\t\treturn resolveConfigValue(cred.key);\n\t\t}\n\n\t\tif (cred?.type === \"oauth\") {\n\t\t\ttry {\n\t\t\t\tconst result = await this.resolveOAuthTokenWithLock(providerId, options?.minRemainingMs ?? 0);\n\t\t\t\tif (result) {\n\t\t\t\t\treturn result.apiKey;\n\t\t\t\t}\n\t\t\t} catch (error) {\n\t\t\t\tthis.recordError(error);\n\t\t\t\t// Credentials are preserved so a later request or /login can recover.\n\t\t\t\tthis.reload();\n\t\t\t\treturn undefined;\n\t\t\t}\n\t\t}\n\n\t\t// A store that could not be read is not the same as a provider with no credential. Substituting\n\t\t// an environment key here replaced a stored OAuth credential with a stale OAuth token from\n\t\t// ANTHROPIC_API_KEY, and the provider answered 401 \"OAuth access token is invalid\" — an error\n\t\t// /login could never clear, because the stored credential was never used.\n\t\tif (this.loadError) {\n\t\t\tthis.recordError(this.loadError);\n\t\t\treturn undefined;\n\t\t}\n\n\t\t// Fall back to environment variable\n\t\tconst envKey = getEnvApiKey(providerId);\n\t\tif (envKey) return envKey;\n\n\t\t// Fall back to custom resolver (e.g., models.json custom providers)\n\t\tif (options?.includeFallback !== false) {\n\t\t\treturn this.fallbackResolver?.(providerId) ?? undefined;\n\t\t}\n\n\t\treturn undefined;\n\t}\n\n\t/**\n\t * Get all registered OAuth providers\n\t */\n\tgetOAuthProviders() {\n\t\treturn getOAuthProviders();\n\t}\n}\n"]}
@@ -11,8 +11,9 @@ import { getOAuthApiKey, getOAuthProvider, getOAuthProviders } from "omk-ai/oaut
11
11
  import { dirname, join } from "path";
12
12
  import lockfile from "proper-lockfile";
13
13
  import { getAgentDir } from "../config.js";
14
- import { stripAnsi } from "../utils/ansi.js";
15
14
  import { normalizePath } from "../utils/paths.js";
15
+ import { findMatchingOAuthAccount, findOAuthAccountMatch, getOAuthAccountDisplayLabel, mergeImportedCredential, previewOAuthAccountImport, } from "./oauth-account-import.js";
16
+ import { createOAuthCredential, getOAuthAccounts, getSelectedOAuthAccountIndex, } from "./oauth-credential.js";
16
17
  import { resolveConfigValue } from "./resolve-config-value.js";
17
18
  const RETIRED_GROK_OAUTH_PROXY = "grok-oauth-proxy";
18
19
  function omitRetiredGrokOAuthProxy(data) {
@@ -21,113 +22,6 @@ function omitRetiredGrokOAuthProxy(data) {
21
22
  const { [RETIRED_GROK_OAUTH_PROXY]: _retired, ...kept } = data;
22
23
  return kept;
23
24
  }
24
- const OAUTH_ACCOUNT_ID_FIELDS = ["accountId", "email", "userId", "username"];
25
- const OAUTH_ACCOUNT_LABEL_FIELDS = ["email", "username"];
26
- const OAUTH_ACCOUNT_LABEL_MAX_LENGTH = 96;
27
- const UNSAFE_ACCOUNT_LABEL_PATTERN = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]/g;
28
- function sanitizeOAuthAccountLabel(value) {
29
- const sanitized = stripAnsi(value)
30
- .replace(/[\t\r\n]+/g, " ")
31
- .replace(UNSAFE_ACCOUNT_LABEL_PATTERN, "")
32
- .replace(/\s+/g, " ")
33
- .trim()
34
- .slice(0, OAUTH_ACCOUNT_LABEL_MAX_LENGTH);
35
- return sanitized || undefined;
36
- }
37
- function stripOAuthStorageMetadata(credential) {
38
- const { type: _type, accounts: _accounts, activeAccount: _activeAccount, nextAccount: _nextAccount, ...credentials } = credential;
39
- return credentials;
40
- }
41
- function getOAuthAccounts(credential) {
42
- if (Array.isArray(credential.accounts) && credential.accounts.length > 0) {
43
- return credential.accounts;
44
- }
45
- return [stripOAuthStorageMetadata(credential)];
46
- }
47
- function normalizeOAuthAccountIndex(index, accountCount) {
48
- if (accountCount <= 0 || typeof index !== "number" || !Number.isInteger(index)) {
49
- return 0;
50
- }
51
- return ((index % accountCount) + accountCount) % accountCount;
52
- }
53
- function getSelectedOAuthAccountIndex(credential, accountCount) {
54
- return normalizeOAuthAccountIndex(credential.activeAccount ?? credential.nextAccount, accountCount);
55
- }
56
- function createOAuthCredential(accounts, activeAccount = 0) {
57
- const selectedIndex = normalizeOAuthAccountIndex(activeAccount, accounts.length);
58
- const selected = accounts[selectedIndex];
59
- if (!selected) {
60
- throw new Error("OAuth credential must contain at least one account");
61
- }
62
- if (accounts.length === 1) {
63
- return { ...selected, type: "oauth" };
64
- }
65
- return {
66
- ...selected,
67
- type: "oauth",
68
- accounts,
69
- activeAccount: selectedIndex,
70
- };
71
- }
72
- function getOAuthAccountIdentity(credentials) {
73
- const accountId = credentials.accountId;
74
- const orgId = credentials.orgId;
75
- if (typeof accountId === "string" && accountId.trim() && typeof orgId === "string" && orgId.trim()) {
76
- return `accountId:${accountId.trim().toLowerCase()}:orgId:${orgId.trim().toLowerCase()}`;
77
- }
78
- for (const field of OAUTH_ACCOUNT_ID_FIELDS) {
79
- const value = credentials[field];
80
- if (typeof value === "string" && value.trim()) {
81
- return `${field}:${value.trim().toLowerCase()}`;
82
- }
83
- }
84
- return undefined;
85
- }
86
- function getOAuthAccountDisplayLabel(providerId, credentials) {
87
- for (const field of OAUTH_ACCOUNT_LABEL_FIELDS) {
88
- const value = credentials[field];
89
- if (typeof value === "string" && value.trim()) {
90
- const label = sanitizeOAuthAccountLabel(value);
91
- if (!label)
92
- continue;
93
- const orgName = typeof credentials.orgName === "string" ? sanitizeOAuthAccountLabel(credentials.orgName) : undefined;
94
- return sanitizeOAuthAccountLabel(orgName ? `${label} (${orgName})` : label);
95
- }
96
- }
97
- try {
98
- const label = getOAuthProvider(providerId)?.getAccountLabel?.(credentials);
99
- return typeof label === "string" ? sanitizeOAuthAccountLabel(label) : undefined;
100
- }
101
- catch {
102
- return undefined;
103
- }
104
- }
105
- function findMatchingOAuthAccount(accounts, credentials) {
106
- const identity = getOAuthAccountIdentity(credentials);
107
- if (identity) {
108
- const exactMatch = accounts.findIndex((account) => getOAuthAccountIdentity(account) === identity);
109
- if (exactMatch >= 0)
110
- return exactMatch;
111
- }
112
- // Let newly enriched credentials update older entries that predate email/org metadata.
113
- for (const field of OAUTH_ACCOUNT_ID_FIELDS) {
114
- const value = credentials[field];
115
- if (typeof value !== "string" || !value.trim())
116
- continue;
117
- const normalizedValue = value.trim().toLowerCase();
118
- const orgId = typeof credentials.orgId === "string" ? credentials.orgId.trim().toLowerCase() : "";
119
- const compatibleMatch = accounts.findIndex((account) => {
120
- const existingValue = account[field];
121
- if (typeof existingValue !== "string" || existingValue.trim().toLowerCase() !== normalizedValue)
122
- return false;
123
- const existingOrgId = typeof account.orgId === "string" ? account.orgId.trim().toLowerCase() : "";
124
- return !orgId || !existingOrgId || orgId === existingOrgId;
125
- });
126
- if (compatibleMatch >= 0)
127
- return compatibleMatch;
128
- }
129
- return accounts.findIndex((account) => account.refresh === credentials.refresh);
130
- }
131
25
  const AUTH_FILE_WRITE_OPTIONS = { encoding: "utf-8", mode: 0o600 };
132
26
  export class FileAuthStorageBackend {
133
27
  authPath;
@@ -439,7 +333,9 @@ export class AuthStorage {
439
333
  getOAuthAccountLabel(provider) {
440
334
  return this.listOAuthAccounts(provider).find((account) => account.selected)?.label;
441
335
  }
442
- /** Explicitly select and persist the OAuth account used by this provider. */
336
+ /**
337
+ * Explicitly select and persist the OAuth account used by this provider.
338
+ */
443
339
  selectOAuthAccount(provider, accountIndex) {
444
340
  this.storage.withLock((current) => {
445
341
  const currentData = this.parseStorageData(current);
@@ -460,6 +356,73 @@ export class AuthStorage {
460
356
  return { result: undefined, next: JSON.stringify(merged, null, 2) };
461
357
  });
462
358
  }
359
+ /** Match, merge and write inside the storage lock (`omk provider adopt`). The active selection never moves. */
360
+ addOAuthAccount(provider, credentials) {
361
+ if (this.loadError) {
362
+ return { action: "blocked", reason: "the credential store could not be read" };
363
+ }
364
+ try {
365
+ return this.storage.withLock((current) => {
366
+ const currentData = this.parseStorageData(current);
367
+ const existing = currentData[provider];
368
+ const accounts = existing?.type === "oauth" ? [...getOAuthAccounts(existing)] : [];
369
+ const activeIndex = existing?.type === "oauth" ? getSelectedOAuthAccountIndex(existing, accounts.length) : 0;
370
+ const matchIndex = findOAuthAccountMatch(accounts, credentials);
371
+ if (matchIndex >= 0) {
372
+ const current_ = accounts[matchIndex];
373
+ if (current_ && typeof current_.expires === "number" && current_.expires > Date.now()) {
374
+ this.data = currentData;
375
+ return {
376
+ result: {
377
+ action: "unchanged",
378
+ accountIndex: matchIndex,
379
+ reason: "the stored account is still valid",
380
+ },
381
+ };
382
+ }
383
+ accounts[matchIndex] = mergeImportedCredential(current_, credentials);
384
+ const merged = {
385
+ ...currentData,
386
+ [provider]: createOAuthCredential(accounts, accounts.length === 1 ? 0 : activeIndex),
387
+ };
388
+ this.data = merged;
389
+ this.loadError = null;
390
+ return {
391
+ result: { action: "updated", accountIndex: matchIndex },
392
+ next: JSON.stringify(merged, null, 2),
393
+ };
394
+ }
395
+ accounts.push(credentials);
396
+ const merged = {
397
+ ...currentData,
398
+ [provider]: createOAuthCredential(accounts, accounts.length === 1 ? 0 : activeIndex),
399
+ };
400
+ this.data = merged;
401
+ this.loadError = null;
402
+ return {
403
+ result: { action: "imported", accountIndex: accounts.length - 1 },
404
+ next: JSON.stringify(merged, null, 2),
405
+ };
406
+ });
407
+ }
408
+ catch (error) {
409
+ this.recordError(error);
410
+ return {
411
+ action: "blocked",
412
+ reason: `the credential store could not be written: ${error instanceof Error ? error.message : error}`,
413
+ };
414
+ }
415
+ }
416
+ /** Predict what `addOAuthAccount` would do, without writing. */
417
+ previewOAuthAccountImport(provider, credentials) {
418
+ const existing = this.data[provider];
419
+ return previewOAuthAccountImport({
420
+ loadError: this.loadError !== null,
421
+ existing,
422
+ accounts: existing?.type === "oauth" ? getOAuthAccounts(existing) : [],
423
+ credentials,
424
+ });
425
+ }
463
426
  /**
464
427
  * Return the explicitly selected OAuth credentials without refreshing tokens.
465
428
  * Used by provider model customization hooks.
@@ -505,6 +468,13 @@ export class AuthStorage {
505
468
  this.errors = [];
506
469
  return drained;
507
470
  }
471
+ /**
472
+ * True when the credential store could not be read (for example, lock contention with another
473
+ * session). Callers must not substitute a different credential source while this is set.
474
+ */
475
+ hasLoadError() {
476
+ return this.loadError !== null;
477
+ }
508
478
  /**
509
479
  * Login to an OAuth provider. Repeated logins append distinct accounts and
510
480
  * update an existing account when the provider exposes a stable identity.
@@ -567,9 +537,8 @@ export class AuthStorage {
567
537
  */
568
538
  async resolveOAuthTokenWithLock(providerId, minRemainingMs = 0, rejectedApiKey) {
569
539
  const provider = getOAuthProvider(providerId);
570
- if (!provider) {
540
+ if (!provider)
571
541
  return null;
572
- }
573
542
  return this.storage.withLockAsync(async (current) => {
574
543
  const currentData = this.parseStorageData(current);
575
544
  this.data = currentData;
@@ -663,6 +632,12 @@ export class AuthStorage {
663
632
  if (runtimeKey) {
664
633
  return runtimeKey;
665
634
  }
635
+ // Lock contention with another session leaves the credential map unread (reload() records the
636
+ // failure and keeps the previous data). Retry the read here so one transient failure at startup
637
+ // does not decide how this process authenticates for its whole lifetime.
638
+ if (this.loadError) {
639
+ this.reload();
640
+ }
666
641
  const cred = this.data[providerId];
667
642
  if (cred?.type === "api_key") {
668
643
  return resolveConfigValue(cred.key);
@@ -681,6 +656,14 @@ export class AuthStorage {
681
656
  return undefined;
682
657
  }
683
658
  }
659
+ // A store that could not be read is not the same as a provider with no credential. Substituting
660
+ // an environment key here replaced a stored OAuth credential with a stale OAuth token from
661
+ // ANTHROPIC_API_KEY, and the provider answered 401 "OAuth access token is invalid" — an error
662
+ // /login could never clear, because the stored credential was never used.
663
+ if (this.loadError) {
664
+ this.recordError(this.loadError);
665
+ return undefined;
666
+ }
684
667
  // Fall back to environment variable
685
668
  const envKey = getEnvApiKey(providerId);
686
669
  if (envKey)