byterover-cli 2.2.0 → 2.3.1

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 (99) 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/commands/restart.d.ts +34 -50
  10. package/dist/oclif/commands/restart.js +122 -209
  11. package/dist/oclif/hooks/init/block-command-update-npm.d.ts +11 -0
  12. package/dist/oclif/hooks/init/block-command-update-npm.js +15 -0
  13. package/dist/oclif/hooks/init/update-notifier.d.ts +3 -0
  14. package/dist/oclif/hooks/init/update-notifier.js +17 -4
  15. package/dist/oclif/hooks/postrun/restart-after-update.d.ts +22 -0
  16. package/dist/oclif/hooks/postrun/restart-after-update.js +40 -0
  17. package/dist/oclif/lib/daemon-client.d.ts +4 -0
  18. package/dist/oclif/lib/daemon-client.js +13 -3
  19. package/dist/server/core/domain/entities/provider-config.d.ts +6 -0
  20. package/dist/server/core/domain/entities/provider-config.js +4 -3
  21. package/dist/server/core/domain/entities/provider-registry.d.ts +41 -1
  22. package/dist/server/core/domain/entities/provider-registry.js +24 -1
  23. package/dist/server/core/domain/errors/task-error.d.ts +2 -0
  24. package/dist/server/core/domain/errors/task-error.js +6 -1
  25. package/dist/server/core/domain/transport/schemas.d.ts +2 -0
  26. package/dist/server/core/interfaces/i-provider-config-store.d.ts +2 -0
  27. package/dist/server/core/interfaces/i-provider-model-fetcher.d.ts +12 -3
  28. package/dist/server/core/interfaces/i-provider-oauth-token-store.d.ts +24 -0
  29. package/dist/server/core/interfaces/i-provider-oauth-token-store.js +1 -0
  30. package/dist/server/core/interfaces/i-token-refresh-manager.d.ts +7 -0
  31. package/dist/server/core/interfaces/i-token-refresh-manager.js +1 -0
  32. package/dist/server/infra/daemon/agent-process.js +22 -4
  33. package/dist/server/infra/daemon/brv-server.js +13 -2
  34. package/dist/server/infra/http/models-dev-client.d.ts +29 -0
  35. package/dist/server/infra/http/models-dev-client.js +133 -0
  36. package/dist/server/infra/http/openrouter-api-client.js +1 -1
  37. package/dist/server/infra/http/provider-model-fetcher-registry.d.ts +2 -1
  38. package/dist/server/infra/http/provider-model-fetcher-registry.js +6 -1
  39. package/dist/server/infra/http/provider-model-fetchers.d.ts +33 -8
  40. package/dist/server/infra/http/provider-model-fetchers.js +88 -10
  41. package/dist/server/infra/process/feature-handlers.d.ts +3 -1
  42. package/dist/server/infra/process/feature-handlers.js +3 -1
  43. package/dist/server/infra/provider/provider-config-resolver.d.ts +4 -2
  44. package/dist/server/infra/provider/provider-config-resolver.js +59 -4
  45. package/dist/server/infra/provider-oauth/callback-server.d.ts +24 -0
  46. package/dist/server/infra/provider-oauth/callback-server.js +203 -0
  47. package/dist/server/infra/provider-oauth/errors.d.ts +39 -0
  48. package/dist/server/infra/provider-oauth/errors.js +76 -0
  49. package/dist/server/infra/provider-oauth/index.d.ts +9 -0
  50. package/dist/server/infra/provider-oauth/index.js +9 -0
  51. package/dist/server/infra/provider-oauth/jwt-utils.d.ts +17 -0
  52. package/dist/server/infra/provider-oauth/jwt-utils.js +51 -0
  53. package/dist/server/infra/provider-oauth/pkce-service.d.ts +22 -0
  54. package/dist/server/infra/provider-oauth/pkce-service.js +33 -0
  55. package/dist/server/infra/provider-oauth/provider-oauth-token-store.d.ts +48 -0
  56. package/dist/server/infra/provider-oauth/provider-oauth-token-store.js +155 -0
  57. package/dist/server/infra/provider-oauth/refresh-token-exchange.d.ts +8 -0
  58. package/dist/server/infra/provider-oauth/refresh-token-exchange.js +39 -0
  59. package/dist/server/infra/provider-oauth/token-exchange.d.ts +8 -0
  60. package/dist/server/infra/provider-oauth/token-exchange.js +44 -0
  61. package/dist/server/infra/provider-oauth/token-refresh-manager.d.ts +32 -0
  62. package/dist/server/infra/provider-oauth/token-refresh-manager.js +96 -0
  63. package/dist/server/infra/provider-oauth/types.d.ts +55 -0
  64. package/dist/server/infra/provider-oauth/types.js +22 -0
  65. package/dist/server/infra/storage/file-provider-config-store.d.ts +2 -0
  66. package/dist/server/infra/storage/file-provider-config-store.js +1 -3
  67. package/dist/server/infra/transport/handlers/model-handler.d.ts +3 -0
  68. package/dist/server/infra/transport/handlers/model-handler.js +53 -11
  69. package/dist/server/infra/transport/handlers/provider-handler.d.ts +26 -0
  70. package/dist/server/infra/transport/handlers/provider-handler.js +215 -13
  71. package/dist/shared/constants/oauth.d.ts +14 -0
  72. package/dist/shared/constants/oauth.js +14 -0
  73. package/dist/shared/transport/events/index.d.ts +5 -0
  74. package/dist/shared/transport/events/model-events.d.ts +2 -0
  75. package/dist/shared/transport/events/provider-events.d.ts +36 -0
  76. package/dist/shared/transport/events/provider-events.js +5 -0
  77. package/dist/shared/transport/types/dto.d.ts +4 -0
  78. package/dist/tui/features/model/api/set-active-model.d.ts +1 -1
  79. package/dist/tui/features/model/api/set-active-model.js +12 -4
  80. package/dist/tui/features/provider/api/await-oauth-callback.d.ts +11 -0
  81. package/dist/tui/features/provider/api/await-oauth-callback.js +25 -0
  82. package/dist/tui/features/provider/api/cancel-oauth.d.ts +5 -0
  83. package/dist/tui/features/provider/api/cancel-oauth.js +10 -0
  84. package/dist/tui/features/provider/api/start-oauth.d.ts +11 -0
  85. package/dist/tui/features/provider/api/start-oauth.js +15 -0
  86. package/dist/tui/features/provider/components/auth-method-dialog.d.ts +9 -0
  87. package/dist/tui/features/provider/components/auth-method-dialog.js +20 -0
  88. package/dist/tui/features/provider/components/oauth-dialog.d.ts +9 -0
  89. package/dist/tui/features/provider/components/oauth-dialog.js +96 -0
  90. package/dist/tui/features/provider/components/provider-dialog.js +1 -1
  91. package/dist/tui/features/provider/components/provider-flow.js +54 -4
  92. package/dist/tui/features/provider/components/provider-subscription-initializer.d.ts +1 -0
  93. package/dist/tui/features/provider/components/provider-subscription-initializer.js +5 -0
  94. package/dist/tui/features/provider/hooks/use-provider-subscriptions.d.ts +6 -0
  95. package/dist/tui/features/provider/hooks/use-provider-subscriptions.js +24 -0
  96. package/dist/tui/providers/app-providers.js +2 -1
  97. package/dist/tui/utils/error-messages.js +6 -1
  98. package/oclif.manifest.json +191 -168
  99. package/package.json +7 -5
