ethagent 2.1.1 → 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 (60) hide show
  1. package/package.json +1 -1
  2. package/src/auth/openaiOAuth/credentials.ts +47 -0
  3. package/src/auth/openaiOAuth/crypto.ts +23 -0
  4. package/src/auth/openaiOAuth/index.ts +238 -0
  5. package/src/auth/openaiOAuth/landingPage.ts +125 -0
  6. package/src/auth/openaiOAuth/listener.ts +151 -0
  7. package/src/auth/openaiOAuth/refresh.ts +70 -0
  8. package/src/auth/openaiOAuth/shared.ts +115 -0
  9. package/src/chat/chatSessionState.ts +2 -1
  10. package/src/chat/commands.ts +2 -1
  11. package/src/identity/ens/agentRecords.ts +5 -19
  12. package/src/identity/ens/ensAutomation/setup.ts +0 -1
  13. package/src/identity/ens/ensAutomation/types.ts +0 -1
  14. package/src/identity/hub/OperationalRoutes.tsx +2 -11
  15. package/src/identity/hub/components/IdentitySummary.tsx +8 -3
  16. package/src/identity/hub/components/MenuScreen.tsx +1 -2
  17. package/src/identity/hub/components/menuFlagsFromReconciliation.ts +1 -3
  18. package/src/identity/hub/effects/ens/transactions.ts +15 -15
  19. package/src/identity/hub/effects/index.ts +0 -1
  20. package/src/identity/hub/effects/profile/profileState.ts +12 -4
  21. package/src/identity/hub/effects/publicProfile/runPublicProfileSave.ts +37 -159
  22. package/src/identity/hub/effects/rebackup/runRebackup.ts +2 -2
  23. package/src/identity/hub/effects/restoreAdmin.ts +2 -61
  24. package/src/identity/hub/effects/shared/sync.ts +3 -44
  25. package/src/identity/hub/flows/custody/CustodyEditFlow.tsx +1 -39
  26. package/src/identity/hub/flows/custody/custodyFlowActions.ts +5 -3
  27. package/src/identity/hub/flows/custody/custodyFlowTypes.ts +1 -1
  28. package/src/identity/hub/flows/ens/EnsEditAdvancedScreens.tsx +80 -175
  29. package/src/identity/hub/flows/ens/EnsEditFlow.tsx +20 -75
  30. package/src/identity/hub/flows/ens/EnsEditMaintenanceScreens.tsx +16 -56
  31. package/src/identity/hub/flows/ens/EnsEditReviewScreens.tsx +0 -18
  32. package/src/identity/hub/flows/ens/EnsEditRunners.tsx +0 -136
  33. package/src/identity/hub/flows/ens/EnsEditShared.tsx +5 -4
  34. package/src/identity/hub/flows/ens/EnsEditSimpleScreens.tsx +56 -205
  35. package/src/identity/hub/flows/ens/IdentityHubEnsFlow.tsx +7 -0
  36. package/src/identity/hub/flows/ens/OperatorWalletsScreen.tsx +0 -31
  37. package/src/identity/hub/flows/ens/ensEditCopy.ts +1 -1
  38. package/src/identity/hub/flows/ens/ensEditTypes.ts +6 -20
  39. package/src/identity/hub/flows/profile/EditProfileFlow.tsx +7 -0
  40. package/src/identity/hub/flows/restore/RestoreFlow.tsx +5 -5
  41. package/src/identity/hub/reconciliation/agentReconciliation/hook.ts +0 -1
  42. package/src/identity/hub/reconciliation/agentReconciliation/run.ts +1 -34
  43. package/src/identity/hub/reconciliation/agentReconciliation/types.ts +0 -4
  44. package/src/identity/hub/reconciliation/index.ts +0 -7
  45. package/src/identity/hub/reconciliation/walletSetup.ts +1 -194
  46. package/src/identity/wallet/browserWallet/types.ts +0 -5
  47. package/src/identity/wallet/page/copy.ts +1 -31
  48. package/src/identity/wallet/walletPurposeCompat.ts +0 -2
  49. package/src/models/ModelPicker.tsx +246 -8
  50. package/src/models/catalog.ts +28 -1
  51. package/src/models/modelPickerOptions.ts +15 -1
  52. package/src/providers/openai-responses-format.ts +156 -0
  53. package/src/providers/openai-responses.ts +276 -0
  54. package/src/providers/registry.ts +85 -8
  55. package/src/runtime/systemPrompt.ts +1 -1
  56. package/src/runtime/turn.ts +0 -1
  57. package/src/storage/secrets.ts +4 -1
  58. package/src/tools/privateContinuityEditTool.ts +6 -0
  59. package/src/utils/openExternal.ts +20 -10
  60. package/src/identity/ens/ensRegistration.ts +0 -199
