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.
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/auth/openaiOAuth/credentials.ts +47 -0
- package/src/auth/openaiOAuth/crypto.ts +23 -0
- package/src/auth/openaiOAuth/index.ts +238 -0
- package/src/auth/openaiOAuth/landingPage.ts +125 -0
- package/src/auth/openaiOAuth/listener.ts +151 -0
- package/src/auth/openaiOAuth/refresh.ts +70 -0
- package/src/auth/openaiOAuth/shared.ts +115 -0
- package/src/chat/chatSessionState.ts +2 -1
- package/src/chat/commands.ts +2 -1
- package/src/identity/ens/agentRecords.ts +5 -19
- package/src/identity/ens/ensAutomation/setup.ts +0 -1
- package/src/identity/ens/ensAutomation/types.ts +0 -1
- package/src/identity/hub/OperationalRoutes.tsx +2 -11
- package/src/identity/hub/components/IdentitySummary.tsx +8 -3
- package/src/identity/hub/components/MenuScreen.tsx +1 -2
- package/src/identity/hub/components/menuFlagsFromReconciliation.ts +1 -3
- package/src/identity/hub/effects/ens/transactions.ts +15 -15
- package/src/identity/hub/effects/index.ts +0 -1
- package/src/identity/hub/effects/profile/profileState.ts +12 -4
- package/src/identity/hub/effects/publicProfile/runPublicProfileSave.ts +37 -159
- package/src/identity/hub/effects/rebackup/runRebackup.ts +2 -2
- package/src/identity/hub/effects/restoreAdmin.ts +2 -61
- package/src/identity/hub/effects/shared/sync.ts +3 -44
- package/src/identity/hub/flows/custody/CustodyEditFlow.tsx +1 -39
- package/src/identity/hub/flows/custody/custodyFlowActions.ts +5 -3
- package/src/identity/hub/flows/custody/custodyFlowTypes.ts +1 -1
- package/src/identity/hub/flows/ens/EnsEditAdvancedScreens.tsx +80 -175
- package/src/identity/hub/flows/ens/EnsEditFlow.tsx +20 -75
- package/src/identity/hub/flows/ens/EnsEditMaintenanceScreens.tsx +16 -56
- package/src/identity/hub/flows/ens/EnsEditReviewScreens.tsx +0 -18
- package/src/identity/hub/flows/ens/EnsEditRunners.tsx +0 -136
- package/src/identity/hub/flows/ens/EnsEditShared.tsx +5 -4
- package/src/identity/hub/flows/ens/EnsEditSimpleScreens.tsx +56 -205
- package/src/identity/hub/flows/ens/IdentityHubEnsFlow.tsx +7 -0
- package/src/identity/hub/flows/ens/OperatorWalletsScreen.tsx +0 -31
- package/src/identity/hub/flows/ens/ensEditCopy.ts +1 -1
- package/src/identity/hub/flows/ens/ensEditTypes.ts +6 -20
- package/src/identity/hub/flows/profile/EditProfileFlow.tsx +7 -0
- package/src/identity/hub/flows/restore/RestoreFlow.tsx +5 -5
- package/src/identity/hub/reconciliation/agentReconciliation/hook.ts +0 -1
- package/src/identity/hub/reconciliation/agentReconciliation/run.ts +1 -34
- package/src/identity/hub/reconciliation/agentReconciliation/types.ts +0 -4
- package/src/identity/hub/reconciliation/index.ts +0 -7
- package/src/identity/hub/reconciliation/walletSetup.ts +1 -194
- package/src/identity/wallet/browserWallet/types.ts +0 -5
- package/src/identity/wallet/page/copy.ts +1 -31
- package/src/identity/wallet/walletPurposeCompat.ts +0 -2
- package/src/models/ModelPicker.tsx +246 -8
- package/src/models/catalog.ts +28 -1
- package/src/models/modelPickerOptions.ts +15 -1
- package/src/providers/openai-responses-format.ts +156 -0
- package/src/providers/openai-responses.ts +276 -0
- package/src/providers/registry.ts +85 -8
- package/src/runtime/systemPrompt.ts +1 -1
- package/src/runtime/turn.ts +0 -1
- package/src/storage/secrets.ts +4 -1
- package/src/tools/privateContinuityEditTool.ts +6 -0
- package/src/utils/openExternal.ts +20 -10
- 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
|
|
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
|
-
|
|
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
|
@@ -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
|
+
}
|