ethagent 2.1.0 → 2.2.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 (61) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/src/auth/openaiOAuth/credentials.ts +47 -0
  4. package/src/auth/openaiOAuth/crypto.ts +23 -0
  5. package/src/auth/openaiOAuth/index.ts +238 -0
  6. package/src/auth/openaiOAuth/landingPage.ts +125 -0
  7. package/src/auth/openaiOAuth/listener.ts +151 -0
  8. package/src/auth/openaiOAuth/refresh.ts +70 -0
  9. package/src/auth/openaiOAuth/shared.ts +115 -0
  10. package/src/chat/chatSessionState.ts +2 -1
  11. package/src/chat/commands.ts +2 -1
  12. package/src/identity/ens/agentRecords.ts +5 -19
  13. package/src/identity/ens/ensAutomation/setup.ts +0 -1
  14. package/src/identity/ens/ensAutomation/types.ts +0 -1
  15. package/src/identity/hub/OperationalRoutes.tsx +2 -11
  16. package/src/identity/hub/components/IdentitySummary.tsx +8 -3
  17. package/src/identity/hub/components/MenuScreen.tsx +1 -2
  18. package/src/identity/hub/components/menuFlagsFromReconciliation.ts +1 -3
  19. package/src/identity/hub/effects/ens/transactions.ts +15 -15
  20. package/src/identity/hub/effects/index.ts +0 -1
  21. package/src/identity/hub/effects/profile/profileState.ts +12 -4
  22. package/src/identity/hub/effects/publicProfile/runPublicProfileSave.ts +37 -159
  23. package/src/identity/hub/effects/rebackup/runRebackup.ts +2 -2
  24. package/src/identity/hub/effects/restoreAdmin.ts +2 -61
  25. package/src/identity/hub/effects/shared/sync.ts +3 -44
  26. package/src/identity/hub/flows/custody/CustodyEditFlow.tsx +1 -39
  27. package/src/identity/hub/flows/custody/custodyFlowActions.ts +5 -3
  28. package/src/identity/hub/flows/custody/custodyFlowTypes.ts +1 -1
  29. package/src/identity/hub/flows/ens/EnsEditAdvancedScreens.tsx +80 -175
  30. package/src/identity/hub/flows/ens/EnsEditFlow.tsx +20 -75
  31. package/src/identity/hub/flows/ens/EnsEditMaintenanceScreens.tsx +16 -56
  32. package/src/identity/hub/flows/ens/EnsEditReviewScreens.tsx +0 -18
  33. package/src/identity/hub/flows/ens/EnsEditRunners.tsx +0 -136
  34. package/src/identity/hub/flows/ens/EnsEditShared.tsx +5 -4
  35. package/src/identity/hub/flows/ens/EnsEditSimpleScreens.tsx +56 -205
  36. package/src/identity/hub/flows/ens/IdentityHubEnsFlow.tsx +7 -0
  37. package/src/identity/hub/flows/ens/OperatorWalletsScreen.tsx +0 -31
  38. package/src/identity/hub/flows/ens/ensEditCopy.ts +1 -1
  39. package/src/identity/hub/flows/ens/ensEditTypes.ts +6 -20
  40. package/src/identity/hub/flows/profile/EditProfileFlow.tsx +7 -0
  41. package/src/identity/hub/flows/restore/RestoreFlow.tsx +5 -5
  42. package/src/identity/hub/reconciliation/agentReconciliation/hook.ts +0 -1
  43. package/src/identity/hub/reconciliation/agentReconciliation/run.ts +1 -34
  44. package/src/identity/hub/reconciliation/agentReconciliation/types.ts +0 -4
  45. package/src/identity/hub/reconciliation/index.ts +0 -7
  46. package/src/identity/hub/reconciliation/walletSetup.ts +1 -194
  47. package/src/identity/wallet/browserWallet/types.ts +0 -5
  48. package/src/identity/wallet/page/copy.ts +1 -31
  49. package/src/identity/wallet/walletPurposeCompat.ts +0 -2
  50. package/src/models/ModelPicker.tsx +246 -8
  51. package/src/models/catalog.ts +28 -1
  52. package/src/models/modelPickerOptions.ts +15 -1
  53. package/src/providers/openai-responses-format.ts +156 -0
  54. package/src/providers/openai-responses.ts +276 -0
  55. package/src/providers/registry.ts +85 -8
  56. package/src/runtime/systemPrompt.ts +1 -1
  57. package/src/runtime/turn.ts +0 -1
  58. package/src/storage/secrets.ts +4 -1
  59. package/src/tools/privateContinuityEditTool.ts +6 -0
  60. package/src/utils/openExternal.ts +20 -10
  61. package/src/identity/ens/ensRegistration.ts +0 -199