@@ -0,0 +1,276 @@
1
+ import type { ProviderId } from '../storage/config.js'
2
+ import type { Message, Provider, ProviderCompleteOptions, StreamEvent } from './contracts.js'
3
+ import { ProviderError } from './contracts.js'
4
+ import { providerErrorFromResponse } from './errors.js'
5
+ import { fetchWithRetryStreamEvents } from './retry.js'
6
+ import { iterSseEvents } from './sse.js'
7
+ import { buildResponsesBody } from './openai-responses-format.js'
8
+ import type { OpenAIToolDefinition } from './openai-chat.js'
9
+
10
+ const READ_TIMEOUT_MS = 45_000
11
+
12
+ type DoneStopReason = 'end_turn' | 'tool_use' | 'max_tokens' | 'stop_sequence' | 'unknown'
13
+
14
+ export type OpenAIResponsesProviderOptions = {
15
+ model: string
16
+ baseUrl: string
17
+ accessToken: string
18
+ accountId?: string
19
+ originator?: string
20
+ tools?: OpenAIToolDefinition[]
21
+ maxRetries?: number
22
+ refresh?: () => Promise<string>
23
+ }
24
+
25
+ type StreamingToolCall = {
26
+ callId: string
27
+ name: string
28
+ inputJson: string
29
+ started: boolean
30
+ }
31
+
32
+ export class OpenAIResponsesProvider implements Provider {
33
+ readonly id: ProviderId = 'openai'
34
+ readonly model: string
35
+ readonly supportsTools: boolean
36
+ private accessToken: string
37
+ private readonly baseUrl: string
38
+ private readonly accountId?: string
39
+ private readonly originator: string
40
+ private readonly tools: OpenAIToolDefinition[]
41
+ private readonly maxRetries?: number
42
+ private readonly refresh?: () => Promise<string>
43
+
44
+ constructor(opts: OpenAIResponsesProviderOptions) {
45
+ this.model = opts.model
46
+ this.baseUrl = opts.baseUrl.replace(/\/+$/, '')
47
+ this.accessToken = opts.accessToken
48
+ this.accountId = opts.accountId
49
+ this.originator = opts.originator ?? 'codex_cli_rs'
50
+ this.tools = opts.tools ?? []
51
+ this.maxRetries = opts.maxRetries
52
+ this.refresh = opts.refresh
53
+ this.supportsTools = this.tools.length > 0
54
+ }
55
+
56
+ async *complete(
57
+ messages: Message[],
58
+ signal: AbortSignal,
59
+ options: ProviderCompleteOptions = {},
60
+ ): AsyncIterable<StreamEvent> {
61
+ if (!this.accessToken) {
62
+ const error = new ProviderError('missing OAuth access token for openai (sign in again via the model picker)')
63
+ yield { type: 'error', message: error.message }
64
+ return
65
+ }
66
+
67
+ let attempt = 0
68
+ while (true) {
69
+ attempt += 1
70
+ const body = JSON.stringify(buildResponsesBody({
71
+ model: this.model,
72
+ messages,
73
+ tools: this.tools,
74
+ maxOutputTokens: options.maxTokens,
75
+ }))
76
+
77
+ let response: Response
78
+ try {
79
+ response = yield* fetchWithRetryStreamEvents(`${this.baseUrl}/responses`, {
80
+ method: 'POST',
81
+ headers: this.requestHeaders(),
82
+ body,
83
+ }, { signal, maxRetries: this.maxRetries, rateLimitResetProvider: 'openai-compatible' })
84
+ } catch (err: unknown) {
85
+ if (signal.aborted) return
86
+ yield { type: 'error', message: networkErrorMessage(this.baseUrl, err) }
87
+ return
88
+ }
89
+
90
+ if (response.status === 401 && this.refresh && attempt === 1) {
91
+ try {
92
+ this.accessToken = await this.refresh()
93
+ continue
94
+ } catch (refreshErr) {
95
+ const message = refreshErr instanceof Error ? refreshErr.message : String(refreshErr)
96
+ yield { type: 'error', message: `OpenAI sign-in expired and refresh failed: ${message}` }
97
+ return
98
+ }
99
+ }
100
+
101
+ if (!response.ok) {
102
+ const error = await providerErrorFromResponse('openai', response)
103
+ yield { type: 'error', message: error.message }
104
+ return
105
+ }
106
+ if (!response.body) {
107
+ yield { type: 'error', message: 'empty response body' }
108
+ return
109
+ }
110
+
111
+ yield* this.parseStream(response.body, signal)
112
+ return
113
+ }
114
+ }
115
+
116
+ private requestHeaders(): Record<string, string> {
117
+ const headers: Record<string, string> = {
118
+ 'Content-Type': 'application/json',
119
+ Accept: 'text/event-stream',
120
+ Authorization: `Bearer ${this.accessToken}`,
121
+ originator: this.originator,
122
+ }
123
+ if (this.accountId) headers['chatgpt-account-id'] = this.accountId
124
+ return headers
125
+ }
126
+
127
+ private async *parseStream(body: ReadableStream<Uint8Array>, signal: AbortSignal): AsyncIterable<StreamEvent> {
128
+ const toolCalls = new Map<string, StreamingToolCall>()
129
+ let inputTokens: number | undefined
130
+ let outputTokens: number | undefined
131
+ let stopReason: DoneStopReason = 'unknown'
132
+
133
+ try {
134
+ for await (const frame of iterSseEvents(body, signal, READ_TIMEOUT_MS)) {
135
+ const eventName = frame.event ?? ''
136
+ if (!frame.data || frame.data === '[DONE]') continue
137
+ let parsed: Record<string, unknown>
138
+ try {
139
+ parsed = JSON.parse(frame.data) as Record<string, unknown>
140
+ } catch {
141
+ continue
142
+ }
143
+
144
+ switch (eventName) {
145
+ case 'response.output_text.delta': {
146
+ const delta = typeof parsed.delta === 'string' ? parsed.delta : ''
147
+ if (delta) yield { type: 'text', delta }
148
+ break
149
+ }
150
+ case 'response.reasoning_summary_text.delta':
151
+ case 'response.reasoning.delta':
152
+ case 'response.reasoning_text.delta': {
153
+ const delta = typeof parsed.delta === 'string' ? parsed.delta : ''
154
+ if (delta) yield { type: 'thinking', delta }
155
+ break
156
+ }
157
+ case 'response.output_item.added': {
158
+ const item = (parsed.item ?? {}) as Record<string, unknown>
159
+ if (item.type === 'function_call') {
160
+ const callId = pickString(item.call_id) ?? pickString(item.id) ?? `tool-${toolCalls.size}`
161
+ const name = pickString(item.name) ?? ''
162
+ toolCalls.set(callId, { callId, name, inputJson: '', started: false })
163
+ if (name) {
164
+ toolCalls.get(callId)!.started = true
165
+ yield { type: 'tool_use_start', id: callId, name }
166
+ }
167
+ }
168
+ break
169
+ }
170
+ case 'response.function_call_arguments.delta': {
171
+ const callId = resolveCallId(parsed, toolCalls)
172
+ const delta = typeof parsed.delta === 'string' ? parsed.delta : ''
173
+ if (!callId || !delta) break
174
+ const existing = toolCalls.get(callId)
175
+ if (!existing) break
176
+ existing.inputJson += delta
177
+ yield { type: 'tool_use_delta', id: callId, delta }
178
+ break
179
+ }
180
+ case 'response.output_item.done': {
181
+ const item = (parsed.item ?? {}) as Record<string, unknown>
182
+ if (item.type === 'function_call') {
183
+ const callId = pickString(item.call_id) ?? pickString(item.id)
184
+ if (!callId) break
185
+ const existing = toolCalls.get(callId)
186
+ const name = pickString(item.name) ?? existing?.name ?? ''
187
+ const argsJson = pickString(item.arguments) ?? existing?.inputJson ?? ''
188
+ stopReason = 'tool_use'
189
+ yield {
190
+ type: 'tool_use_stop',
191
+ id: callId,
192
+ name,
193
+ input: parseToolArguments(argsJson),
194
+ }
195
+ toolCalls.delete(callId)
196
+ }
197
+ break
198
+ }
199
+ case 'response.completed': {
200
+ const usage = (parsed.response as { usage?: Record<string, unknown> } | undefined)?.usage
201
+ const tokens = readUsage(usage)
202
+ if (tokens.input !== undefined) inputTokens = tokens.input
203
+ if (tokens.output !== undefined) outputTokens = tokens.output
204
+ if (stopReason !== 'tool_use') stopReason = 'end_turn'
205
+ break
206
+ }
207
+ case 'response.failed':
208
+ case 'response.error':
209
+ case 'error': {
210
+ const error = parsed.error as { message?: string } | undefined
211
+ const message = error?.message ?? 'Responses API error'
212
+ yield { type: 'error', message }
213
+ return
214
+ }
215
+ case 'response.incomplete': {
216
+ const reason = pickString((parsed.response as { incomplete_details?: { reason?: string } } | undefined)?.incomplete_details?.reason)
217
+ if (reason === 'max_output_tokens') stopReason = 'max_tokens'
218
+ break
219
+ }
220
+ default:
221
+ break
222
+ }
223
+ }
224
+ } catch (err: unknown) {
225
+ if (signal.aborted) return
226
+ yield { type: 'error', message: networkErrorMessage(this.baseUrl, err, 'stream error') }
227
+ return
228
+ }
229
+
230
+ if (signal.aborted) return
231
+ yield { type: 'done', inputTokens, outputTokens, stopReason }
232
+ }
233
+ }
234
+
235
+ function pickString(value: unknown): string | undefined {
236
+ return typeof value === 'string' && value.length > 0 ? value : undefined
237
+ }
238
+
239
+ function resolveCallId(parsed: Record<string, unknown>, calls: Map<string, StreamingToolCall>): string | undefined {
240
+ return (
241
+ pickString(parsed.call_id)
242
+ ?? pickString(parsed.item_id)
243
+ ?? pickString((parsed.item as Record<string, unknown> | undefined)?.call_id)
244
+ ?? pickString((parsed.item as Record<string, unknown> | undefined)?.id)
245
+ ?? (calls.size === 1 ? Array.from(calls.keys())[0] : undefined)
246
+ )
247
+ }
248
+
249
+ function readUsage(usage: Record<string, unknown> | undefined): { input?: number; output?: number } {
250
+ if (!usage) return {}
251
+ const input = typeof usage.input_tokens === 'number'
252
+ ? usage.input_tokens
253
+ : typeof usage.prompt_tokens === 'number' ? usage.prompt_tokens : undefined
254
+ const output = typeof usage.output_tokens === 'number'
255
+ ? usage.output_tokens
256
+ : typeof usage.completion_tokens === 'number' ? usage.completion_tokens : undefined
257
+ return { input, output }
258
+ }
259
+
260
+ function parseToolArguments(input: string): Record<string, unknown> {
261
+ const trimmed = input.trim()
262
+ if (!trimmed) return {}
263
+ try {
264
+ const parsed = JSON.parse(trimmed) as unknown
265
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
266
+ ? (parsed as Record<string, unknown>)
267
+ : {}
268
+ } catch {
269
+ return {}
270
+ }
271
+ }
272
+
273
+ function networkErrorMessage(baseUrl: string, err: unknown, fallback = 'network error'): string {
274
+ const message = (err as Error).message || fallback
275
+ return `openai request failed at ${baseUrl}: ${message}`
276
+ }
@@ -6,10 +6,19 @@ import type { SessionMode } from '../runtime/sessionMode.js'
6
6
  import { AnthropicProvider } from './anthropic.js'
