byterover-cli 2.2.0 → 2.3.0

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 (90) hide show
  1. package/dist/agent/infra/llm/providers/openai.d.ts +12 -0
  2. package/dist/agent/infra/llm/providers/openai.js +52 -1
  3. package/dist/oclif/commands/curate/index.js +2 -2
  4. package/dist/oclif/commands/model/switch.js +14 -3
  5. package/dist/oclif/commands/providers/connect.d.ts +9 -0
  6. package/dist/oclif/commands/providers/connect.js +110 -14
  7. package/dist/oclif/commands/providers/list.js +3 -5
  8. package/dist/oclif/commands/query.js +2 -2
  9. package/dist/oclif/lib/daemon-client.d.ts +4 -0
  10. package/dist/oclif/lib/daemon-client.js +13 -3
  11. package/dist/server/core/domain/entities/provider-config.d.ts +6 -0
  12. package/dist/server/core/domain/entities/provider-config.js +4 -3
  13. package/dist/server/core/domain/entities/provider-registry.d.ts +41 -1
  14. package/dist/server/core/domain/entities/provider-registry.js +24 -1
  15. package/dist/server/core/domain/errors/task-error.d.ts +2 -0
  16. package/dist/server/core/domain/errors/task-error.js +6 -1
  17. package/dist/server/core/domain/transport/schemas.d.ts +2 -0
  18. package/dist/server/core/interfaces/i-provider-config-store.d.ts +2 -0
  19. package/dist/server/core/interfaces/i-provider-model-fetcher.d.ts +12 -3
  20. package/dist/server/core/interfaces/i-provider-oauth-token-store.d.ts +24 -0
  21. package/dist/server/core/interfaces/i-provider-oauth-token-store.js +1 -0
  22. package/dist/server/core/interfaces/i-token-refresh-manager.d.ts +7 -0
  23. package/dist/server/core/interfaces/i-token-refresh-manager.js +1 -0
  24. package/dist/server/infra/daemon/agent-process.js +22 -4
  25. package/dist/server/infra/daemon/brv-server.js +13 -2
  26. package/dist/server/infra/http/models-dev-client.d.ts +29 -0
  27. package/dist/server/infra/http/models-dev-client.js +133 -0
  28. package/dist/server/infra/http/provider-model-fetcher-registry.d.ts +2 -1
  29. package/dist/server/infra/http/provider-model-fetcher-registry.js +6 -1
  30. package/dist/server/infra/http/provider-model-fetchers.d.ts +33 -8
  31. package/dist/server/infra/http/provider-model-fetchers.js +88 -10
  32. package/dist/server/infra/process/feature-handlers.d.ts +3 -1
  33. package/dist/server/infra/process/feature-handlers.js +3 -1
  34. package/dist/server/infra/provider/provider-config-resolver.d.ts +4 -2
  35. package/dist/server/infra/provider/provider-config-resolver.js +59 -4
  36. package/dist/server/infra/provider-oauth/callback-server.d.ts +24 -0
  37. package/dist/server/infra/provider-oauth/callback-server.js +203 -0
  38. package/dist/server/infra/provider-oauth/errors.d.ts +39 -0
  39. package/dist/server/infra/provider-oauth/errors.js +76 -0
  40. package/dist/server/infra/provider-oauth/index.d.ts +9 -0
  41. package/dist/server/infra/provider-oauth/index.js +9 -0
  42. package/dist/server/infra/provider-oauth/jwt-utils.d.ts +17 -0
  43. package/dist/server/infra/provider-oauth/jwt-utils.js +51 -0
  44. package/dist/server/infra/provider-oauth/pkce-service.d.ts +22 -0
  45. package/dist/server/infra/provider-oauth/pkce-service.js +33 -0
  46. package/dist/server/infra/provider-oauth/provider-oauth-token-store.d.ts +48 -0
  47. package/dist/server/infra/provider-oauth/provider-oauth-token-store.js +155 -0
  48. package/dist/server/infra/provider-oauth/refresh-token-exchange.d.ts +8 -0
  49. package/dist/server/infra/provider-oauth/refresh-token-exchange.js +39 -0
  50. package/dist/server/infra/provider-oauth/token-exchange.d.ts +8 -0
  51. package/dist/server/infra/provider-oauth/token-exchange.js +44 -0
  52. package/dist/server/infra/provider-oauth/token-refresh-manager.d.ts +32 -0
  53. package/dist/server/infra/provider-oauth/token-refresh-manager.js +96 -0
  54. package/dist/server/infra/provider-oauth/types.d.ts +55 -0
  55. package/dist/server/infra/provider-oauth/types.js +22 -0
  56. package/dist/server/infra/storage/file-provider-config-store.d.ts +2 -0
  57. package/dist/server/infra/storage/file-provider-config-store.js +1 -3
  58. package/dist/server/infra/transport/handlers/model-handler.d.ts +3 -0
  59. package/dist/server/infra/transport/handlers/model-handler.js +53 -11
  60. package/dist/server/infra/transport/handlers/provider-handler.d.ts +26 -0
  61. package/dist/server/infra/transport/handlers/provider-handler.js +215 -13
  62. package/dist/shared/constants/oauth.d.ts +14 -0
  63. package/dist/shared/constants/oauth.js +14 -0
  64. package/dist/shared/transport/events/index.d.ts +5 -0
  65. package/dist/shared/transport/events/model-events.d.ts +2 -0
  66. package/dist/shared/transport/events/provider-events.d.ts +36 -0
  67. package/dist/shared/transport/events/provider-events.js +5 -0
  68. package/dist/shared/transport/types/dto.d.ts +4 -0
  69. package/dist/tui/features/model/api/set-active-model.d.ts +1 -1
  70. package/dist/tui/features/model/api/set-active-model.js +12 -4
  71. package/dist/tui/features/provider/api/await-oauth-callback.d.ts +11 -0
  72. package/dist/tui/features/provider/api/await-oauth-callback.js +25 -0
  73. package/dist/tui/features/provider/api/cancel-oauth.d.ts +5 -0
  74. package/dist/tui/features/provider/api/cancel-oauth.js +10 -0
  75. package/dist/tui/features/provider/api/start-oauth.d.ts +11 -0
  76. package/dist/tui/features/provider/api/start-oauth.js +15 -0
  77. package/dist/tui/features/provider/components/auth-method-dialog.d.ts +9 -0
  78. package/dist/tui/features/provider/components/auth-method-dialog.js +20 -0
  79. package/dist/tui/features/provider/components/oauth-dialog.d.ts +9 -0
  80. package/dist/tui/features/provider/components/oauth-dialog.js +96 -0
  81. package/dist/tui/features/provider/components/provider-dialog.js +1 -1
  82. package/dist/tui/features/provider/components/provider-flow.js +54 -4
  83. package/dist/tui/features/provider/components/provider-subscription-initializer.d.ts +1 -0
  84. package/dist/tui/features/provider/components/provider-subscription-initializer.js +5 -0
  85. package/dist/tui/features/provider/hooks/use-provider-subscriptions.d.ts +6 -0
  86. package/dist/tui/features/provider/hooks/use-provider-subscriptions.js +24 -0
  87. package/dist/tui/providers/app-providers.js +2 -1
  88. package/dist/tui/utils/error-messages.js +6 -1
  89. package/oclif.manifest.json +132 -116
  90. package/package.json +1 -1
