typeclaw 0.32.1 → 0.33.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.
@@ -0,0 +1,342 @@
1
+ import { createServer, type Server } from 'node:http'
2
+
3
+ import { registerOAuthProvider } from '@mariozechner/pi-ai/oauth'
4
+ import type { OAuthCredentials, OAuthLoginCallbacks, OAuthProviderInterface } from '@mariozechner/pi-ai/oauth'
5
+
6
+ // xAI (Grok) OAuth 2.0. xAI runs a standard OIDC authorization server at
7
+ // auth.x.ai that supports BOTH authorization-code + PKCE (loopback callback)
8
+ // and the device-authorization grant. We implement the auth-code path with a
9
+ // localhost callback server (same UX as pi-ai's anthropic provider) plus a
10
+ // manual-paste fallback for cross-device/SSH flows.
11
+ //
12
+ // There is no public developer console to register a third-party OAuth client,
13
+ // so — like every OSS Grok integration (Grok CLI, opencode, hermes-agent,
14
+ // pi-xai-oauth) — we reuse the Grok CLI's public client id. The `plan=generic`
15
+ // query param is load-bearing: loopback OAuth against this client id is
16
+ // rejected without it. `referrer` is attribution only.
17
+ //
18
+ // Endpoints below are the live values from
19
+ // https://auth.x.ai/.well-known/openid-configuration. The token endpoint speaks
20
+ // application/x-www-form-urlencoded (OAuth2 default) — NOT JSON like Anthropic.
21
+ export const XAI_OAUTH_PROVIDER_ID = 'xai'
22
+
23
+ const CLIENT_ID = 'b1a00492-073a-47ea-816f-4c329264a828'
24
+ const AUTHORIZE_URL = 'https://auth.x.ai/oauth2/authorize'
25
+ const TOKEN_URL = 'https://auth.x.ai/oauth2/token'
26
+ const CALLBACK_HOST = '127.0.0.1'
27
+ const CALLBACK_PORT = 56121
28
+ const CALLBACK_PATH = '/callback'
29
+ const REDIRECT_URI = `http://${CALLBACK_HOST}:${CALLBACK_PORT}${CALLBACK_PATH}`
30
+ const SCOPES = 'openid profile email offline_access grok-cli:access api:access'
31
+ const REFERRER = 'typeclaw'
32
+ // Refresh slightly early so an in-flight request never races expiry.
33
+ const EXPIRY_SKEW_MS = 5 * 60 * 1000
34
+ const REQUEST_TIMEOUT_MS = 30_000
35
+
36
+ type XaiTokenResponse = {
37
+ access_token?: string
38
+ refresh_token?: string
39
+ expires_in?: number
40
+ token_type?: string
41
+ }
42
+
43
+ function base64UrlEncode(bytes: Uint8Array): string {
44
+ let binary = ''
45
+ for (const byte of bytes) binary += String.fromCharCode(byte)
46
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
47
+ }
48
+
49
+ // PKCE (RFC 7636, S256). pi-ai keeps its `generatePKCE` helper out of the
50
+ // public `/oauth` barrel, so we generate the verifier/challenge with Web Crypto
51
+ // directly — available in both Node and Bun.
52
+ async function generatePkce(): Promise<{ verifier: string; challenge: string }> {
53
+ const verifier = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)))
54
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
55
+ return { verifier, challenge: base64UrlEncode(new Uint8Array(digest)) }
56
+ }
57
+
58
+ function parseAuthorizationInput(input: string): { code?: string; state?: string } {
59
+ const value = input.trim()
60
+ if (!value) return {}
61
+ try {
62
+ const url = new URL(value)
63
+ return {
64
+ code: url.searchParams.get('code') ?? undefined,
65
+ state: url.searchParams.get('state') ?? undefined,
66
+ }
67
+ } catch {
68
+ // Not a full URL — fall through to query-string / bare-code handling.
69
+ }
70
+ if (value.includes('code=')) {
71
+ const params = new URLSearchParams(value)
72
+ return {
73
+ code: params.get('code') ?? undefined,
74
+ state: params.get('state') ?? undefined,
75
+ }
76
+ }
77
+ return { code: value }
78
+ }
79
+
80
+ type CallbackServer = {
81
+ server: Server
82
+ cancelWait: () => void
83
+ waitForCode: () => Promise<{ code: string; state: string | null } | null>
84
+ }
85
+
86
+ function successHtml(): string {
87
+ return '<!doctype html><html><body style="font-family:sans-serif;padding:2rem"><h2>xAI authentication complete.</h2><p>You can close this window and return to the terminal.</p></body></html>'
88
+ }
89
+
90
+ // The OAuth `error` query param is provider-supplied and reflected into the
91
+ // callback page, so escape it to keep a crafted callback URL
92
+ // (`?error=<script>…`) from injecting markup into the local page.
93
+ function escapeHtml(value: string): string {
94
+ return value
95
+ .replaceAll('&', '&amp;')
96
+ .replaceAll('<', '&lt;')
97
+ .replaceAll('>', '&gt;')
98
+ .replaceAll('"', '&quot;')
99
+ .replaceAll("'", '&#39;')
100
+ }
101
+
102
+ export function errorHtml(message: string): string {
103
+ return `<!doctype html><html><body style="font-family:sans-serif;padding:2rem"><h2>xAI authentication failed.</h2><p>${escapeHtml(message)}</p></body></html>`
104
+ }
105
+
106
+ // Resolves to `null` when the fixed loopback port can't be bound (EADDRINUSE,
107
+ // sandbox bind restriction). The caller then falls back to manual-paste mode
108
+ // rather than failing the whole login — the browser callback is a convenience,
109
+ // not a hard requirement, since the user can always paste the redirect URL.
110
+ function startCallbackServer(expectedState: string): Promise<CallbackServer | null> {
111
+ return new Promise((resolve) => {
112
+ let settle: ((value: { code: string; state: string | null } | null) => void) | undefined
113
+ const waitForCodePromise = new Promise<{ code: string; state: string | null } | null>((resolveWait) => {
114
+ let settled = false
115
+ settle = (value) => {
116
+ if (settled) return
117
+ settled = true
118
+ resolveWait(value)
119
+ }
120
+ })
121
+
122
+ const server = createServer((req, res) => {
123
+ try {
124
+ const url = new URL(req.url || '', `http://${CALLBACK_HOST}`)
125
+ if (url.pathname !== CALLBACK_PATH) {
126
+ res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' })
127
+ res.end(errorHtml('Callback route not found.'))
128
+ return
129
+ }
130
+ const code = url.searchParams.get('code')
131
+ const state = url.searchParams.get('state')
132
+ const error = url.searchParams.get('error')
133
+ if (error) {
134
+ res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' })
135
+ res.end(errorHtml(`Error: ${error}`))
136
+ return
137
+ }
138
+ if (!code) {
139
+ res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' })
140
+ res.end(errorHtml('Missing code parameter.'))
141
+ return
142
+ }
143
+ if (state !== expectedState) {
144
+ res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' })
145
+ res.end(errorHtml('State mismatch.'))
146
+ return
147
+ }
148
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
149
+ res.end(successHtml())
150
+ settle?.({ code, state })
151
+ } catch {
152
+ res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' })
153
+ res.end('Internal error')
154
+ }
155
+ })
156
+
157
+ server.on('error', () => {
158
+ server.close()
159
+ resolve(null)
160
+ })
161
+ server.listen(CALLBACK_PORT, CALLBACK_HOST, () => {
162
+ resolve({
163
+ server,
164
+ cancelWait: () => settle?.(null),
165
+ waitForCode: () => waitForCodePromise,
166
+ })
167
+ })
168
+ })
169
+ }
170
+
171
+ export type FetchFn = (input: string, init: RequestInit) => Promise<Response>
172
+
173
+ async function postForm(url: string, body: Record<string, string>, fetchImpl: FetchFn): Promise<XaiTokenResponse> {
174
+ const response = await fetchImpl(url, {
175
+ method: 'POST',
176
+ headers: {
177
+ 'Content-Type': 'application/x-www-form-urlencoded',
178
+ Accept: 'application/json',
179
+ },
180
+ body: new URLSearchParams(body).toString(),
181
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
182
+ })
183
+ const text = await response.text()
184
+ if (!response.ok) {
185
+ throw new Error(`xAI OAuth request failed. status=${response.status}; url=${url}; body=${text}`)
186
+ }
187
+ try {
188
+ return JSON.parse(text) as XaiTokenResponse
189
+ } catch {
190
+ throw new Error(`xAI OAuth returned invalid JSON. url=${url}; body=${text}`)
191
+ }
192
+ }
193
+
194
+ function toCredentials(token: XaiTokenResponse): OAuthCredentials {
195
+ if (!token.access_token || !token.refresh_token || token.expires_in === undefined) {
196
+ throw new Error('xAI OAuth response missing access_token, refresh_token, or expires_in')
197
+ }
198
+ return {
199
+ access: token.access_token,
200
+ refresh: token.refresh_token,
201
+ expires: Date.now() + token.expires_in * 1000 - EXPIRY_SKEW_MS,
202
+ }
203
+ }
204
+
205
+ async function exchangeAuthorizationCode(
206
+ code: string,
207
+ verifier: string,
208
+ fetchImpl: FetchFn,
209
+ ): Promise<OAuthCredentials> {
210
+ const token = await postForm(
211
+ TOKEN_URL,
212
+ {
213
+ grant_type: 'authorization_code',
214
+ client_id: CLIENT_ID,
215
+ code,
216
+ redirect_uri: REDIRECT_URI,
217
+ code_verifier: verifier,
218
+ },
219
+ fetchImpl,
220
+ )
221
+ return toCredentials(token)
222
+ }
223
+
224
+ export async function loginXai(callbacks: OAuthLoginCallbacks, fetchImpl: FetchFn = fetch): Promise<OAuthCredentials> {
225
+ const { verifier, challenge } = await generatePkce()
226
+ const server = await startCallbackServer(verifier)
227
+ let code: string | undefined
228
+ try {
229
+ const authParams = new URLSearchParams({
230
+ client_id: CLIENT_ID,
231
+ response_type: 'code',
232
+ redirect_uri: REDIRECT_URI,
233
+ scope: SCOPES,
234
+ code_challenge: challenge,
235
+ code_challenge_method: 'S256',
236
+ state: verifier,
237
+ plan: 'generic',
238
+ referrer: REFERRER,
239
+ })
240
+ callbacks.onAuth({
241
+ url: `${AUTHORIZE_URL}?${authParams.toString()}`,
242
+ instructions:
243
+ 'Complete login in your browser. If the browser is on another machine, paste the final redirect URL here.',
244
+ })
245
+
246
+ if (server && callbacks.onManualCodeInput) {
247
+ // Race the local callback server against a manual paste: whichever lands
248
+ // a code first wins (cross-device/SSH logins can't reach the loopback).
249
+ let manualInput: string | undefined
250
+ let manualError: Error | undefined
251
+ const manualPromise = callbacks
252
+ .onManualCodeInput()
253
+ .then((input) => {
254
+ manualInput = input
255
+ server.cancelWait()
256
+ })
257
+ .catch((err) => {
258
+ manualError = err instanceof Error ? err : new Error(String(err))
259
+ server.cancelWait()
260
+ })
261
+
262
+ const result = await server.waitForCode()
263
+ if (manualError) throw manualError
264
+ if (result?.code) {
265
+ code = result.code
266
+ } else if (manualInput) {
267
+ code = parseManualCode(manualInput, verifier)
268
+ }
269
+ if (!code) {
270
+ await manualPromise
271
+ if (manualError) throw manualError
272
+ if (manualInput) {
273
+ code = parseManualCode(manualInput, verifier)
274
+ }
275
+ }
276
+ } else if (server) {
277
+ const result = await server.waitForCode()
278
+ if (result?.code) code = result.code
279
+ } else if (callbacks.onManualCodeInput) {
280
+ // No callback server bound — manual paste is the only path to a code.
281
+ code = parseManualCode(await callbacks.onManualCodeInput(), verifier)
282
+ }
283
+
284
+ if (!code) {
285
+ const input = await callbacks.onPrompt({
286
+ message: 'Paste the authorization code or full redirect URL:',
287
+ placeholder: REDIRECT_URI,
288
+ })
289
+ code = parseManualCode(input, verifier)
290
+ }
291
+
292
+ if (!code) throw new Error('Missing authorization code')
293
+
294
+ callbacks.onProgress?.('Exchanging authorization code for tokens...')
295
+ return await exchangeAuthorizationCode(code, verifier, fetchImpl)
296
+ } finally {
297
+ server?.server.close()
298
+ }
299
+ }
300
+
301
+ function parseManualCode(input: string, verifier: string): string | undefined {
302
+ const parsed = parseAuthorizationInput(input)
303
+ if (parsed.state && parsed.state !== verifier) throw new Error('OAuth state mismatch')
304
+ return parsed.code
305
+ }
306
+
307
+ export async function refreshXaiToken(refreshToken: string, fetchImpl: FetchFn = fetch): Promise<OAuthCredentials> {
308
+ const token = await postForm(
309
+ TOKEN_URL,
310
+ {
311
+ grant_type: 'refresh_token',
312
+ client_id: CLIENT_ID,
313
+ refresh_token: refreshToken,
314
+ },
315
+ fetchImpl,
316
+ )
317
+ // Some OAuth servers omit a rotated refresh token on refresh; keep the prior
318
+ // one so the credential stays usable across the next cycle.
319
+ return toCredentials({ ...token, refresh_token: token.refresh_token ?? refreshToken })
320
+ }
321
+
322
+ export const xaiOAuthProvider: OAuthProviderInterface = {
323
+ id: XAI_OAUTH_PROVIDER_ID,
324
+ name: 'xAI (Grok)',
325
+ usesCallbackServer: true,
326
+ login: loginXai,
327
+ refreshToken: (credentials) => refreshXaiToken(credentials.refresh),
328
+ getApiKey: (credentials) => credentials.access,
329
+ }
330
+
331
+ let registered = false
332
+
333
+ // pi-ai ships no built-in xAI OAuth provider, so we register ours. Idempotent
334
+ // and called from `createSecretsStoreForAgent` — the single chokepoint both the
335
+ // init-time login path and the container-runtime auth/refresh path go through —
336
+ // so the provider is always present before `AuthStorage.login()` /
337
+ // `getApiKey()` look it up via `getOAuthProvider('xai')`.
338
+ export function registerXaiOAuthProvider(): void {
339
+ if (registered) return
340
+ registerOAuthProvider(xaiOAuthProvider)
341
+ registered = true
342
+ }
@@ -9,6 +9,7 @@ import {
9
9
  import lockfile from 'proper-lockfile'
10
10
 
11
11
  import { providerKeyDefaultEnv } from './defaults'
12
+ import { registerXaiOAuthProvider } from './oauth-xai'
12
13
  import { resolveSecret, type Secret } from './resolve'
13
14
  import {
14
15
  type Channels,
@@ -374,6 +375,7 @@ export class SecretsBackend implements AuthStorageBackend {
374
375
  }
375
376
 
376
377
  export function createSecretsStoreForAgent(secretsPath: string): AuthStorage {
378
+ registerXaiOAuthProvider()
377
379
  return AuthStorageImpl.fromStorage(new SecretsBackend(secretsPath))
378
380
  }
379
381
 
@@ -41,7 +41,16 @@
41
41
  "zai-coding/glm-4.7",
42
42
  "zai-coding/glm-5",
43
43
  "zai-coding/glm-5-turbo",
44
- "zai-coding/glm-5.1"
44
+ "zai-coding/glm-5.1",
45
+ "xai/grok-4.3",
46
+ "xai/grok-4.20-0309-reasoning",
47
+ "xai/grok-4.20-0309-non-reasoning",
48
+ "xai/grok-build-0.1",
49
+ "minimax/MiniMax-M3",
50
+ "minimax/MiniMax-M2.7",
51
+ "minimax/MiniMax-M2.5",
52
+ "minimax/MiniMax-M2.1",
53
+ "minimax/MiniMax-M2"
45
54
  ]
46
55
  },
47
56
  {
@@ -69,7 +78,16 @@
69
78
  "zai-coding/glm-4.7",
70
79
  "zai-coding/glm-5",
71
80
  "zai-coding/glm-5-turbo",
72
- "zai-coding/glm-5.1"
81
+ "zai-coding/glm-5.1",
82
+ "xai/grok-4.3",
83
+ "xai/grok-4.20-0309-reasoning",
84
+ "xai/grok-4.20-0309-non-reasoning",
85
+ "xai/grok-build-0.1",
86
+ "minimax/MiniMax-M3",
87
+ "minimax/MiniMax-M2.7",
88
+ "minimax/MiniMax-M2.5",
89
+ "minimax/MiniMax-M2.1",
90
+ "minimax/MiniMax-M2"
73
91
  ]
74
92
  }
75
93
  }