7
7
  import { GeminiProvider } from './gemini.js'
8
8
  import { OpenAIChatProvider } from './openai-chat.js'
9
+ import { OpenAIResponsesProvider } from './openai-responses.js'
9
10
  import { anthropicTools, geminiTools, openAITools } from '../tools/registry.js'
10
- import { openAIBaseUrlFor } from '../models/catalog.js'
11
+ import { openAIBaseUrlFor, OPENAI_OAUTH_DEFAULT_MODEL, isOpenAIOAuthAllowedModel } from '../models/catalog.js'
12
+ import {
13
+ getOpenAIOAuthCredentials,
14
+ setOpenAIOAuthCredentials,
15
+ type OpenAIOAuthCredentials,
16
+ } from '../auth/openaiOAuth/credentials.js'
17
+ import { refreshOpenAIAccessToken, shouldRefresh } from '../auth/openaiOAuth/refresh.js'
11
18
  import type { Tool } from '../tools/contracts.js'
12
19
 
20
+ export const OPENAI_CHATGPT_BACKEND_URL = 'https://chatgpt.com/backend-api/codex'
21
+
13
22
  export function isLocalProvider(provider: string): boolean {
14
23
  return provider === 'llamacpp'
15
24
  }
@@ -27,16 +36,84 @@ export function createProvider(config: EthagentConfig, options: { mode?: Session
27
36
  tools: openAITools(mode, toolContext),
28
37
  })