@@ -0,0 +1,48 @@
1
+ import type { IProviderOAuthTokenStore, OAuthTokenRecord } from '../../core/interfaces/i-provider-oauth-token-store.js';
2
+ /**
3
+ * Dependencies for FileProviderOAuthTokenStore.
4
+ * Allows injection for testing (paths + filesystem operations).
5
+ */
6
+ export interface FileProviderOAuthTokenStoreDeps {
7
+ readonly ensureDir?: (path: string) => Promise<void>;
8
+ readonly getCredentialsPath: () => string;
9
+ readonly getDataDir: () => string;
10
+ readonly getKeyPath: () => string;
11
+ readonly readBuffer?: (path: string) => Promise<Buffer>;
12
+ readonly readString?: (path: string) => Promise<string>;
13
+ readonly renameFile?: (oldPath: string, newPath: string) => Promise<void>;
14
+ readonly writeData?: (path: string, data: Buffer | string, options: {
15
+ encoding?: 'utf8';
16
+ mode: number;
17
+ }) => Promise<void>;
18
+ }
19
+ /**
20
+ * File-based encrypted OAuth token store.
21
+ *
22
+ * Security:
23
+ * - Random 32-byte key stored in <global-data-dir>/.provider-oauth-keys (rotated on each save)
24
+ * - AES-256-GCM authenticated encryption for OAuth refresh tokens + expiry
25
+ * - Both files have 0600 permissions (owner read/write only)
26
+ * - All tokens stored as encrypted JSON map: { [providerId]: OAuthTokenRecord }
27
+ */
28
+ export declare class FileProviderOAuthTokenStore implements IProviderOAuthTokenStore {
29
+ private readonly deps;
30
+ private readonly ensureDir;
31
+ private readonly readBuffer;
32
+ private readonly readString;
33
+ private readonly renameFile;
34
+ private readonly writeData;
35
+ /** Serializes concurrent read-modify-write cycles to prevent data loss */
36
+ private writeLock;
37
+ constructor(deps?: FileProviderOAuthTokenStoreDeps);
38
+ delete(providerId: string): Promise<void>;
39
+ get(providerId: string): Promise<OAuthTokenRecord | undefined>;
40
+ has(providerId: string): Promise<boolean>;
41
+ set(providerId: string, data: OAuthTokenRecord): Promise<void>;
42
+ private decrypt;
43
+ private encrypt;
44
+ private loadAll;
45
+ private saveAll;
46
+ private serialize;
47
+ }
48
+ export declare function createProviderOAuthTokenStore(): IProviderOAuthTokenStore;
@@ -0,0 +1,155 @@
1
+ import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
2
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
3
+ import { z } from 'zod';
4
+ import { getGlobalDataDir } from '../../utils/global-data-path.js';
5
+ const OAuthTokenRecordSchema = z.object({
6
+ expiresAt: z.string(),
7
+ refreshToken: z.string(),
8
+ });
9
+ const TokenRecordMapSchema = z.record(OAuthTokenRecordSchema);
10
+ const KEY_FILE = '.provider-oauth-keys';
11
+ const CREDENTIALS_FILE = 'provider-oauth-tokens';
12
+ const ALGORITHM = 'aes-256-gcm';
13
+ const KEY_LENGTH = 32;
14
+ const IV_LENGTH = 16;
15
+ const defaultDeps = {
16
+ getCredentialsPath: () => `${getGlobalDataDir()}/${CREDENTIALS_FILE}`,
17
+ getDataDir: getGlobalDataDir,
18
+ getKeyPath: () => `${getGlobalDataDir()}/${KEY_FILE}`,
19
+ };
20
+ /**
21
+ * File-based encrypted OAuth token store.
22
+ *
23
+ * Security:
24
+ * - Random 32-byte key stored in <global-data-dir>/.provider-oauth-keys (rotated on each save)
25
+ * - AES-256-GCM authenticated encryption for OAuth refresh tokens + expiry
26
+ * - Both files have 0600 permissions (owner read/write only)
27
+ * - All tokens stored as encrypted JSON map: { [providerId]: OAuthTokenRecord }
28
+ */
29
+ export class FileProviderOAuthTokenStore {
30
+ deps;
31
+ ensureDir;
32
+ readBuffer;
33
+ readString;
34
+ renameFile;
35
+ writeData;
36
+ /** Serializes concurrent read-modify-write cycles to prevent data loss */
37
+ writeLock = Promise.resolve();
38
+ constructor(deps = defaultDeps) {
39
+ this.deps = deps;
40
+ this.ensureDir = deps.ensureDir ?? ((p) => mkdir(p, { recursive: true }).then(() => { }));
41
+ this.readBuffer = deps.readBuffer ?? ((p) => readFile(p));
42
+ this.readString = deps.readString ?? ((p) => readFile(p, 'utf8'));
43
+ this.renameFile = deps.renameFile ?? rename;
44
+ this.writeData = deps.writeData ?? ((p, d, o) => writeFile(p, d, o));
45
+ }
46
+ async delete(providerId) {
47
+ return this.serialize(async () => {
48
+ let records;
49
+ try {
50
+ records = await this.loadAll();
51
+ }
52
+ catch {
53
+ return; // No file to delete from
54
+ }
55
+ const updated = Object.fromEntries(Object.entries(records).filter(([key]) => key !== providerId));
56
+ await this.saveAll(updated);
57
+ });
58
+ }
59
+ async get(providerId) {
60
+ return this.serialize(async () => {
61
+ try {
62
+ const records = await this.loadAll();
63
+ return records[providerId] ?? undefined;
64
+ }
65
+ catch { }
66
+ });
67
+ }
68
+ async has(providerId) {
69
+ const record = await this.get(providerId);
70
+ return record !== undefined;
71
+ }
72
+ async set(providerId, data) {
73
+ return this.serialize(async () => {
74
+ let records;
75
+ try {
76
+ records = await this.loadAll();
77
+ }
78
+ catch {
79
+ // Credentials file is corrupt or unreadable — start fresh.
80
+ // Existing records are unrecoverable; overwriting is the only path forward.
81
+ records = {};
82
+ }
83
+ records[providerId] = data;
84
+ await this.saveAll(records);
85
+ });
86
+ }
87
+ decrypt(ciphertext, key) {
88
+ const parts = ciphertext.split(':');
89
+ if (parts.length !== 3) {
90
+ throw new Error('Invalid format');
91
+ }
92
+ const iv = Buffer.from(parts[0], 'base64');
93
+ const authTag = Buffer.from(parts[1], 'base64');
94
+ const encrypted = Buffer.from(parts[2], 'base64');
95
+ const decipher = createDecipheriv(ALGORITHM, key, iv);
96
+ decipher.setAuthTag(authTag);
97
+ return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8');
98
+ }
99
+ encrypt(plaintext, key) {
100
+ const iv = randomBytes(IV_LENGTH);
101
+ const cipher = createCipheriv(ALGORITHM, key, iv);
102
+ const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
103
+ const authTag = cipher.getAuthTag();
104
+ /** Format: iv:authTag:data (all base64) */
105
+ return `${iv.toString('base64')}:${authTag.toString('base64')}:${encrypted.toString('base64')}`;
106
+ }
107
+ async loadAll() {
108
+ const keyPath = this.deps.getKeyPath();
109
+ const credentialsPath = this.deps.getCredentialsPath();
110
+ try {
111
+ const key = await this.readBuffer(keyPath);
112
+ const encrypted = await this.readString(credentialsPath);
113
+ const decrypted = this.decrypt(encrypted.trim(), key);
114
+ const parsed = JSON.parse(decrypted);
115
+ return TokenRecordMapSchema.parse(parsed);
116
+ }
117
+ catch (error) {
118
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT')
119
+ return {};
120
+ throw error;
121
+ }
122
+ }
123
+ async saveAll(records) {
124
+ const dataDir = this.deps.getDataDir();
125
+ const keyPath = this.deps.getKeyPath();
126
+ const credentialsPath = this.deps.getCredentialsPath();
127
+ const tmpKeyPath = `${keyPath}.tmp`;
128
+ const tmpCredentialsPath = `${credentialsPath}.tmp`;
129
+ await this.ensureDir(dataDir);
130
+ // Always generate new key for rotation (security best practice).
131
+ // Write to temp files first, then atomically rename both — a crash at any
132
+ // point leaves either the old consistent pair or the new one, never a
133
+ // mismatched key/ciphertext that would destroy all stored tokens.
134
+ const key = randomBytes(KEY_LENGTH);
135
+ const plaintext = JSON.stringify(records);
136
+ const encrypted = this.encrypt(plaintext, key);
137
+ await this.writeData(tmpKeyPath, key, { mode: 0o600 });
138
+ await this.writeData(tmpCredentialsPath, encrypted, { encoding: 'utf8', mode: 0o600 });
139
+ // Rename key first, then credentials. If a crash occurs between the two
140
+ // renames, loadAll() will read the new key + old ciphertext and fail to
141
+ // decrypt — but set() recovers by overwriting with fresh data. The key
142
+ // improvement is that writes target temp files, so a crash during the
143
+ // write phase never corrupts the live key/ciphertext pair.
144
+ await this.renameFile(tmpKeyPath, keyPath);
145
+ await this.renameFile(tmpCredentialsPath, credentialsPath);
146
+ }
147
+ serialize(fn) {
148
+ const result = this.writeLock.then(fn, fn);
149
+ this.writeLock = result.then(() => { }, () => { });
150
+ return result;
151
+ }
152
+ }
153
+ export function createProviderOAuthTokenStore() {
154
+ return new FileProviderOAuthTokenStore();
155
+ }
@@ -0,0 +1,8 @@
1
+ import type { ProviderTokenResponse, RefreshTokenExchangeParams } from './types.js';
2
+ /**
3
+ * Exchanges a refresh token for a new access token at the provider's token endpoint.
4
+ * Supports configurable content type to accommodate different providers:
5
+ * - OpenAI uses application/x-www-form-urlencoded
6
+ * - Anthropic uses application/json
7
+ */
8
+ export declare function exchangeRefreshToken(params: RefreshTokenExchangeParams): Promise<ProviderTokenResponse>;
@@ -0,0 +1,39 @@
1
+ /* eslint-disable camelcase */
2
+ import axios, { isAxiosError } from 'axios';
3
+ import { extractOAuthErrorFields, ProviderTokenExchangeError } from './errors.js';
4
+ import { ProviderTokenResponseSchema } from './types.js';
5
+ /**
6
+ * Exchanges a refresh token for a new access token at the provider's token endpoint.
7
+ * Supports configurable content type to accommodate different providers:
8
+ * - OpenAI uses application/x-www-form-urlencoded
9
+ * - Anthropic uses application/json
10
+ */
11
+ export async function exchangeRefreshToken(params) {
12
+ const body = {
13
+ client_id: params.clientId,
14
+ grant_type: 'refresh_token',
15
+ refresh_token: params.refreshToken,
16
+ };
17
+ let response;
18
+ try {
19
+ response = await axios.post(params.tokenUrl, params.contentType === 'application/x-www-form-urlencoded' ? new URLSearchParams(body).toString() : body, {
20
+ headers: {
21
+ 'Content-Type': params.contentType,
22
+ },
23
+ timeout: 30_000,
24
+ });
25
+ }
26
+ catch (error) {
27
+ if (isAxiosError(error)) {
28
+ const data = error.response?.data;
29
+ const errorFields = extractOAuthErrorFields(data);
30
+ throw new ProviderTokenExchangeError({
31
+ errorCode: errorFields.error ?? error.code,
32
+ message: errorFields.error_description ?? `Token refresh failed: ${error.message}`,
33
+ statusCode: error.response?.status,
34
+ });
35
+ }
36
+ throw error;
37
+ }
38
+ return ProviderTokenResponseSchema.parse(response.data);
39
+ }
@@ -0,0 +1,8 @@
1
+ import type { ProviderTokenResponse, TokenExchangeParams } from './types.js';
2
+ /**
3
+ * Exchanges an authorization code for tokens at the provider's token endpoint.
4
+ * Supports configurable content type to accommodate different providers:
5
+ * - OpenAI uses application/x-www-form-urlencoded
6
+ * - Anthropic uses application/json
7
+ */
8
+ export declare function exchangeCodeForTokens(params: TokenExchangeParams): Promise<ProviderTokenResponse>;
@@ -0,0 +1,44 @@
1
+ /* eslint-disable camelcase */
2
+ import axios, { isAxiosError } from 'axios';
3
+ import { extractOAuthErrorFields, ProviderTokenExchangeError } from './errors.js';
4
+ import { ProviderTokenResponseSchema } from './types.js';
5
+ /**
6
+ * Exchanges an authorization code for tokens at the provider's token endpoint.
7
+ * Supports configurable content type to accommodate different providers:
8
+ * - OpenAI uses application/x-www-form-urlencoded
9
+ * - Anthropic uses application/json
10
+ */
11
+ export async function exchangeCodeForTokens(params) {
12
+ const body = {
13
+ client_id: params.clientId,
14
+ code: params.code,
15
+ code_verifier: params.codeVerifier,
16
+ grant_type: 'authorization_code',
17
+ redirect_uri: params.redirectUri,
18
+ };
19
+ if (params.clientSecret !== undefined) {
20
+ body.client_secret = params.clientSecret;
21
+ }
22
+ let response;
23
+ try {
24
+ response = await axios.post(params.tokenUrl, params.contentType === 'application/x-www-form-urlencoded' ? new URLSearchParams(body).toString() : body, {
25
+ headers: {
26
+ 'Content-Type': params.contentType,
27
+ },
28
+ timeout: 30_000,
29
+ });
30
+ }
31
+ catch (error) {
32
+ if (isAxiosError(error)) {
33
+ const data = error.response?.data;
34
+ const errorFields = extractOAuthErrorFields(data);
35
+ throw new ProviderTokenExchangeError({
36
+ errorCode: errorFields.error ?? error.code,
37
+ message: errorFields.error_description ?? `Token exchange failed: ${error.message}`,
38
+ statusCode: error.response?.status,
39
+ });
40
+ }
41
+ throw error;
42
+ }
43
+ return ProviderTokenResponseSchema.parse(response.data);
44
+ }
@@ -0,0 +1,32 @@
1
+ import type { IProviderConfigStore } from '../../core/interfaces/i-provider-config-store.js';
2
+ import type { IProviderKeychainStore } from '../../core/interfaces/i-provider-keychain-store.js';
3
+ import type { IProviderOAuthTokenStore } from '../../core/interfaces/i-provider-oauth-token-store.js';
4
+ import type { ITokenRefreshManager } from '../../core/interfaces/i-token-refresh-manager.js';
5
+ import type { ITransportServer } from '../../core/interfaces/transport/i-transport-server.js';
6
+ import type { ProviderTokenResponse, RefreshTokenExchangeParams } from './types.js';
7
+ export { type ITokenRefreshManager } from '../../core/interfaces/i-token-refresh-manager.js';
8
+ /** Refresh tokens when they expire within this threshold */
9
+ export declare const REFRESH_THRESHOLD_MS: number;
10
+ export interface TokenRefreshManagerDeps {
11
+ exchangeRefreshToken?: (params: RefreshTokenExchangeParams) => Promise<ProviderTokenResponse>;
12
+ providerConfigStore: IProviderConfigStore;
13
+ providerKeychainStore: IProviderKeychainStore;
14
+ providerOAuthTokenStore: IProviderOAuthTokenStore;
15
+ transport: ITransportServer;
16
+ }
17
+ /**
18
+ * Manages automatic OAuth token refresh for providers.
19
+ *
20
+ * Called by resolveProviderConfig() before returning config to agents.
21
+ * If a token is expiring within 5 minutes, exchanges the refresh token
22
+ * for a new access token. On failure, disconnects the provider.
23
+ */
24
+ export declare class TokenRefreshManager implements ITokenRefreshManager {
25
+ private readonly deps;
26
+ private readonly exchangeRefreshToken;
27
+ /** Per-provider mutex to serialize concurrent refresh attempts */
28
+ private readonly pendingRefreshes;
29
+ constructor(deps: TokenRefreshManagerDeps);
30
+ refreshIfNeeded(providerId: string): Promise<boolean>;
31
+ private doRefresh;
32
+ }
@@ -0,0 +1,96 @@
1
+ import { getProviderById } from '../../core/domain/entities/provider-registry.js';
2
+ import { TransportDaemonEventNames } from '../../core/domain/transport/schemas.js';
3
+ import { processLog } from '../../utils/process-logger.js';
4
+ import { isPermanentOAuthError } from './errors.js';
5
+ import { exchangeRefreshToken as defaultExchangeRefreshToken } from './refresh-token-exchange.js';
6
+ import { computeExpiresAt } from './types.js';
7
+ /** Refresh tokens when they expire within this threshold */
8
+ export const REFRESH_THRESHOLD_MS = 5 * 60 * 1000;
9
+ /**
10
+ * Manages automatic OAuth token refresh for providers.
11
+ *
12
+ * Called by resolveProviderConfig() before returning config to agents.
13
+ * If a token is expiring within 5 minutes, exchanges the refresh token
14
+ * for a new access token. On failure, disconnects the provider.
15
+ */
16
+ export class TokenRefreshManager {
17
+ deps;
18
+ exchangeRefreshToken;
19
+ /** Per-provider mutex to serialize concurrent refresh attempts */
20
+ pendingRefreshes = new Map();
21
+ constructor(deps) {
22
+ this.deps = deps;
23
+ this.exchangeRefreshToken = deps.exchangeRefreshToken ?? defaultExchangeRefreshToken;
24
+ }
25
+ async refreshIfNeeded(providerId) {
26
+ // Serialize concurrent refreshes for the same provider
27
+ const pending = this.pendingRefreshes.get(providerId);
28
+ if (pending) {
29
+ return pending;
30
+ }
31
+ const promise = this.doRefresh(providerId).finally(() => {
32
+ this.pendingRefreshes.delete(providerId);
33
+ });
34
+ this.pendingRefreshes.set(providerId, promise);
35
+ return promise;
36
+ }
37
+ async doRefresh(providerId) {
38
+ // 1. Check if provider is OAuth-connected
39
+ const config = await this.deps.providerConfigStore.read();
40
+ const providerConfig = config.providers[providerId];
41
+ if (providerConfig?.authMethod !== 'oauth') {
42
+ return true;
43
+ }
44
+ // 2. Get token record from encrypted store
45
+ const tokenRecord = await this.deps.providerOAuthTokenStore.get(providerId);
46
+ if (!tokenRecord) {
47
+ return false;
48
+ }
49
+ // 3. Check if refresh is needed
50
+ const expiresAt = new Date(tokenRecord.expiresAt).getTime();
51
+ const timeUntilExpiry = expiresAt - Date.now();
52
+ if (timeUntilExpiry > REFRESH_THRESHOLD_MS) {
53
+ return true;
54
+ }
55
+ // 4. Look up provider OAuth config
56
+ const providerDef = getProviderById(providerId);
57
+ if (!providerDef?.oauth) {
58
+ return false;
59
+ }
60
+ const oauthConfig = providerDef.oauth;
61
+ const contentType = oauthConfig.tokenContentType === 'form' ? 'application/x-www-form-urlencoded' : 'application/json';
62
+ // 5. Attempt refresh
63
+ try {
64
+ const tokens = await this.exchangeRefreshToken({
65
+ clientId: oauthConfig.clientId,
66
+ contentType,
67
+ refreshToken: tokenRecord.refreshToken,
68
+ tokenUrl: oauthConfig.tokenUrl,
69
+ });
70
+ // 6. Success: update keychain + encrypted store
71
+ await this.deps.providerKeychainStore.setApiKey(providerId, tokens.access_token);
72
+ const newExpiresAt = tokens.expires_in ? computeExpiresAt(tokens.expires_in) : tokenRecord.expiresAt;
73
+ await this.deps.providerOAuthTokenStore.set(providerId, {
74
+ expiresAt: newExpiresAt,
75
+ refreshToken: tokens.refresh_token ?? tokenRecord.refreshToken,
76
+ });
77
+ this.deps.transport.broadcast(TransportDaemonEventNames.PROVIDER_UPDATED, {});
78
+ return true;
79
+ }
80
+ catch (error) {
81
+ // 7. Permanent failure (token revoked, client invalid): disconnect provider, clean up
82
+ if (isPermanentOAuthError(error)) {
83
+ await this.deps.providerConfigStore.disconnectProvider(providerId).catch(() => { });
84
+ await this.deps.providerOAuthTokenStore.delete(providerId).catch(() => { });
85
+ await this.deps.providerKeychainStore.deleteApiKey(providerId).catch(() => { });
86
+ this.deps.transport.broadcast(TransportDaemonEventNames.PROVIDER_UPDATED, {});
87
+ return false;
88
+ }
89
+ // Transient errors (network timeout, 5xx): keep credentials intact.
90
+ // Return true so the caller uses the existing access token from the keychain,
91
+ // which may still be valid until it actually expires.
92
+ processLog(`[TokenRefreshManager] Transient refresh error for ${providerId}: ${error instanceof Error ? error.message : String(error)}`);
93
+ return true;
94
+ }
95
+ }
96
+ }
@@ -0,0 +1,55 @@
1
+ import { z } from 'zod';
2
+ export type ProviderCallbackResult = {
3
+ code: string;
4
+ state: string;
5
+ };
6
+ export type PkceParameters = {
7
+ codeChallenge: string;
8
+ codeVerifier: string;
9
+ state: string;
10
+ };
11
+ export type TokenRequestContentType = 'application/json' | 'application/x-www-form-urlencoded';
12
+ /**
13
+ * Raw token response from an OAuth provider.
14
+ * Fields are snake_case per OAuth 2.0 spec (RFC 6749).
15
+ */
16
+ export declare const ProviderTokenResponseSchema: z.ZodObject<{
17
+ access_token: z.ZodString;
18
+ expires_in: z.ZodOptional<z.ZodNumber>;
19
+ id_token: z.ZodOptional<z.ZodString>;
20
+ refresh_token: z.ZodOptional<z.ZodString>;
21
+ scope: z.ZodOptional<z.ZodString>;
22
+ token_type: z.ZodOptional<z.ZodString>;
23
+ }, "strip", z.ZodTypeAny, {
24
+ access_token: string;
25
+ scope?: string | undefined;
26
+ refresh_token?: string | undefined;
27
+ expires_in?: number | undefined;
28
+ id_token?: string | undefined;
29
+ token_type?: string | undefined;
30
+ }, {
31
+ access_token: string;
32
+ scope?: string | undefined;
33
+ refresh_token?: string | undefined;
34
+ expires_in?: number | undefined;
35
+ id_token?: string | undefined;
36
+ token_type?: string | undefined;
37
+ }>;
38
+ export type ProviderTokenResponse = z.infer<typeof ProviderTokenResponseSchema>;
39
+ export type RefreshTokenExchangeParams = {
40
+ clientId: string;
41
+ contentType: TokenRequestContentType;
42
+ refreshToken: string;
43
+ tokenUrl: string;
44
+ };
45
+ export type TokenExchangeParams = {
46
+ clientId: string;
47
+ clientSecret?: string;
48
+ code: string;
49
+ codeVerifier: string;
50
+ contentType: TokenRequestContentType;
51
+ redirectUri: string;
52
+ tokenUrl: string;
53
+ };
54
+ /** Compute an ISO 8601 expiry timestamp from an OAuth expires_in value (seconds). */
55
+ export declare function computeExpiresAt(expiresInSeconds: number): string;
@@ -0,0 +1,22 @@
1
+ import { z } from 'zod';
2
+ /**
3
+ * Raw token response from an OAuth provider.
4
+ * Fields are snake_case per OAuth 2.0 spec (RFC 6749).
5
+ */
6
+ export const ProviderTokenResponseSchema = z.object({
7
+ // eslint-disable-next-line camelcase
8
+ access_token: z.string().min(1),
9
+ // eslint-disable-next-line camelcase
10
+ expires_in: z.number().optional(),
11
+ // eslint-disable-next-line camelcase
12
+ id_token: z.string().optional(),
13
+ // eslint-disable-next-line camelcase
14
+ refresh_token: z.string().optional(),
15
+ scope: z.string().optional(),
16
+ // eslint-disable-next-line camelcase
17
+ token_type: z.string().optional(),
18
+ });
19
+ /** Compute an ISO 8601 expiry timestamp from an OAuth expires_in value (seconds). */
20
+ export function computeExpiresAt(expiresInSeconds) {
21
+ return new Date(Date.now() + expiresInSeconds * 1000).toISOString();
22
+ }
@@ -31,7 +31,9 @@ export declare class FileProviderConfigStore implements IProviderConfigStore {
31
31
  */
32
32
  connectProvider(providerId: string, options?: {
33
33
  activeModel?: string;
34
+ authMethod?: 'api-key' | 'oauth';
34
35
  baseUrl?: string;
36
+ oauthAccountId?: string;
35
37
  }): Promise<void>;
36
38
  /**
37
39
  * Removes a provider connection.
@@ -38,9 +38,7 @@ export class FileProviderConfigStore {
38
38
  */
39
39
  async connectProvider(providerId, options) {
40
40
  const config = await this.read();
41
- const newConfig = config
42
- .withProviderConnected(providerId, options)
43
- .withActiveProvider(providerId);
41
+ const newConfig = config.withProviderConnected(providerId, options).withActiveProvider(providerId);
44
42
  await this.write(newConfig);
45
43
  }
46
44
  /**
@@ -1,7 +1,9 @@
1
1
  import type { IProviderConfigStore } from '../../../core/interfaces/i-provider-config-store.js';
2
2
  import type { IProviderKeychainStore } from '../../../core/interfaces/i-provider-keychain-store.js';
3
+ import type { IProviderModelFetcher } from '../../../core/interfaces/i-provider-model-fetcher.js';
3
4
  import type { ITransportServer } from '../../../core/interfaces/transport/i-transport-server.js';
4
5
  export interface ModelHandlerDeps {
6
+ getModelFetcher?: (providerId: string) => Promise<IProviderModelFetcher | undefined>;
5
7
  providerConfigStore: IProviderConfigStore;
6
8
  providerKeychainStore: IProviderKeychainStore;
7
9
  transport: ITransportServer;
@@ -11,6 +13,7 @@ export interface ModelHandlerDeps {
11
13
  * Business logic for model listing and selection — no terminal/UI calls.
12
14
  */
13
15
  export declare class ModelHandler {
16
+ private readonly getModelFetcher;
14
17
  private readonly providerConfigStore;
15
18
  private readonly providerKeychainStore;
16
19
  private readonly transport;