byterover-cli 2.1.5 → 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 (110) 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/locations.d.ts +14 -0
  5. package/dist/oclif/commands/locations.js +68 -0
  6. package/dist/oclif/commands/model/switch.js +14 -3
  7. package/dist/oclif/commands/providers/connect.d.ts +9 -0
  8. package/dist/oclif/commands/providers/connect.js +110 -14
  9. package/dist/oclif/commands/providers/list.js +3 -5
  10. package/dist/oclif/commands/query.js +2 -2
  11. package/dist/oclif/commands/status.js +3 -3
  12. package/dist/oclif/lib/daemon-client.d.ts +4 -0
  13. package/dist/oclif/lib/daemon-client.js +13 -3
  14. package/dist/server/core/domain/entities/provider-config.d.ts +6 -0
  15. package/dist/server/core/domain/entities/provider-config.js +4 -3
  16. package/dist/server/core/domain/entities/provider-registry.d.ts +41 -1
  17. package/dist/server/core/domain/entities/provider-registry.js +24 -1
  18. package/dist/server/core/domain/errors/task-error.d.ts +2 -0
  19. package/dist/server/core/domain/errors/task-error.js +6 -1
  20. package/dist/server/core/domain/transport/schemas.d.ts +2 -0
  21. package/dist/server/core/interfaces/i-provider-config-store.d.ts +2 -0
  22. package/dist/server/core/interfaces/i-provider-model-fetcher.d.ts +12 -3
  23. package/dist/server/core/interfaces/i-provider-oauth-token-store.d.ts +24 -0
  24. package/dist/server/core/interfaces/i-provider-oauth-token-store.js +1 -0
  25. package/dist/server/core/interfaces/i-token-refresh-manager.d.ts +7 -0
  26. package/dist/server/core/interfaces/i-token-refresh-manager.js +1 -0
  27. package/dist/server/infra/daemon/agent-process.js +22 -4
  28. package/dist/server/infra/daemon/brv-server.js +15 -2
  29. package/dist/server/infra/http/models-dev-client.d.ts +29 -0
  30. package/dist/server/infra/http/models-dev-client.js +133 -0
  31. package/dist/server/infra/http/provider-model-fetcher-registry.d.ts +2 -1
  32. package/dist/server/infra/http/provider-model-fetcher-registry.js +6 -1
  33. package/dist/server/infra/http/provider-model-fetchers.d.ts +33 -8
  34. package/dist/server/infra/http/provider-model-fetchers.js +88 -10
  35. package/dist/server/infra/process/feature-handlers.d.ts +6 -1
  36. package/dist/server/infra/process/feature-handlers.js +11 -2
  37. package/dist/server/infra/provider/provider-config-resolver.d.ts +4 -2
  38. package/dist/server/infra/provider/provider-config-resolver.js +59 -4
  39. package/dist/server/infra/provider-oauth/callback-server.d.ts +24 -0
  40. package/dist/server/infra/provider-oauth/callback-server.js +203 -0
  41. package/dist/server/infra/provider-oauth/errors.d.ts +39 -0
  42. package/dist/server/infra/provider-oauth/errors.js +76 -0
  43. package/dist/server/infra/provider-oauth/index.d.ts +9 -0
  44. package/dist/server/infra/provider-oauth/index.js +9 -0
  45. package/dist/server/infra/provider-oauth/jwt-utils.d.ts +17 -0
  46. package/dist/server/infra/provider-oauth/jwt-utils.js +51 -0
  47. package/dist/server/infra/provider-oauth/pkce-service.d.ts +22 -0
  48. package/dist/server/infra/provider-oauth/pkce-service.js +33 -0
  49. package/dist/server/infra/provider-oauth/provider-oauth-token-store.d.ts +48 -0
  50. package/dist/server/infra/provider-oauth/provider-oauth-token-store.js +155 -0
  51. package/dist/server/infra/provider-oauth/refresh-token-exchange.d.ts +8 -0
  52. package/dist/server/infra/provider-oauth/refresh-token-exchange.js +39 -0
  53. package/dist/server/infra/provider-oauth/token-exchange.d.ts +8 -0
  54. package/dist/server/infra/provider-oauth/token-exchange.js +44 -0
  55. package/dist/server/infra/provider-oauth/token-refresh-manager.d.ts +32 -0
  56. package/dist/server/infra/provider-oauth/token-refresh-manager.js +96 -0
  57. package/dist/server/infra/provider-oauth/types.d.ts +55 -0
  58. package/dist/server/infra/provider-oauth/types.js +22 -0
  59. package/dist/server/infra/storage/file-provider-config-store.d.ts +2 -0
  60. package/dist/server/infra/storage/file-provider-config-store.js +1 -3
  61. package/dist/server/infra/transport/handlers/index.d.ts +2 -0
  62. package/dist/server/infra/transport/handlers/index.js +1 -0
  63. package/dist/server/infra/transport/handlers/locations-handler.d.ts +25 -0
  64. package/dist/server/infra/transport/handlers/locations-handler.js +64 -0
  65. package/dist/server/infra/transport/handlers/model-handler.d.ts +3 -0
  66. package/dist/server/infra/transport/handlers/model-handler.js +53 -11
  67. package/dist/server/infra/transport/handlers/provider-handler.d.ts +26 -0
  68. package/dist/server/infra/transport/handlers/provider-handler.js +215 -13
  69. package/dist/server/templates/skill/SKILL.md +19 -1
  70. package/dist/shared/constants/oauth.d.ts +14 -0
  71. package/dist/shared/constants/oauth.js +14 -0
  72. package/dist/shared/transport/events/index.d.ts +8 -0
  73. package/dist/shared/transport/events/index.js +3 -0
  74. package/dist/shared/transport/events/locations-events.d.ts +7 -0
  75. package/dist/shared/transport/events/locations-events.js +3 -0
  76. package/dist/shared/transport/events/model-events.d.ts +2 -0
  77. package/dist/shared/transport/events/provider-events.d.ts +36 -0
  78. package/dist/shared/transport/events/provider-events.js +5 -0
  79. package/dist/shared/transport/types/dto.d.ts +15 -0
  80. package/dist/tui/features/commands/definitions/index.js +2 -0
  81. package/dist/tui/features/commands/definitions/locations.d.ts +2 -0
  82. package/dist/tui/features/commands/definitions/locations.js +11 -0
  83. package/dist/tui/features/locations/api/get-locations.d.ts +16 -0
  84. package/dist/tui/features/locations/api/get-locations.js +17 -0
  85. package/dist/tui/features/locations/components/locations-view.d.ts +3 -0
  86. package/dist/tui/features/locations/components/locations-view.js +25 -0
  87. package/dist/tui/features/locations/utils/format-locations.d.ts +2 -0
  88. package/dist/tui/features/locations/utils/format-locations.js +26 -0
  89. package/dist/tui/features/model/api/set-active-model.d.ts +1 -1
  90. package/dist/tui/features/model/api/set-active-model.js +12 -4
  91. package/dist/tui/features/provider/api/await-oauth-callback.d.ts +11 -0
  92. package/dist/tui/features/provider/api/await-oauth-callback.js +25 -0
  93. package/dist/tui/features/provider/api/cancel-oauth.d.ts +5 -0
  94. package/dist/tui/features/provider/api/cancel-oauth.js +10 -0
  95. package/dist/tui/features/provider/api/start-oauth.d.ts +11 -0
  96. package/dist/tui/features/provider/api/start-oauth.js +15 -0
  97. package/dist/tui/features/provider/components/auth-method-dialog.d.ts +9 -0
  98. package/dist/tui/features/provider/components/auth-method-dialog.js +20 -0
  99. package/dist/tui/features/provider/components/oauth-dialog.d.ts +9 -0
  100. package/dist/tui/features/provider/components/oauth-dialog.js +96 -0
  101. package/dist/tui/features/provider/components/provider-dialog.js +1 -1
  102. package/dist/tui/features/provider/components/provider-flow.js +54 -4
  103. package/dist/tui/features/provider/components/provider-subscription-initializer.d.ts +1 -0
  104. package/dist/tui/features/provider/components/provider-subscription-initializer.js +5 -0
  105. package/dist/tui/features/provider/hooks/use-provider-subscriptions.d.ts +6 -0
  106. package/dist/tui/features/provider/hooks/use-provider-subscriptions.js +24 -0
  107. package/dist/tui/providers/app-providers.js +2 -1
  108. package/dist/tui/utils/error-messages.js +6 -1
  109. package/oclif.manifest.json +56 -1
  110. package/package.json +1 -1