29
38
  case 'openai':
30
- return new OpenAIChatProvider({
31
- id: 'openai',
32
- model: config.model,
33
- baseUrl: openAIBaseUrlFor(config),
34
- loadApiKey: () => getKey('openai'),
35
- tools: openAITools(mode, toolContext),
36
- })
39
+ return createOpenAIProvider(config, openAITools(mode, toolContext))
37
40
  case 'anthropic':
38
41
  return new AnthropicProvider({ model: config.model, tools: anthropicTools(mode, toolContext) })
39
42
  case 'gemini':
40
43
  return new GeminiProvider({ model: config.model, tools: geminiTools(mode, toolContext) })
41
44
  }
42
45
  }
46
+
47
+ function createOpenAIProvider(config: EthagentConfig, tools: ReturnType<typeof openAITools>): Provider {
48
+ return new OpenAIRoutingProvider(config, tools)
49
+ }
50
+
51
+ class OpenAIRoutingProvider implements Provider {
52
+ readonly id = 'openai' as const
53
+ readonly model: string
54
+ readonly supportsTools: boolean
55
+ private delegate: Provider | null = null
56
+ private readonly config: EthagentConfig
57
+ private readonly tools: ReturnType<typeof openAITools>
58
+
59
+ constructor(config: EthagentConfig, tools: ReturnType<typeof openAITools>) {
60
+ this.config = config
61
+ this.tools = tools
62
+ this.model = config.model
63
+ this.supportsTools = tools.length > 0
64
+ }
65
+
66
+ async *complete(...args: Parameters<Provider['complete']>): ReturnType<Provider['complete']> {
67
+ if (!this.delegate) this.delegate = await this.resolveDelegate()
68
+ yield* this.delegate.complete(...args)
69
+ }
70
+
71
+ private async resolveDelegate(): Promise<Provider> {
72
+ const oauth = await loadFreshOAuthCredentials()
73
+ if (oauth) {
74
+ const oauthModel = isOpenAIOAuthAllowedModel(this.model) ? this.model : OPENAI_OAUTH_DEFAULT_MODEL
75
+ return new OpenAIResponsesProvider({
76
+ model: oauthModel,
77
+ baseUrl: OPENAI_CHATGPT_BACKEND_URL,
78
+ accessToken: oauth.accessToken,
79
+ accountId: oauth.accountId,
80
+ tools: this.tools,
81
+ refresh: async () => {
82
+ const next = await loadFreshOAuthCredentials({ force: true })
83
+ if (!next) throw new Error('No OAuth credentials available')
84
+ return next.accessToken
85
+ },
86
+ })
87
+ }
88
+ return new OpenAIChatProvider({
89
+ id: 'openai',
90
+ model: this.model,
91
+ baseUrl: openAIBaseUrlFor(this.config),
92
+ loadApiKey: () => getKey('openai'),
93
+ tools: this.tools,
94
+ })
95
+ }
96
+ }
97
+
98
+ async function loadFreshOAuthCredentials(options: { force?: boolean } = {}): Promise<OpenAIOAuthCredentials | null> {
99
+ const current = await getOpenAIOAuthCredentials()
100
+ if (!current) return null
101
+ if (!options.force && !shouldRefresh(current)) return current
102
+
103
+ try {
104
+ const refreshed = await refreshOpenAIAccessToken(current.refreshToken)
105
+ const now = Date.now()
106
+ const next: OpenAIOAuthCredentials = {
107
+ accessToken: refreshed.accessToken,
108
+ refreshToken: refreshed.refreshToken,
109
+ idToken: refreshed.idToken ?? current.idToken,
110
+ accountId: refreshed.accountId ?? current.accountId,
111
+ expiresAt: now + refreshed.expiresIn * 1000,
112
+ lastRefreshAt: now,
113
+ }
114
+ await setOpenAIOAuthCredentials(next)
115
+ return next
116
+ } catch {
117
+ return current
118
+ }
119
+ }
@@ -93,6 +93,7 @@ function buildToolEnabledPrompt(ctx: SystemPromptContext): string {
93
93
  'When exact private continuity text is needed for surgical removal or targeted replacement, call `read_private_continuity_file` with `file: "MEMORY.md"` or `file: "SOUL.md"` first.',
94
94
  'When the user wants memory, persona, preferences, or private identity continuity changed, call `propose_private_continuity_edit`; do NOT create, overwrite, or patch SOUL.md/MEMORY.md with `write_file` or `edit_file`.',
95
95
  'For private continuity, edit the existing scaffold and build on top of it: prefer `appendToSection`+`appendText` for new notes or use `oldText`+`newText` for targeted replacement. Never omit the edit anchor, never create a new file, and never replace the whole file.',
96
+ 'Never call `propose_private_continuity_edit` with `{}` or only `file`. Send exactly one edit mode: either `appendToSection` + non-empty `appendText`, or `oldText` + `newText`. Omit the fields you are not using — do not pass empty strings for `oldText`/`newText` alongside an append, or empty `appendToSection`/`appendText` alongside a targeted edit.',
96
97
  'If the user asks to remember preferences or facts, call exactly one private continuity append such as `{"file":"MEMORY.md","appendToSection":"Durable User Preferences","appendText":"- User preference or durable memory."}`.',
97
98
  'If the user asks to change persona or standing behavior, call exactly one private continuity append such as `{"file":"SOUL.md","appendToSection":"Persona","appendText":"- Persona or standing behavior."}`.',
98
99
  ]