package/README.md CHANGED
@@ -49,7 +49,7 @@ Once running:
49
49
  The Identity Hub manages everything portable about the agent:
50
50
 
51
51
  - **Public Profile** edits name, description, icon, and the Agent Card.
52
- - **ENS Name** links the agent to a subdomain and authorizes operator wallets to write the subdomain's records.
52
+ - **ENS Name** links the agent to a subdomain under a parent name the owner wallet controls.
53
53
  - **Custody Mode** switches between Simple and Advanced by depositing the token into its Vault or unwrapping it back out.
54
54
  - **Prepare Transfer** stages a dual-wallet snapshot before sending the token externally.
55
55
  - **Refetch Latest** pulls the most recent published snapshot back to local files.
@@ -89,7 +89,7 @@ The vault is an immutable Foundry contract at `contracts/src/Vault.sol`. New vau
89
89
 
90
90
  Subdomains live under a parent name you control, never on root `.eth` names directly. You keep `you.eth`; the agent gets `agent.you.eth`. The split makes the boundary explicit: one address speaks for the human, the other speaks for the agent.
91
91
 
92
- For agents in Advanced custody, the owner wallet approves operator wallets on the resolver once. After that, an approved operator wallet can update the agent's ENS profile pointer (the IPFS CID for the latest agent card) on every snapshot save without another owner signature.
92
+ ENS records stay owner-controlled in both custody modes. Operator wallets in Advanced custody rotate the ERC-8004 token URI through the Vault (see Custody Modes), not ENS. Any ENS text-record update requires an owner signature.
93
93
 
94
94
  Save the token ID + network somewhere safe. ENS records can be cleared and rebuilt; the token ID is the durable handle.
95
95
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ethagent",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "A privacy-first AI agent with a portable Ethereum identity",
5
5
  "type": "module",
6
6
  "main": "bin/ethagent.js",