@@ -0,0 +1,76 @@
1
+ export class ProviderOAuthError extends Error {
2
+ constructor(message) {
3
+ super(message);
4
+ this.name = 'ProviderOAuthError';
5
+ }
6
+ }
7
+ export class ProviderCallbackTimeoutError extends ProviderOAuthError {
8
+ timeoutMs;
9
+ constructor(timeoutMs) {
10
+ super(`OAuth callback timed out after ${timeoutMs}ms`);
11
+ this.name = 'ProviderCallbackTimeoutError';
12
+ this.timeoutMs = timeoutMs;
13
+ }
14
+ }
15
+ export class ProviderCallbackStateError extends ProviderOAuthError {
16
+ constructor() {
17
+ super('OAuth callback state mismatch — possible CSRF attack');
18
+ this.name = 'ProviderCallbackStateError';
19
+ }
20
+ }
21
+ export class ProviderCallbackOAuthError extends ProviderOAuthError {
22
+ errorCode;
23
+ constructor(errorCode, errorDescription) {
24
+ super(errorDescription ?? `OAuth provider returned error: ${errorCode}`);
25
+ this.name = 'ProviderCallbackOAuthError';
26
+ this.errorCode = errorCode;
27
+ }
28
+ }
29
+ export class ProviderTokenExchangeError extends ProviderOAuthError {
30
+ errorCode;
31
+ statusCode;
32
+ constructor(params) {
33
+ super(params.message);
34
+ this.name = 'ProviderTokenExchangeError';
35
+ this.errorCode = params.errorCode;
36
+ this.statusCode = params.statusCode;
37
+ }
38
+ }
39
+ /**
40
+ * Extracts OAuth error fields from an unknown error response body.
41
+ * Shared by token-exchange and refresh-token-exchange.
42
+ */
43
+ export function extractOAuthErrorFields(data) {
44
+ if (typeof data !== 'object' || data === null) {
45
+ return {};
46
+ }
47
+ return {
48
+ error: 'error' in data && typeof data.error === 'string' ? data.error : undefined,
49
+ // eslint-disable-next-line camelcase
50
+ error_description: 'error_description' in data && typeof data.error_description === 'string' ? data.error_description : undefined,
51
+ };
52
+ }
53
+ /**
54
+ * Checks whether an OAuth token refresh error is permanent (token revoked, client invalid)
55
+ * vs. transient (network timeout, server error).
56
+ *
57
+ * Permanent errors require disconnecting the provider and re-authenticating.
58
+ * Transient errors should preserve credentials so the existing access token can still be used.
59
+ */
60
+ export function isPermanentOAuthError(error) {
61
+ if (!(error instanceof ProviderTokenExchangeError)) {
62
+ return false;
63
+ }
64
+ // 401/403 are unconditionally permanent (credentials rejected)
65
+ if (error.statusCode && [401, 403].includes(error.statusCode)) {
66
+ return true;
67
+ }
68
+ // 400 is only permanent when the OAuth error code explicitly indicates it.
69
+ // A 400 with an unknown or transient error code (e.g. temporarily_unavailable)
70
+ // should preserve credentials so the existing access token can still be used.
71
+ const permanentErrorCodes = new Set(['invalid_client', 'invalid_grant', 'unauthorized_client']);
72
+ if (error.errorCode && permanentErrorCodes.has(error.errorCode)) {
73
+ return true;
74
+ }
75
+ return false;
76
+ }
@@ -0,0 +1,9 @@
1
+ export * from './callback-server.js';
2
+ export * from './errors.js';
3
+ export * from './jwt-utils.js';
4
+ export * from './pkce-service.js';
5
+ export * from './provider-oauth-token-store.js';
6
+ export * from './refresh-token-exchange.js';
7
+ export * from './token-exchange.js';
8
+ export * from './token-refresh-manager.js';
9
+ export * from './types.js';
@@ -0,0 +1,9 @@
1
+ export * from './callback-server.js';
2
+ export * from './errors.js';
3
+ export * from './jwt-utils.js';
4
+ export * from './pkce-service.js';
5
+ export * from './provider-oauth-token-store.js';
6
+ export * from './refresh-token-exchange.js';
7
+ export * from './token-exchange.js';
8
+ export * from './token-refresh-manager.js';
9
+ export * from './types.js';
@@ -0,0 +1,17 @@
1
+ /**
2
+ * JWT Utilities for Provider OAuth
3
+ *
4
+ * Parses provider-specific claims from OAuth id_tokens.
5
+ * Uses manual base64url decoding — no external JWT library needed.
6
+ */
7
+ /**
8
+ * Extracts the ChatGPT Account ID from an OpenAI id_token.
9
+ *
10
+ * Checks claims in priority order:
11
+ * 1. `chatgpt_account_id` (top-level claim)
12
+ * 2. `["https://api.openai.com/auth"].chatgpt_account_id` (nested claim)
13
+ * 3. `organizations[0].id` (fallback)
14
+ *
15
+ * @returns The account ID string, or undefined if not found or token is malformed
16
+ */
17
+ export declare function parseAccountIdFromIdToken(idToken: string): string | undefined;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * JWT Utilities for Provider OAuth
3
+ *
4
+ * Parses provider-specific claims from OAuth id_tokens.
5
+ * Uses manual base64url decoding — no external JWT library needed.
6
+ */
7
+ function isRecord(value) {
8
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
9
+ }
10
+ /**
11
+ * Extracts the ChatGPT Account ID from an OpenAI id_token.
12
+ *
13
+ * Checks claims in priority order:
14
+ * 1. `chatgpt_account_id` (top-level claim)
15
+ * 2. `["https://api.openai.com/auth"].chatgpt_account_id` (nested claim)
16
+ * 3. `organizations[0].id` (fallback)
17
+ *
18
+ * @returns The account ID string, or undefined if not found or token is malformed
19
+ */
20
+ export function parseAccountIdFromIdToken(idToken) {
21
+ try {
22
+ const parts = idToken.split('.');
23
+ if (parts.length < 2 || !parts[1])
24
+ return undefined;
25
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
26
+ if (!isRecord(payload))
27
+ return undefined;
28
+ // 1. Top-level claim
29
+ if (typeof payload.chatgpt_account_id === 'string' && payload.chatgpt_account_id) {
30
+ return payload.chatgpt_account_id;
31
+ }
32
+ // 2. Nested claim under OpenAI auth namespace
33
+ const authNamespace = payload['https://api.openai.com/auth'];
34
+ if (isRecord(authNamespace) &&
35
+ typeof authNamespace.chatgpt_account_id === 'string' &&
36
+ authNamespace.chatgpt_account_id) {
37
+ return authNamespace.chatgpt_account_id;
38
+ }
39
+ // 3. Organizations fallback
40
+ if (Array.isArray(payload.organizations) && payload.organizations.length > 0) {
41
+ const org = payload.organizations[0];
42
+ if (isRecord(org) && typeof org.id === 'string' && org.id) {
43
+ return org.id;
44
+ }
45
+ }
46
+ return undefined;
47
+ }
48
+ catch {
49
+ return undefined;
50
+ }
51
+ }
@@ -0,0 +1,22 @@
1
+ import type { PkceParameters } from './types.js';
2
+ /**
3
+ * Generates a cryptographically secure PKCE code verifier.
4
+ * Output is 43 characters (base64url encoding of 32 random bytes).
5
+ * Meets the OAuth 2.0 PKCE spec requirement of 43-128 characters.
6
+ */
7
+ export declare function generateCodeVerifier(): string;
8
+ /**
9
+ * Generates S256 code challenge from a code verifier.
10
+ * SHA-256 hash of the verifier, base64url-encoded.
11
+ */
12
+ export declare function generateCodeChallenge(codeVerifier: string): string;
13
+ /**
14
+ * Generates a cryptographically secure state parameter for CSRF protection.
15
+ * Output is 22 characters (base64url encoding of 16 random bytes).
16
+ */
17
+ export declare function generateState(): string;
18
+ /**
19
+ * Generates a complete set of PKCE parameters for an authorization request.
20
+ * Convenience function combining verifier, challenge, and state generation.
21
+ */
22
+ export declare function generatePkce(): PkceParameters;
@@ -0,0 +1,33 @@
1
+ import crypto from 'node:crypto';
2
+ /**
3
+ * Generates a cryptographically secure PKCE code verifier.
4
+ * Output is 43 characters (base64url encoding of 32 random bytes).
5
+ * Meets the OAuth 2.0 PKCE spec requirement of 43-128 characters.
6
+ */
7
+ export function generateCodeVerifier() {
8
+ return crypto.randomBytes(32).toString('base64url');
9
+ }
10
+ /**
11
+ * Generates S256 code challenge from a code verifier.
12
+ * SHA-256 hash of the verifier, base64url-encoded.
13
+ */
14
+ export function generateCodeChallenge(codeVerifier) {
15
+ return crypto.createHash('sha256').update(codeVerifier).digest('base64url');
16
+ }
17
+ /**
18
+ * Generates a cryptographically secure state parameter for CSRF protection.
19
+ * Output is 22 characters (base64url encoding of 16 random bytes).
20
+ */
21
+ export function generateState() {
22
+ return crypto.randomBytes(16).toString('base64url');
23
+ }
24
+ /**
25
+ * Generates a complete set of PKCE parameters for an authorization request.
26
+ * Convenience function combining verifier, challenge, and state generation.
27
+ */
28
+ export function generatePkce() {
29
+ const codeVerifier = generateCodeVerifier();
30
+ const codeChallenge = generateCodeChallenge(codeVerifier);
31
+ const state = generateState();
32
+ return { codeChallenge, codeVerifier, state };
33
+ }
@@ -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
+ }