@@ -120,7 +121,6 @@ function buildToolEnabledPrompt(ctx: SystemPromptContext): string {
120
121
  ? [
121
122
  'For private SOUL.md or MEMORY.md inspection, do not search project folders. Call `read_private_continuity_file` with `file: "SOUL.md"` or `file: "MEMORY.md"`.',
122
123
  'For private SOUL.md or MEMORY.md changes, call `propose_private_continuity_edit` with `file: "SOUL.md"` or `file: "MEMORY.md"` and an in-place append/replacement payload.',
123
- 'Never call `propose_private_continuity_edit` with `{}` or only `file`. For memory/preferences include `appendToSection: "Durable User Preferences"` and a non-empty `appendText`; for persona include `appendToSection: "Persona"` and a non-empty `appendText`.',
124
124
  ]
125
125
  : []),
126
126
  'For targeted private continuity edits with `oldText`, copy the text verbatim from the most recent `read_private_continuity_file` output. For workspace targeted edits, copy from the most recent `read_file` output.',
@@ -461,7 +461,6 @@ function nextToolResultRepairNudge(
461
461
  completedTools: ExecutedToolUse[],
462
462
  ): string | null {
463
463
  if (!provider.supportsTools) return null
464
- if (provider.id !== 'llamacpp') return null
465
464
  const failedPrivateEdit = completedTools.some(completed =>
466
465
  completed.name === 'propose_private_continuity_edit'
467
466
  && !completed.result.ok
@@ -129,8 +129,11 @@ export async function getSecret(account: string): Promise<string | null> {
129
129
  }
130
130
 
131
131
  export async function setSecret(account: string, value: string): Promise<KeyBackend> {
132
+ if (typeof value !== 'string') {
133
+ throw new Error(`setSecret(${account}): value is ${typeof value}, expected string`)
134
+ }
132
135
  const trimmed = value.trim()
133
- if (!trimmed) throw new Error('Secret value is empty')
136
+ if (!trimmed) throw new Error(`setSecret(${account}): value is empty`)
134
137
  const keytar = await loadKeytar()
135
138
  if (keytar) {
136
139
  await keytar.setPassword(KEYTAR_SERVICE, account, trimmed)
@@ -166,6 +166,12 @@ function normalizePrivateContinuityInput(input: Record<string, unknown>): Record
166
166
  if (normalized.appendText === undefined) {
167
167
  normalized.appendText = normalized.note ?? normalized.text ?? normalized.content
168
168
  }
169
+ for (const key of ['oldText', 'newText', 'appendToSection', 'appendText'] as const) {
170
+ const value = normalized[key]
171
+ if (typeof value === 'string' && value.trim() === '') {
172
+ normalized[key] = undefined
173
+ }
174
+ }
169
175
  return normalized
170
176
  }
171
177
 
@@ -3,18 +3,28 @@ import { spawn } from 'node:child_process'
3
3
  export function openExternalUrl(url: string): void {
4
4
  const target = url.trim()
5
5
  if (!target) return
6
- const command = process.platform === 'win32'
7
- ? 'cmd'
8
- : process.platform === 'darwin'
9
- ? 'open'
10
- : 'xdg-open'
11
- const args = process.platform === 'win32'
12
- ? ['/c', 'start', '', target]
13
- : [target]
14
- const child = spawn(command, args, {
6
+
7
+ if (process.platform === 'win32') {
8
+ const safe = target.replace(/"/g, '%22')
9
+ const child = spawn(
10
+ 'cmd.exe',
11
+ ['/s', '/c', `start "" "${safe}"`],
12
+ {
13
+ detached: true,
14
+ stdio: 'ignore',
15
+ windowsHide: true,
16
+ windowsVerbatimArguments: true,
17
+ },
18
+ )
19
+ child.on('error', () => {})
20
+ child.unref()
21
+ return
22
+ }
23
+
24
+ const command = process.platform === 'darwin' ? 'open' : 'xdg-open'
25
+ const child = spawn(command, [target], {
15
26
  detached: true,
16
27
  stdio: 'ignore',
17
- windowsHide: true,
18
28
  })
19
29
  child.on('error', () => {})
20
30
  child.unref()
@@ -1,199 +0,0 @@
1
- import {
2
- encodeAbiParameters,
3
- encodeFunctionData,
4
- getAddress,
5
- keccak256,
6
- namehash,
7
- parseAbi,
8
- type Address,
9
- type Hex,
10
- type PublicClient,
11
- } from 'viem'
12
-
13
- export const ETH_REGISTRAR_CONTROLLER_MAINNET = '0x253553366Da8546fC250F225fe3d25d0C782303b' as Address
14
- export const PUBLIC_RESOLVER_MAINNET = '0x231b0Ee14048e9dCcD1d247744d114a4EB5E8E63' as Address
15
- export const MIN_COMMIT_AGE_SECONDS = 60
16
- export const MAX_COMMIT_AGE_SECONDS = 86_400
17
- export const ONE_YEAR_SECONDS = 31_557_600
18
- export const REGISTER_VALUE_BUFFER_BIPS = 500n
19
-
20
- const CONTROLLER_ABI = parseAbi([
21
- 'function available(string name) view returns (bool)',
22
- 'function rentPrice(string name, uint256 duration) view returns (uint256 base, uint256 premium)',
23
- 'function commit(bytes32 commitment)',
24
- 'function register(string name, address owner, uint256 duration, bytes32 secret, address resolver, bytes[] data, bool reverseRecord, uint16 ownerControlledFuses) payable',
25
- ])
26
-
27
- const RESOLVER_ABI = parseAbi([
28
- 'function setAddr(bytes32 node, address a)',
29
- ])
30
-
31
- export type RegistrableNameValidation =
32
- | { ok: true; label: string }
33
- | { ok: false; reason: 'too-short' | 'too-long' | 'invalid-characters' | 'leading-hyphen' | 'trailing-hyphen' | 'contains-dot'; detail: string }
34
-
35
- export function validateRegistrableName(value: string): RegistrableNameValidation {
36
- const label = value.trim().toLowerCase()
37
- if (label.includes('.')) {
38
- return { ok: false, reason: 'contains-dot', detail: 'Enter only the label, not a full .eth name.' }
39
- }
40
- if (label.length < 3) {
41
- return { ok: false, reason: 'too-short', detail: 'Names must be at least 3 characters.' }
42
- }
43
- if (label.length > 64) {
44
- return { ok: false, reason: 'too-long', detail: 'Names must be 64 characters or fewer.' }
45
- }
46
- if (label.startsWith('-')) {
47
- return { ok: false, reason: 'leading-hyphen', detail: 'Names cannot start with a hyphen.' }
48
- }
49
- if (label.endsWith('-')) {
50
- return { ok: false, reason: 'trailing-hyphen', detail: 'Names cannot end with a hyphen.' }
51
- }
52
- if (!/^[a-z0-9-]+$/.test(label)) {
53
- return { ok: false, reason: 'invalid-characters', detail: 'Names use lowercase letters, numbers, and hyphens only.' }
54
- }
55
- return { ok: true, label }
56
- }
57
-
58
- type RentPrice = {
59
- base: bigint
60
- premium: bigint
61
- total: bigint
62
- }
63
-
64
- export type EnsRegistrationReadClient = Pick<PublicClient, 'readContract'>
65
-
66
- export async function readRentPrice(
67
- client: EnsRegistrationReadClient,
68
- label: string,
69
- durationSeconds: bigint,
70
- ): Promise<RentPrice> {
71
- const result = await client.readContract({
72
- address: ETH_REGISTRAR_CONTROLLER_MAINNET,
73
- abi: CONTROLLER_ABI,
74
- functionName: 'rentPrice',
75
- args: [label, durationSeconds],
76
- }) as readonly [bigint, bigint]
77
- const [base, premium] = result
78
- return { base, premium, total: base + premium }
79
- }
80
-
81
- export async function readNameAvailable(
82
- client: EnsRegistrationReadClient,
83
- label: string,
84
- ): Promise<boolean> {
85
- return await client.readContract({
86
- address: ETH_REGISTRAR_CONTROLLER_MAINNET,
87
- abi: CONTROLLER_ABI,
88
- functionName: 'available',
89
- args: [label],
90
- }) as boolean
91
- }
92
-
93
- type CommitmentArgs = {
94
- label: string
95
- owner: Address
96
- durationSeconds: bigint
97
- secret: Hex
98
- resolver?: Address
99
- setAddrToOwner?: boolean
100
- reverseRecord?: boolean
101
- ownerControlledFuses?: number
102
- }
103
-
104
- type CommitmentBuild = {
105
- commitment: Hex
106
- resolver: Address
107
- data: Hex[]
108
- reverseRecord: boolean
109
- ownerControlledFuses: number
110
- }
111
-
112
- export function buildCommitment(args: CommitmentArgs): CommitmentBuild {
113
- const owner = getAddress(args.owner)
114
- const resolver = getAddress(args.resolver ?? PUBLIC_RESOLVER_MAINNET)
115
- const reverseRecord = args.reverseRecord ?? false
116
- const ownerControlledFuses = args.ownerControlledFuses ?? 0
117
- const data: Hex[] = []
118
- if (args.setAddrToOwner ?? true) {
119
- data.push(encodeFunctionData({
120
- abi: RESOLVER_ABI,
121
- functionName: 'setAddr',
122
- args: [namehash(`${args.label}.eth`), owner],
123
- }))
124
- }
125
- const commitment = keccak256(encodeAbiParameters(
126
- [
127
- { type: 'string' },
128
- { type: 'address' },
129
- { type: 'uint256' },
130
- { type: 'bytes32' },
131
- { type: 'address' },
132
- { type: 'bytes[]' },
133
- { type: 'bool' },
134
- { type: 'uint16' },
135
- ],
136
- [args.label, owner, args.durationSeconds, args.secret, resolver, data, reverseRecord, ownerControlledFuses],
137
- ))
138
- return { commitment, resolver, data, reverseRecord, ownerControlledFuses }
139
- }
140
-
141
- export function encodeCommitTransaction(commitment: Hex): { to: Address; data: Hex } {
142
- return {
143
- to: ETH_REGISTRAR_CONTROLLER_MAINNET,
144
- data: encodeFunctionData({
145
- abi: CONTROLLER_ABI,
146
- functionName: 'commit',
147
- args: [commitment],
148
- }),
149
- }
150
- }
151
-
152
- type RegisterTransactionArgs = {
153
- label: string
154
- owner: Address
155
- durationSeconds: bigint
156
- secret: Hex
157
- rentPrice: RentPrice
158
- resolver?: Address
159
- setAddrToOwner?: boolean
160
- reverseRecord?: boolean
161
- ownerControlledFuses?: number
162
- }
163
-
164
- export function encodeRegisterTransaction(args: RegisterTransactionArgs): { to: Address; data: Hex; value: Hex } {
165
- const owner = getAddress(args.owner)
166
- const built = buildCommitment({
167
- label: args.label,
168
- owner,
169
- durationSeconds: args.durationSeconds,
170
- secret: args.secret,
171
- ...(args.resolver !== undefined ? { resolver: args.resolver } : {}),
172
- ...(args.setAddrToOwner !== undefined ? { setAddrToOwner: args.setAddrToOwner } : {}),
173
- ...(args.reverseRecord !== undefined ? { reverseRecord: args.reverseRecord } : {}),
174
- ...(args.ownerControlledFuses !== undefined ? { ownerControlledFuses: args.ownerControlledFuses } : {}),
175
- })
176
- const data = encodeFunctionData({
177
- abi: CONTROLLER_ABI,
178
- functionName: 'register',
179
- args: [args.label, owner, args.durationSeconds, args.secret, built.resolver, built.data, built.reverseRecord, built.ownerControlledFuses],
180
- })
181
- const buffered = (args.rentPrice.total * (10000n + REGISTER_VALUE_BUFFER_BIPS)) / 10000n
182
- return {
183
- to: ETH_REGISTRAR_CONTROLLER_MAINNET,
184
- data,
185
- value: ('0x' + buffered.toString(16)) as Hex,
186
- }
187
- }
188
-
189
- export function generateRegistrationSecret(): Hex {
190
- const bytes = new Uint8Array(32)
191
- if (typeof globalThis.crypto?.getRandomValues === 'function') {
192
- globalThis.crypto.getRandomValues(bytes)
193
- } else {
194
- for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256)
195
- }
196
- let hex = '0x'
197
- for (const byte of bytes) hex += byte.toString(16).padStart(2, '0')
198
- return hex as Hex
199
- }