@@ -0,0 +1,47 @@
1
+ import { getSecret, rmSecret, setSecret } from '../../storage/secrets.js'
2
+
3
+ const ACCOUNT = 'openai-oauth'
4
+
5
+ export type OpenAIOAuthCredentials = {
6
+ accessToken: string
7
+ refreshToken: string
8
+ idToken?: string
9
+ accountId?: string
10
+ expiresAt: number
11
+ lastRefreshAt: number
12
+ }
13
+
14
+ export async function getOpenAIOAuthCredentials(): Promise<OpenAIOAuthCredentials | null> {
15
+ const raw = await getSecret(ACCOUNT)
16
+ if (!raw) return null
17
+ try {
18
+ const parsed = JSON.parse(raw) as Partial<OpenAIOAuthCredentials>
19
+ if (typeof parsed.accessToken !== 'string' || !parsed.accessToken) return null
20
+ if (typeof parsed.refreshToken !== 'string' || !parsed.refreshToken) return null
21
+ if (typeof parsed.expiresAt !== 'number' || !Number.isFinite(parsed.expiresAt)) return null
22
+ if (typeof parsed.lastRefreshAt !== 'number' || !Number.isFinite(parsed.lastRefreshAt)) return null
23
+ return {
24
+ accessToken: parsed.accessToken,
25
+ refreshToken: parsed.refreshToken,
26
+ idToken: typeof parsed.idToken === 'string' ? parsed.idToken : undefined,
27
+ accountId: typeof parsed.accountId === 'string' ? parsed.accountId : undefined,
28
+ expiresAt: parsed.expiresAt,
29
+ lastRefreshAt: parsed.lastRefreshAt,
30
+ }
31
+ } catch {
32
+ return null
33
+ }
34
+ }
35
+
36
+ export async function setOpenAIOAuthCredentials(creds: OpenAIOAuthCredentials): Promise<void> {
37
+ await setSecret(ACCOUNT, JSON.stringify(creds))
38
+ }
39
+
40
+ export async function rmOpenAIOAuthCredentials(): Promise<void> {
41
+ await rmSecret(ACCOUNT)
42
+ }
43
+
44
+ export async function hasOpenAIOAuthCredentials(): Promise<boolean> {
45
+ const creds = await getOpenAIOAuthCredentials()
46
+ return creds !== null
47
+ }
@@ -0,0 +1,23 @@
1
+ import { randomBytes, webcrypto } from 'node:crypto'
2
+
3
+ function base64UrlEncode(buffer: Buffer): string {
4
+ return buffer
5
+ .toString('base64')
6
+ .replace(/\+/g, '-')
7
+ .replace(/\//g, '_')
8
+ .replace(/=/g, '')
9
+ }
10
+
11
+ export function generateCodeVerifier(): string {
12
+ return base64UrlEncode(randomBytes(32))
13
+ }
14
+
15
+ export async function generateCodeChallenge(verifier: string): Promise<string> {
16
+ const encoded = new TextEncoder().encode(verifier)
17
+ const digest = await webcrypto.subtle.digest('SHA-256', encoded)
18
+ return base64UrlEncode(Buffer.from(digest))
19
+ }
20
+
21
+ export function generateState(): string {
22
+ return base64UrlEncode(randomBytes(32))
23
+ }
@@ -0,0 +1,238 @@
1
+ import { AuthCodeListener } from './listener.js'
2
+ import { generateCodeChallenge, generateCodeVerifier, generateState } from './crypto.js'
3
+ import {
4
+ OPENAI_OAUTH_CALLBACK_PORT,
5
+ OPENAI_OAUTH_CLIENT_ID,
6
+ OPENAI_OAUTH_ISSUER,
7
+ OPENAI_OAUTH_ORIGINATOR,
8
+ OPENAI_OAUTH_SCOPE,
9
+ OPENAI_OAUTH_TOKEN_URL,
10
+ asTrimmedString,
11
+ exchangeIdTokenForApiKey,
12
+ parseChatgptAccountId,
13
+ } from './shared.js'
14
+ import { setOpenAIOAuthCredentials } from './credentials.js'
15
+ import { renderOAuthLandingPage } from './landingPage.js'
16
+
17
+ type OpenAIOAuthTokenResponse = {
18
+ id_token?: string
19
+ access_token?: string
20
+ refresh_token?: string
21
+ expires_in?: number
22
+ }
23
+
24
+ export type OpenAIOAuthResult =
25
+ | { kind: 'apikey'; apiKey: string; accountId?: string }
26
+ | { kind: 'oauth-only'; accountId?: string; reason?: string }
27
+
28
+ export type OpenAIOAuthOnReady = (authUrl: string) => void | Promise<void>
29
+
30
+ function buildAuthorizeUrl(args: { port: number; codeChallenge: string; state: string }): string {
31
+ const redirectUri = `http://localhost:${args.port}/auth/callback`
32
+ const params: Array<[string, string]> = [
33
+ ['response_type', 'code'],
34
+ ['client_id', OPENAI_OAUTH_CLIENT_ID],
35
+ ['redirect_uri', redirectUri],
36
+ ['scope', OPENAI_OAUTH_SCOPE],
37
+ ['code_challenge', args.codeChallenge],
38
+ ['code_challenge_method', 'S256'],
39
+ ['id_token_add_organizations', 'true'],
40
+ ['codex_cli_simplified_flow', 'true'],
41
+ ['state', args.state],
42
+ ['originator', OPENAI_OAUTH_ORIGINATOR],
43
+ ]
44
+ const qs = params
45
+ .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
46
+ .join('&')
47
+ return `${OPENAI_OAUTH_ISSUER}/oauth/authorize?${qs}`
48
+ }
49
+
50
+ function renderSuccessPage(): string {
51
+ return renderOAuthLandingPage({
52
+ tone: 'success',
53
+ pageTitle: 'OpenAI Sign-in Complete',
54
+ headline: 'OpenAI sign-in complete',
55
+ message: 'You can return to your terminal.',
56
+ })
57
+ }
58
+
59
+ function renderErrorPage(reason: string): string {
60
+ return renderOAuthLandingPage({
61
+ tone: 'error',
62
+ pageTitle: 'OpenAI Sign-in Failed',
63
+ headline: 'OpenAI sign-in failed',
64
+ message: reason,
65
+ })
66
+ }
67
+
68
+ function renderCancelledPage(): string {
69
+ return renderOAuthLandingPage({
70
+ tone: 'cancelled',
71
+ pageTitle: 'OpenAI Sign-in Cancelled',
72
+ headline: 'OpenAI sign-in cancelled',
73
+ message: 'You can return to your terminal.',
74
+ })
75
+ }
76
+
77
+ async function exchangeAuthorizationCode(args: {
78
+ authorizationCode: string
79
+ codeVerifier: string
80
+ port: number
81
+ signal: AbortSignal
82
+ }): Promise<OpenAIOAuthResult> {
83
+ const redirectUri = `http://localhost:${args.port}/auth/callback`
84
+ const body = new URLSearchParams({
85
+ grant_type: 'authorization_code',
86
+ code: args.authorizationCode,
87
+ redirect_uri: redirectUri,
88
+ client_id: OPENAI_OAUTH_CLIENT_ID,
89
+ code_verifier: args.codeVerifier,
90
+ })
91
+
92
+ const response = await fetch(OPENAI_OAUTH_TOKEN_URL, {
93
+ method: 'POST',
94
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
95
+ body,
96
+ signal: AbortSignal.any([args.signal, AbortSignal.timeout(15_000)]),
97
+ })
98
+
99
+ if (!response.ok) {
100
+ const errorText = await response.text().catch(() => '')
101
+ throw new Error(
102
+ errorText.trim()
103
+ ? `OpenAI OAuth token exchange failed (${response.status}): ${errorText.trim()}`
104
+ : `OpenAI OAuth token exchange failed with status ${response.status}.`,
105
+ )
106
+ }
107
+
108
+ const payload = (await response.json()) as OpenAIOAuthTokenResponse
109
+ const accessToken = asTrimmedString(payload.access_token)
110
+ const refreshToken = asTrimmedString(payload.refresh_token)
111
+ const idToken = asTrimmedString(payload.id_token)
112
+ if (!accessToken || !refreshToken) {
113
+ throw new Error('OpenAI OAuth completed, but the response was missing access_token or refresh_token.')
114
+ }
115
+
116
+ const accountId = parseChatgptAccountId(idToken) ?? parseChatgptAccountId(accessToken)
117
+ const expiresIn = typeof payload.expires_in === 'number' && payload.expires_in > 0
118
+ ? payload.expires_in
119
+ : 3600
120
+ const now = Date.now()
121
+ await setOpenAIOAuthCredentials({
122
+ accessToken,
123
+ refreshToken,
124
+ idToken,
125
+ accountId,
126
+ expiresAt: now + expiresIn * 1000,
127
+ lastRefreshAt: now,
128
+ })
129
+
130
+ if (!idToken) {
131
+ return { kind: 'oauth-only', accountId, reason: 'no id_token returned' }
132
+ }
133
+
134
+ try {
135
+ const apiKey = await exchangeIdTokenForApiKey(idToken)
136
+ if (typeof apiKey !== 'string' || apiKey.length === 0) {
137
+ return { kind: 'oauth-only', accountId, reason: 'API key exchange returned an empty token' }
138
+ }
139
+ return { kind: 'apikey', apiKey, accountId }
140
+ } catch (err) {
141
+ const reason = err instanceof Error ? err.message : String(err)
142
+ return { kind: 'oauth-only', accountId, reason }
143
+ }
144
+ }
145
+
146
+ export class OpenAIOAuthService {
147
+ private listener: AuthCodeListener | null = null
148
+ private exchangeAbort: AbortController | null = null
149
+
150
+ async start(onReady: OpenAIOAuthOnReady): Promise<OpenAIOAuthResult> {
151
+ const codeVerifier = generateCodeVerifier()
152
+ const listener = new AuthCodeListener('/auth/callback')
153
+ this.listener = listener
154
+
155
+ let port: number
156
+ try {
157
+ port = await listener.start(OPENAI_OAUTH_CALLBACK_PORT)
158
+ } catch (err) {
159
+ const message = err instanceof Error ? err.message : String(err)
160
+ if (message.includes('EADDRINUSE') || message.includes(String(OPENAI_OAUTH_CALLBACK_PORT))) {
161
+ listener.close()
162
+ this.listener = null
163
+ throw new Error(
164
+ `OpenAI sign-in needs localhost:${OPENAI_OAUTH_CALLBACK_PORT} for its callback. Close any app already using that port and try again.`,
165
+ )
166
+ }
167
+ listener.close()
168
+ this.listener = null
169
+ throw err
170
+ }
171
+
172
+ const state = generateState()
173
+ const codeChallenge = await generateCodeChallenge(codeVerifier)
174
+ const authUrl = buildAuthorizeUrl({ port, codeChallenge, state })
175
+
176
+ try {
177
+ const authorizationCode = await listener.waitForAuthorization(state, async () => {
178
+ await onReady(authUrl)
179
+ })
180
+
181
+ const abort = new AbortController()
182
+ this.exchangeAbort = abort
183
+ let result: OpenAIOAuthResult
184
+ try {
185
+ result = await exchangeAuthorizationCode({
186
+ authorizationCode,
187
+ codeVerifier,
188
+ port,
189
+ signal: abort.signal,
190
+ })
191
+ } finally {
192
+ if (this.exchangeAbort === abort) this.exchangeAbort = null
193
+ }
194
+
195
+ if (this.listener !== listener) {
196
+ throw new Error('OpenAI sign-in was cancelled.')
197
+ }
198
+
199
+ listener.handleSuccessRedirect([], res => {
200
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
201
+ res.end(renderSuccessPage())
202
+ })
203
+
204
+ return result
205
+ } catch (error) {
206
+ const resolved = this.listener === listener
207
+ ? error
208
+ : new Error('OpenAI sign-in was cancelled.')
209
+
210
+ if (listener.hasPendingResponse()) {
211
+ const isCancellation = resolved instanceof Error && resolved.message === 'OpenAI sign-in was cancelled.'
212
+ listener.handleErrorRedirect(res => {
213
+ res.writeHead(isCancellation ? 200 : 400, { 'Content-Type': 'text/html; charset=utf-8' })
214
+ res.end(isCancellation ? renderCancelledPage() : renderErrorPage(
215
+ resolved instanceof Error ? resolved.message : String(resolved),
216
+ ))
217
+ })
218
+ }
219
+ throw resolved
220
+ } finally {
221
+ this.cleanup()
222
+ }
223
+ }
224
+
225
+ cleanup(): void {
226
+ const cancellationError = new Error('OpenAI sign-in was cancelled.')
227
+ this.exchangeAbort?.abort(cancellationError)
228
+ this.exchangeAbort = null
229
+ if (this.listener?.hasPendingResponse()) {
230
+ this.listener.handleErrorRedirect(res => {
231
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
232
+ res.end(renderCancelledPage())
233
+ })
234
+ }
235
+ this.listener?.cancelPendingAuthorization(cancellationError)
236
+ this.listener = null
237
+ }
238
+ }
@@ -0,0 +1,125 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { transformSync } from 'esbuild'
5
+ import { WALLET_CSS } from '../../identity/wallet/page/styles/index.js'
6
+ import { glyphs } from '../../identity/wallet/page/html.js'
7
+ import { escapeHtml } from './shared.js'
8
+
9
+ export type LandingTone = 'success' | 'error' | 'cancelled'
10
+
11
+ const GRAINIENT_SOURCE_FILE = join(
12
+ dirname(fileURLToPath(import.meta.url)),
13
+ '..',
14
+ '..',
15
+ 'identity',
16
+ 'wallet',
17
+ 'page',
18
+ 'grainient.ts',
19
+ )
20
+
21
+ const COMPILED_GRAINIENT = compileGrainientModule()
22
+
23
+ const GRAINIENT_OPTIONS = {
24
+ color1: '#000422',
25
+ color2: '#d8dcfa',
26
+ color3: '#000422',
27
+ timeSpeed: 0.25,
28
+ colorBalance: 0,
29
+ warpStrength: 1,
30
+ warpFrequency: 5,
31
+ warpSpeed: 2,
32
+ warpAmplitude: 10,
33
+ blendAngle: 0,
34
+ blendSoftness: 0.05,
35
+ rotationAmount: 500,
36
+ noiseScale: 2,
37
+ grainAmount: 0.1,
38
+ grainScale: 2,
39
+ grainAnimated: false,
40
+ contrast: 1.5,
41
+ gamma: 1,
42
+ saturation: 1,
43
+ centerX: 0,
44
+ centerY: 0,
45
+ zoom: 0.9,
46
+ }
47
+
48
+ const CHECK_SVG = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>'
49
+
50
+ const LANDING_EXTRA_CSS = `
51
+ main[data-flow="signin"] .body > .status { margin-top: 9px; }
52
+ main[data-tone="error"] .status .marker {
53
+ color: var(--c-danger);
54
+ border-color: color-mix(in srgb, var(--c-danger) 45%, transparent);
55
+ }
56
+ `
57
+
58
+ function markerHtml(tone: LandingTone): string {
59
+ if (tone === 'success') return CHECK_SVG
60
+ if (tone === 'error') return '<span aria-hidden="true">!</span>'
61
+ return '<span aria-hidden="true">–</span>'
62
+ }
63
+
64
+ export function renderOAuthLandingPage(args: {
65
+ tone: LandingTone
66
+ pageTitle: string
67
+ headline: string
68
+ message: string
69
+ }): string {
70
+ const title = escapeHtml(args.pageTitle)
71
+ const headline = escapeHtml(args.headline)
72
+ const message = escapeHtml(args.message)
73
+ const splash = escapeHtml(glyphs.eyes)
74
+ const optionsJson = JSON.stringify(GRAINIENT_OPTIONS).replaceAll('<', '\\u003c')
75
+ const safeScript = COMPILED_GRAINIENT.replaceAll('</', '<\\/')
76
+ const marker = markerHtml(args.tone)
77
+
78
+ return `<!doctype html>
79
+ <html lang="en">
80
+ <head>
81
+ <meta charset="utf-8" />
82
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
83
+ <title>${title}</title>
84
+ <style>${WALLET_CSS}
85
+ ${LANDING_EXTRA_CSS}</style>
86
+ </head>
87
+ <body>
88
+ <canvas id="grainient" class="grainient-canvas" aria-hidden="true"></canvas>
89
+ <main id="card" data-flow="signin" data-tone="${args.tone}">
90
+ <div class="chrome">
91
+ <span class="chrome-spacer"></span>
92
+ <span class="chrome-title">ethagent</span>
93
+ <span class="chrome-actions"></span>
94
+ </div>
95
+ <div class="body">
96
+ <div class="splash-wrap"><pre class="splash">${splash}</pre></div>
97
+ <h2 class="flow-title">${headline}</h2>
98
+ <div class="status">
99
+ <p class="status-line">
100
+ <span class="marker">${marker}</span>
101
+ <span>${message}</span>
102
+ </p>
103
+ <p class="status-hint">You may close this tab now.</p>
104
+ </div>
105
+ </div>
106
+ </main>
107
+ <script>
108
+ ${safeScript}
109
+ ;(function () {
110
+ var canvas = document.getElementById('grainient');
111
+ if (canvas) startGrainient(canvas, ${optionsJson});
112
+ })();
113
+ </script>
114
+ </body>
115
+ </html>`
116
+ }
117
+
118
+ function compileGrainientModule(): string {
119
+ const source = readFileSync(GRAINIENT_SOURCE_FILE, 'utf8')
120
+ const stripped = source
121
+ .split(/\r?\n/)
122
+ .map(line => line.replace(/^export\s+(?=(function|const|let|interface|type|class)\b)/, ''))
123
+ .join('\n')
124
+ return transformSync(stripped, { loader: 'ts', target: 'es2020' }).code
125
+ }
@@ -0,0 +1,151 @@
1
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
2
+ import type { AddressInfo } from 'node:net'
3
+
4
+ export class AuthCodeListener {
5
+ private localServer: Server
6
+ private port = 0
7
+ private promiseResolver: ((authorizationCode: string) => void) | null = null
8
+ private promiseRejecter: ((error: Error) => void) | null = null
9
+ private expectedState: string | null = null
10
+ private pendingResponse: ServerResponse | null = null
11
+ private readonly callbackPath: string
12
+
13
+ constructor(callbackPath = '/auth/callback') {
14
+ this.localServer = createServer()
15
+ this.callbackPath = callbackPath
16
+ }
17
+
18
+ async start(port?: number): Promise<number> {
19
+ return new Promise((resolve, reject) => {
20
+ const onError = (err: Error): void => {
21
+ reject(new Error(`Failed to start OAuth callback server: ${err.message}`))
22
+ }
23
+ this.localServer.once('error', onError)
24
+ this.localServer.listen(port ?? 0, 'localhost', () => {
25
+ this.localServer.removeListener('error', onError)
26
+ const address = this.localServer.address() as AddressInfo
27
+ this.port = address.port
28
+ resolve(this.port)
29
+ })
30
+ })
31
+ }
32
+
33
+ getPort(): number {
34
+ return this.port
35
+ }
36
+
37
+ hasPendingResponse(): boolean {
38
+ return this.pendingResponse !== null
39
+ }
40
+
41
+ async waitForAuthorization(
42
+ state: string,
43
+ onReady: () => Promise<void> | void,
44
+ ): Promise<string> {
45
+ return new Promise<string>((resolve, reject) => {
46
+ this.promiseResolver = resolve
47
+ this.promiseRejecter = reject
48
+ this.expectedState = state
49
+ this.localServer.on('request', this.handleRedirect.bind(this))
50
+ this.localServer.on('error', err => this.cancelPendingAuthorization(err))
51
+ void Promise.resolve(onReady()).catch(err => this.cancelPendingAuthorization(err as Error))
52
+ })
53
+ }
54
+
55
+ handleSuccessRedirect(
56
+ _scopes: string[],
57
+ customHandler?: (res: ServerResponse) => void,
58
+ ): void {
59
+ if (!this.pendingResponse) return
60
+ const response = this.pendingResponse
61
+ try {
62
+ if (customHandler) customHandler(response)
63
+ else {
64
+ response.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' })
65
+ response.end('OpenAI sign-in complete. You can close this tab.')
66
+ }
67
+ if (!response.writableEnded && !response.destroyed) response.end()
68
+ } finally {
69
+ this.pendingResponse = null
70
+ }
71
+ }
72
+
73
+ handleErrorRedirect(customHandler?: (res: ServerResponse) => void): void {
74
+ if (!this.pendingResponse) return
75
+ const response = this.pendingResponse
76
+ try {
77
+ if (customHandler) customHandler(response)
78
+ else {
79
+ response.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' })
80
+ response.end('OpenAI sign-in failed. You can close this tab.')
81
+ }
82
+ if (!response.writableEnded && !response.destroyed) response.end()
83
+ } finally {
84
+ this.pendingResponse = null
85
+ }
86
+ }
87
+
88
+ cancelPendingAuthorization(error: Error = new Error('OAuth authorization was cancelled.')): void {
89
+ this.reject(error)
90
+ this.close()
91
+ }
92
+
93
+ private handleRedirect(req: IncomingMessage, res: ServerResponse): void {
94
+ const parsedUrl = new URL(req.url || '', `http://${req.headers.host || 'localhost'}`)
95
+ if (parsedUrl.pathname !== this.callbackPath) {
96
+ res.writeHead(404)
97
+ res.end()
98
+ return
99
+ }
100
+ const authCode = parsedUrl.searchParams.get('code') ?? undefined
101
+ const state = parsedUrl.searchParams.get('state') ?? undefined
102
+ this.validateAndRespond(authCode, state, res)
103
+ }
104
+
105
+ private validateAndRespond(
106
+ authCode: string | undefined,
107
+ state: string | undefined,
108
+ res: ServerResponse,
109
+ ): void {
110
+ if (!authCode) {
111
+ res.writeHead(400)
112
+ res.end('Authorization code not found')
113
+ this.reject(new Error('No authorization code received'))
114
+ return
115
+ }
116
+ if (state !== this.expectedState) {
117
+ res.writeHead(400)
118
+ res.end('Invalid state parameter')
119
+ this.reject(new Error('Invalid state parameter'))
120
+ return
121
+ }
122
+ this.pendingResponse = res
123
+ this.resolve(authCode)
124
+ }
125
+
126
+ private resolve(authorizationCode: string): void {
127
+ if (this.promiseResolver) {
128
+ this.promiseResolver(authorizationCode)
129
+ this.promiseResolver = null
130
+ this.promiseRejecter = null
131
+ this.expectedState = null
132
+ }
133
+ }
134
+
135
+ private reject(error: Error): void {
136
+ if (this.promiseRejecter) {
137
+ this.promiseRejecter(error)
138
+ this.promiseResolver = null
139
+ this.promiseRejecter = null
140
+ this.expectedState = null
141
+ }
142
+ }
143
+
144
+ close(): void {
145
+ if (this.pendingResponse) this.handleErrorRedirect()
146
+ this.localServer.removeAllListeners()
147
+ this.localServer.close()
148
+ this.expectedState = null
149
+ this.port = 0
150
+ }
151
+ }
@@ -0,0 +1,70 @@
1
+ import {
2
+ OPENAI_OAUTH_CLIENT_ID,
3
+ OPENAI_OAUTH_TOKEN_URL,
4
+ asTrimmedString,
5
+ parseChatgptAccountId,
6
+ } from './shared.js'
7
+ import type { OpenAIOAuthCredentials } from './credentials.js'
8
+
9
+ export type RefreshedTokens = {
10
+ accessToken: string
11
+ refreshToken: string
12
+ idToken?: string
13
+ accountId?: string
14
+ expiresIn: number
15
+ }
16
+
17
+ const REFRESH_LEEWAY_MS = 60_000
18
+
19
+ export function shouldRefresh(creds: OpenAIOAuthCredentials, now: number = Date.now()): boolean {
20
+ return now >= creds.expiresAt - REFRESH_LEEWAY_MS
21
+ }
22
+
23
+ export async function refreshOpenAIAccessToken(refreshToken: string): Promise<RefreshedTokens> {
24
+ const body = new URLSearchParams({
25
+ client_id: OPENAI_OAUTH_CLIENT_ID,
26
+ grant_type: 'refresh_token',
27
+ refresh_token: refreshToken,
28
+ })
29
+
30
+ const response = await fetch(OPENAI_OAUTH_TOKEN_URL, {
31
+ method: 'POST',
32
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
33
+ body,
34
+ signal: AbortSignal.timeout(15_000),
35
+ })
36
+
37
+ if (!response.ok) {
38
+ const errorText = await response.text().catch(() => '')
39
+ throw new Error(
40
+ errorText.trim()
41
+ ? `OpenAI token refresh failed (${response.status}): ${errorText.trim()}`
42
+ : `OpenAI token refresh failed with status ${response.status}.`,
43
+ )
44
+ }
45
+
46
+ const payload = (await response.json()) as {
47
+ access_token?: string
48
+ refresh_token?: string
49
+ id_token?: string
50
+ expires_in?: number
51
+ }
52
+ const accessToken = asTrimmedString(payload.access_token)
53
+ if (!accessToken) {
54
+ throw new Error('OpenAI token refresh succeeded without a new access token.')
55
+ }
56
+ const nextRefresh = asTrimmedString(payload.refresh_token) ?? refreshToken
57
+ const idToken = asTrimmedString(payload.id_token)
58
+ const expiresIn = typeof payload.expires_in === 'number' && payload.expires_in > 0
59
+ ? payload.expires_in
60
+ : 3600
61
+ const accountId = parseChatgptAccountId(idToken) ?? parseChatgptAccountId(accessToken)
62
+
63
+ return {
64
+ accessToken,
65
+ refreshToken: nextRefresh,
66
+ idToken,
67
+ accountId,
68
+ expiresIn,
69
+ }
70
+ }