@@ -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;
@@ -1,15 +1,17 @@
1
1
  import { ModelEvents, } from '../../../../shared/transport/events/model-events.js';
2
2
  import { TransportDaemonEventNames } from '../../../core/domain/transport/schemas.js';
3
- import { getModelFetcher } from '../../http/provider-model-fetcher-registry.js';
3
+ import { getModelFetcher as getModelFetcherDefault } from '../../http/provider-model-fetcher-registry.js';
4
4
  /**
5
5
  * Handles model:* events.
6
6
  * Business logic for model listing and selection — no terminal/UI calls.
7
7
  */
8
8
  export class ModelHandler {
9
+ getModelFetcher;
9
10
  providerConfigStore;
10
11
  providerKeychainStore;
11
12
  transport;
12
13
  constructor(deps) {
14
+ this.getModelFetcher = deps.getModelFetcher ?? getModelFetcherDefault;
13
15
  this.providerConfigStore = deps.providerConfigStore;
14
16
  this.providerKeychainStore = deps.providerKeychainStore;
15
17
  this.transport = deps.transport;
@@ -22,18 +24,21 @@ export class ModelHandler {
22
24
  setupList() {
23
25
  this.transport.onRequest(ModelEvents.LIST, async (data) => {
24
26
  const { providerId } = data;
25
- const fetcher = await getModelFetcher(providerId);
27
+ const fetcher = await this.getModelFetcher(providerId);
26
28
  if (!fetcher) {
27
29
  return { favorites: [], models: [], recent: [] };
28
30
  }
29
31
  // Fetch models from provider API using the correct per-provider fetcher
30
32
  let fetchedModels;
31
33
  try {
34
+ const config = await this.providerConfigStore.read();
35
+ const authMethod = config.providers[providerId]?.authMethod;
32
36
  const apiKey = await this.providerKeychainStore.getApiKey(providerId);
33
- fetchedModels = await fetcher.fetchModels(apiKey ?? '');
37
+ fetchedModels = await fetcher.fetchModels(apiKey ?? '', { authMethod });
34
38
  }
35
- catch {
36
- return { favorites: [], models: [], recent: [] };
39
+ catch (error) {
40
+ const message = error instanceof Error ? error.message : 'Failed to load models';
41
+ return { error: message, favorites: [], models: [], recent: [] };
37
42
  }
38
43
  const models = fetchedModels.map((m) => ({
39
44
  contextLength: m.contextLength,
@@ -61,12 +66,14 @@ export class ModelHandler {
61
66
  setupListByProviders() {
62
67
  this.transport.onRequest(ModelEvents.LIST_BY_PROVIDERS, async (data) => {
63
68
  const { providerIds } = data;
69
+ const config = await this.providerConfigStore.read();
64
70
  const results = await Promise.allSettled(providerIds.map(async (providerId) => {
65
- const fetcher = await getModelFetcher(providerId);
71
+ const fetcher = await this.getModelFetcher(providerId);
66
72
  if (!fetcher)
67
73
  return [];
74
+ const authMethod = config.providers[providerId]?.authMethod;
68
75
  const apiKey = await this.providerKeychainStore.getApiKey(providerId);
69
- const fetchedModels = await fetcher.fetchModels(apiKey ?? '');
76
+ const fetchedModels = await fetcher.fetchModels(apiKey ?? '', { authMethod });
70
77
  return fetchedModels.map((model) => ({
71
78
  contextLength: model.contextLength,
72
79
  description: model.description,
@@ -98,10 +105,45 @@ export class ModelHandler {
98
105
  }
99
106
  setupSetActive() {
100
107
  this.transport.onRequest(ModelEvents.SET_ACTIVE, async (data) => {
101
- await this.providerConfigStore.setActiveProvider(data.providerId);
102
- await this.providerConfigStore.setActiveModel(data.providerId, data.modelId, data.contextLength);
103
- this.transport.broadcast(TransportDaemonEventNames.PROVIDER_UPDATED, {});
104
- return { success: true };
108
+ try {
109
+ const config = await this.providerConfigStore.read();
110
+ const providerConfig = config.providers[data.providerId];
111
+ if (!providerConfig) {
112
+ return {
113
+ error: `Provider "${data.providerId}" is not connected`,
114
+ success: false,
115
+ };
116
+ }
117
+ let matchedModel;
118
+ // Validate model against allowed list for OAuth providers
119
+ if (providerConfig.authMethod === 'oauth') {
120
+ const fetcher = await this.getModelFetcher(data.providerId);
121
+ if (!fetcher) {
122
+ return {
123
+ error: `Cannot validate model for OAuth-connected ${data.providerId}: model fetcher unavailable`,
124
+ success: false,
125
+ };
126
+ }
127
+ const allowedModels = await fetcher.fetchModels('', { authMethod: 'oauth' });
128
+ matchedModel = allowedModels.find((m) => m.id === data.modelId);
129
+ if (!matchedModel) {
130
+ const allowedIds = allowedModels.map((m) => m.id).join(', ');
131
+ return {
132
+ error: `Model "${data.modelId}" is not available for OAuth-connected ${data.providerId}. Allowed models: ${allowedIds}`,
133
+ success: false,
134
+ };
135
+ }
136
+ }
137
+ const contextLength = data.contextLength ?? matchedModel?.contextLength;
138
+ await this.providerConfigStore.setActiveProvider(data.providerId);
139
+ await this.providerConfigStore.setActiveModel(data.providerId, data.modelId, contextLength);
140
+ this.transport.broadcast(TransportDaemonEventNames.PROVIDER_UPDATED, {});
141
+ return { success: true };
142
+ }
143
+ catch (error) {
144
+ const message = error instanceof Error ? error.message : 'Failed to set active model';
145
+ return { error: message, success: false };
146
+ }
105
147
  });
106
148
  }
107
149
  }
@@ -1,9 +1,24 @@
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 { IProviderOAuthTokenStore } from '../../../core/interfaces/i-provider-oauth-token-store.js';
4
+ import type { IBrowserLauncher } from '../../../core/interfaces/services/i-browser-launcher.js';
3
5
  import type { ITransportServer } from '../../../core/interfaces/transport/i-transport-server.js';
6
+ import type { PkceParameters, ProviderTokenResponse, TokenExchangeParams } from '../../provider-oauth/index.js';
7
+ import { ProviderCallbackServer } from '../../provider-oauth/index.js';
4
8
  export interface ProviderHandlerDeps {
9
+ browserLauncher: IBrowserLauncher;
10
+ /** Factory for creating callback servers (injectable for testing) */
11
+ createCallbackServer?: (options: {
12
+ callbackPath?: string;
13
+ port: number;
14
+ }) => ProviderCallbackServer;
15
+ /** Token exchange function (injectable for testing) */
16
+ exchangeCodeForTokens?: (params: TokenExchangeParams) => Promise<ProviderTokenResponse>;
17
+ /** PKCE generator function (injectable for testing) */
18
+ generatePkce?: () => PkceParameters;
5
19
  providerConfigStore: IProviderConfigStore;
6
20
  providerKeychainStore: IProviderKeychainStore;
21
+ providerOAuthTokenStore: IProviderOAuthTokenStore;
7
22
  transport: ITransportServer;
8
23
  }
9
24
  /**
@@ -11,15 +26,26 @@ export interface ProviderHandlerDeps {
11
26
  * Business logic for provider management — no terminal/UI calls.
12
27
  */
13
28
  export declare class ProviderHandler {
29
+ private readonly browserLauncher;
30
+ private readonly createCallbackServer;
31
+ private readonly exchangeCodeForTokens;
32
+ private readonly generatePkce;
33
+ private readonly oauthFlows;
14
34
  private readonly providerConfigStore;
15
35
  private readonly providerKeychainStore;
36
+ private readonly providerOAuthTokenStore;
16
37
  private readonly transport;
17
38
  constructor(deps: ProviderHandlerDeps);
18
39
  setup(): void;
40
+ private cleanupFlowsForClient;
41
+ private setupAwaitOAuthCallback;
42
+ private setupCancelOAuth;
19
43
  private setupConnect;
20
44
  private setupDisconnect;
21
45
  private setupGetActive;
22
46
  private setupList;
23
47
  private setupSetActive;
48
+ private setupStartOAuth;
49
+ private setupSubmitOAuthCode;
24
50
  private setupValidateApiKey;
25
51
  }
@@ -4,17 +4,29 @@ import { TransportDaemonEventNames } from '../../../core/domain/transport/schema
4
4
  import { getErrorMessage } from '../../../utils/error-helpers.js';
5
5
  import { processLog } from '../../../utils/process-logger.js';
6
6
  import { validateApiKey as validateApiKeyViaFetcher } from '../../http/provider-model-fetcher-registry.js';
7
+ import { computeExpiresAt, exchangeCodeForTokens as defaultExchangeCodeForTokens, generatePkce as defaultGeneratePkce, parseAccountIdFromIdToken, ProviderCallbackServer, ProviderCallbackTimeoutError, } from '../../provider-oauth/index.js';
7
8
  /**
8
9
  * Handles provider:* events.
9
10
  * Business logic for provider management — no terminal/UI calls.
10
11
  */
11
12
  export class ProviderHandler {
13
+ browserLauncher;
14
+ createCallbackServer;
15
+ exchangeCodeForTokens;
16
+ generatePkce;
17
+ oauthFlows = new Map();
12
18
  providerConfigStore;
13
19
  providerKeychainStore;
20
+ providerOAuthTokenStore;
14
21
  transport;
15
22
  constructor(deps) {
23
+ this.browserLauncher = deps.browserLauncher;
24
+ this.createCallbackServer = deps.createCallbackServer ?? ((options) => new ProviderCallbackServer(options));
25
+ this.exchangeCodeForTokens = deps.exchangeCodeForTokens ?? defaultExchangeCodeForTokens;
26
+ this.generatePkce = deps.generatePkce ?? defaultGeneratePkce;
16
27
  this.providerConfigStore = deps.providerConfigStore;
17
28
  this.providerKeychainStore = deps.providerKeychainStore;
29
+ this.providerOAuthTokenStore = deps.providerOAuthTokenStore;
18
30
  this.transport = deps.transport;
19
31
  }
20
32
  setup() {
@@ -24,6 +36,99 @@ export class ProviderHandler {
24
36
  this.setupList();
25
37
  this.setupSetActive();
26
38
  this.setupValidateApiKey();
39
+ this.setupStartOAuth();
40
+ this.setupAwaitOAuthCallback();
41
+ this.setupCancelOAuth();
42
+ this.setupSubmitOAuthCode();
43
+ // Clean up OAuth flows when a client disconnects (prevents callback server port leaks)
44
+ this.transport.onDisconnection((clientId) => {
45
+ this.cleanupFlowsForClient(clientId);
46
+ });
47
+ }
48
+ cleanupFlowsForClient(clientId) {
49
+ for (const [providerId, flow] of this.oauthFlows.entries()) {
50
+ if (flow.clientId === clientId) {
51
+ flow.callbackServer?.stop().catch(() => { });
52
+ this.oauthFlows.delete(providerId);
53
+ }
54
+ }
55
+ }
56
+ setupAwaitOAuthCallback() {
57
+ this.transport.onRequest(ProviderEvents.AWAIT_OAUTH_CALLBACK, async (data) => {
58
+ const flow = this.oauthFlows.get(data.providerId);
59
+ if (!flow?.callbackServer) {
60
+ return { error: 'No active OAuth flow for this provider', success: false };
61
+ }
62
+ if (flow.awaitInProgress) {
63
+ return { error: 'OAuth callback is already being awaited for this provider', success: false };
64
+ }
65
+ flow.awaitInProgress = true;
66
+ try {
67
+ // Block until callback or timeout (5 min default in ProviderCallbackServer)
68
+ const callbackResult = await flow.callbackServer.waitForCallback(flow.state);
69
+ // Exchange code for tokens
70
+ const providerDef = getProviderById(data.providerId);
71
+ if (!providerDef?.oauth) {
72
+ return { error: 'Provider does not support OAuth', success: false };
73
+ }
74
+ const oauthConfig = providerDef.oauth;
75
+ const contentType = oauthConfig.tokenContentType === 'form' ? 'application/x-www-form-urlencoded' : 'application/json';
76
+ const tokens = await this.exchangeCodeForTokens({
77
+ clientId: oauthConfig.clientId,
78
+ code: callbackResult.code,
79
+ codeVerifier: flow.codeVerifier,
80
+ contentType,
81
+ redirectUri: oauthConfig.redirectUri,
82
+ tokenUrl: oauthConfig.tokenUrl,
83
+ });
84
+ // Parse JWT id_token for account ID
85
+ const oauthAccountId = tokens.id_token ? parseAccountIdFromIdToken(tokens.id_token) : undefined;
86
+ // Store access token as the "API key" in keychain
87
+ await this.providerKeychainStore.setApiKey(data.providerId, tokens.access_token);
88
+ // Store refresh token + expiry in encrypted OAuth token store
89
+ if (tokens.refresh_token) {
90
+ const expiresAt = tokens.expires_in ? computeExpiresAt(tokens.expires_in) : computeExpiresAt(3600); // 1-hour default when provider omits expires_in
91
+ await this.providerOAuthTokenStore.set(data.providerId, {
92
+ expiresAt,
93
+ refreshToken: tokens.refresh_token,
94
+ });
95
+ }
96
+ // Connect provider — secrets stored in keychain + encrypted token store, not config
97
+ // OAuth providers may define their own default model (e.g., Codex for OpenAI OAuth)
98
+ const defaultModel = oauthConfig.defaultModel ?? providerDef.defaultModel;
99
+ await this.providerConfigStore.connectProvider(data.providerId, {
100
+ activeModel: defaultModel,
101
+ authMethod: 'oauth',
102
+ oauthAccountId,
103
+ });
104
+ // Broadcast update
105
+ this.transport.broadcast(TransportDaemonEventNames.PROVIDER_UPDATED, {});
106
+ return { success: true };
107
+ }
108
+ catch (error) {
109
+ if (error instanceof ProviderCallbackTimeoutError) {
110
+ return { error: 'Authentication timed out. Please try again.', success: false };
111
+ }
112
+ return { error: getErrorMessage(error), success: false };
113
+ }
114
+ finally {
115
+ // Only clean up if this is still the same flow (guard against concurrent START_OAUTH)
116
+ if (this.oauthFlows.get(data.providerId) === flow) {
117
+ await flow.callbackServer?.stop().catch(() => { });
118
+ this.oauthFlows.delete(data.providerId);
119
+ }
120
+ }
121
+ });
122
+ }
123
+ setupCancelOAuth() {
124
+ this.transport.onRequest(ProviderEvents.CANCEL_OAUTH, async (data) => {
125
+ const flow = this.oauthFlows.get(data.providerId);
126
+ if (flow?.callbackServer) {
127
+ await flow.callbackServer.stop().catch(() => { });
128
+ }
129
+ this.oauthFlows.delete(data.providerId);
130
+ return { success: true };
131
+ });
27
132
  }
28
133
  setupConnect() {
29
134
  this.transport.onRequest(ProviderEvents.CONNECT, async (data) => {
@@ -35,6 +140,7 @@ export class ProviderHandler {
35
140
  const provider = getProviderById(providerId);
36
141
  await this.providerConfigStore.connectProvider(providerId, {
37
142
  activeModel: provider?.defaultModel,
143
+ authMethod: apiKey ? 'api-key' : undefined,
38
144
  baseUrl,
39
145
  });
40
146
  this.transport.broadcast(TransportDaemonEventNames.PROVIDER_UPDATED, {});
@@ -46,6 +152,7 @@ export class ProviderHandler {
46
152
  const { providerId } = data;
47
153
  await this.providerConfigStore.disconnectProvider(providerId);
48
154
  await this.providerKeychainStore.deleteApiKey(providerId);
155
+ await this.providerOAuthTokenStore.delete(providerId);
49
156
  this.transport.broadcast(TransportDaemonEventNames.PROVIDER_UPDATED, {});
50
157
  return { success: true };
51
158
  });
@@ -64,19 +171,28 @@ export class ProviderHandler {
64
171
  processLog(`[ProviderHandler] getActiveProvider failed: ${error instanceof Error ? error.message : String(error)}`);
65
172
  return '';
66
173
  });
67
- const providers = await Promise.all(definitions.map(async (def) => ({
68
- apiKeyUrl: def.apiKeyUrl,
69
- category: def.category,
70
- description: def.description,
71
- id: def.id,
72
- isConnected: await this.providerConfigStore.isProviderConnected(def.id).catch((error) => {
73
- processLog(`[ProviderHandler] isProviderConnected failed for ${def.id}: ${error instanceof Error ? error.message : String(error)}`);
74
- return false;
75
- }),
76
- isCurrent: def.id === activeProviderId,
77
- name: def.name,
78
- requiresApiKey: providerRequiresApiKey(def.id),
79
- })));
174
+ const config = await this.providerConfigStore.read().catch(() => null);
175
+ const providers = await Promise.all(definitions.map(async (def) => {
176
+ const providerConfig = config?.providers[def.id];
177
+ const authMethod = providerConfig?.authMethod;
178
+ return {
179
+ apiKeyUrl: def.apiKeyUrl,
180
+ authMethod,
181
+ category: def.category,
182
+ description: def.description,
183
+ id: def.id,
184
+ isConnected: await this.providerConfigStore.isProviderConnected(def.id).catch((error) => {
185
+ processLog(`[ProviderHandler] isProviderConnected failed for ${def.id}: ${error instanceof Error ? error.message : String(error)}`);
186
+ return false;
187
+ }),
188
+ isCurrent: def.id === activeProviderId,
189
+ name: def.name,
190
+ oauthCallbackMode: def.oauth?.callbackMode,
191
+ oauthLabel: def.oauth?.modes[0]?.label,
192
+ requiresApiKey: providerRequiresApiKey(def.id, authMethod),
193
+ supportsOAuth: Boolean(def.oauth),
194
+ };
195
+ }));
80
196
  return { providers };
81
197
  });
82
198
  }
@@ -87,6 +203,92 @@ export class ProviderHandler {
87
203
  return { success: true };
88
204
  });
89
205
  }
206
+ /* eslint-disable camelcase -- OAuth query params follow RFC 6749 naming */
207
+ setupStartOAuth() {
208
+ this.transport.onRequest(ProviderEvents.START_OAUTH, async (data, clientId) => {
209
+ const providerDef = getProviderById(data.providerId);
210
+ if (!providerDef?.oauth) {
211
+ const errorResponse = {
212
+ authUrl: '',
213
+ callbackMode: 'auto',
214
+ error: 'Provider does not support OAuth',
215
+ success: false,
216
+ };
217
+ return errorResponse;
218
+ }
219
+ try {
220
+ const oauthConfig = providerDef.oauth;
221
+ // Clean up any existing flow for this provider (race condition guard)
222
+ const existingFlow = this.oauthFlows.get(data.providerId);
223
+ if (existingFlow?.callbackServer) {
224
+ await existingFlow.callbackServer.stop().catch(() => { });
225
+ }
226
+ this.oauthFlows.delete(data.providerId);
227
+ // Generate PKCE parameters
228
+ const pkce = this.generatePkce();
229
+ // Build auth URL
230
+ const mode = oauthConfig.modes.find((m) => m.id === (data.mode ?? 'default')) ?? oauthConfig.modes[0];
231
+ const params = new URLSearchParams({
232
+ client_id: oauthConfig.clientId,
233
+ code_challenge: pkce.codeChallenge,
234
+ code_challenge_method: 'S256',
235
+ redirect_uri: oauthConfig.redirectUri,
236
+ response_type: 'code',
237
+ scope: oauthConfig.scopes,
238
+ state: pkce.state,
239
+ });
240
+ // Provider-specific extra params (e.g. OpenAI's codex_cli_simplified_flow)
241
+ if (oauthConfig.extraParams) {
242
+ for (const [key, value] of Object.entries(oauthConfig.extraParams)) {
243
+ params.set(key, value);
244
+ }
245
+ }
246
+ const authUrl = `${mode.authUrl}?${params.toString()}`;
247
+ // Start callback server for auto mode
248
+ let callbackServer;
249
+ if (oauthConfig.callbackMode === 'auto' && oauthConfig.callbackPort) {
250
+ callbackServer = this.createCallbackServer({ port: oauthConfig.callbackPort });
251
+ await callbackServer.start();
252
+ }
253
+ // Store flow state
254
+ this.oauthFlows.set(data.providerId, {
255
+ callbackServer,
256
+ clientId,
257
+ codeVerifier: pkce.codeVerifier,
258
+ state: pkce.state,
259
+ });
260
+ // Open browser (non-fatal on failure)
261
+ try {
262
+ await this.browserLauncher.open(authUrl);
263
+ }
264
+ catch {
265
+ processLog(`[ProviderHandler] Browser launch failed for OAuth — user can copy the URL`);
266
+ }
267
+ return { authUrl, callbackMode: oauthConfig.callbackMode, success: true };
268
+ }
269
+ catch (error) {
270
+ // Clean up callback server if it was started but flow setup failed
271
+ const partialFlow = this.oauthFlows.get(data.providerId);
272
+ if (partialFlow?.callbackServer) {
273
+ await partialFlow.callbackServer.stop().catch(() => { });
274
+ }
275
+ this.oauthFlows.delete(data.providerId);
276
+ const errorResponse = {
277
+ authUrl: '',
278
+ callbackMode: 'auto',
279
+ error: getErrorMessage(error),
280
+ success: false,
281
+ };
282
+ return errorResponse;
283
+ }
284
+ });
285
+ }
286
+ /* eslint-enable camelcase */
287
+ setupSubmitOAuthCode() {
288
+ this.transport.onRequest(ProviderEvents.SUBMIT_OAUTH_CODE,
289
+ // Stub for M2 (Anthropic code-paste flow)
290
+ async () => ({ error: 'Code submission is not yet supported for this provider', success: false }));
291
+ }
90
292
  setupValidateApiKey() {
91
293
  this.transport.onRequest(ProviderEvents.VALIDATE_API_KEY, async (data) => {
92
294
  try {