osborn 0.8.9 → 0.8.11

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.
@@ -53,6 +53,12 @@ export declare function runClaudeAuthFlow(callbacks: ClaudeAuthCallbacks): {
53
53
  * 2. ~/.claude/.credentials.json file
54
54
  * 3. `claude auth status --json`
55
55
  * 4. Interactive OAuth flow (setup-token)
56
+ *
57
+ * Concurrency: if a previous call is still running its OAuth flow, new
58
+ * callers attach to the existing flow rather than spawning a second pty.
59
+ * This prevents the situation where LiveKit reconnects (e.g. after a
60
+ * microphone-permission error) retrigger ensureClaudeAuth and the user
61
+ * sees two different URLs / two different code_challenges racing.
56
62
  */
57
63
  export declare function ensureClaudeAuth(sendToFrontend: (type: string, payload: unknown) => void): Promise<{
58
64
  submitCode?: (code: string) => void;
@@ -147,67 +147,64 @@ export async function checkClaudeAuthStatus() {
147
147
  // ─────────────────────────────────────────
148
148
  /**
149
149
  * Extract OAuth URL from CLI output.
150
- * Strips ALL whitespace first (like vutran1710/claudebox) to handle
151
- * Ink UI wrapping the URL across multiple lines.
152
- * Also cleans trailing "Pastecodehereifprompted" that Ink appends.
153
150
  *
154
- * IMPORTANT: strips the `redirect_uri` query parameter (which points to a
155
- * localhost callback server on the *sprite*, not the user's machine). With
156
- * no redirect_uri, claude.com falls back to showing the auth code in-page,
157
- * which the user pastes back into the modal. This is the only flow that
158
- * works for cloud sandboxes the localhost redirect breaks both on phones
159
- * (no listener) AND on desktops (sprite's localhost is unreachable).
151
+ * Strips ALL whitespace first (like vutran1710/claudebox) to handle Ink UI
152
+ * wrapping the URL across multiple lines. Cleans trailing junk the Ink UI
153
+ * appends (e.g. "Pastecodehereifprompted") and any stray ESC bytes.
154
+ *
155
+ * Note on redirect_uri: we used to strip it hoping claude.ai would fall back
156
+ * to an in-page code display, but claude.ai REQUIRES redirect_uri and returns
157
+ * "Invalid OAuth Request: Missing redirect_uri parameter" when it's missing.
158
+ * The pinned Claude Code client ID (9d1c250a-e61b-44d9-88ed-5944d1962f5e)
159
+ * only has http://localhost:<port>/callback URIs in its whitelist, so we
160
+ * can't rewrite to a public callback either — it would be rejected.
161
+ *
162
+ * Actual flow that works: keep the localhost redirect AS-IS. User clicks the
163
+ * URL on any device, authorizes. claude.ai 302s the browser to the localhost
164
+ * URL (which is unreachable). The browser shows "connection refused" but
165
+ * leaves the full URL in the address bar — including ?code=XXX&state=YYY.
166
+ * The user copies the `code` value from the address bar and pastes it into
167
+ * the modal. This is ugly on mobile but it's the only flow the server
168
+ * accepts.
160
169
  */
161
170
  function extractOAuthUrl(text) {
162
- // Strip ANSI codes
163
- const noAnsi = text.replace(/\x1B\[[0-9;]*[A-Za-z]/g, '')
164
- .replace(/\x1B\][^\x07]*\x07/g, '');
171
+ // Replace ANSI control sequences AND lone ESC bytes with a NUL sentinel.
172
+ // Why NUL and not a space: we need to strip all whitespace next (to
173
+ // unwrap URLs that Ink wrapped across terminal lines), and if we used
174
+ // a space it would be eaten by the whitespace strip — then text on
175
+ // either side of the control sequence would fuse into the URL. NUL
176
+ // survives the whitespace strip and acts as a hard boundary the
177
+ // tail-cut logic below can detect.
178
+ const SENTINEL = '\x00';
179
+ const noAnsi = text
180
+ .replace(/\x1B\[[0-9;]*[A-Za-z]/g, SENTINEL)
181
+ .replace(/\x1B\][^\x07]*\x07/g, SENTINEL)
182
+ .replace(/\x1B/g, SENTINEL);
165
183
  // Strip all whitespace (claudebox pattern: strings.Join(strings.Fields(pane), ""))
184
+ // to unwrap URLs split across terminal lines. NUL sentinels survive.
166
185
  const stripped = noAnsi.replace(/\s+/g, '');
167
186
  const match = stripped.match(URL_REGEX);
168
187
  if (!match)
169
188
  return null;
170
189
  let url = match[0];
171
- // Clean trailing Ink artifacts (claudebox pattern)
172
- const trailingJunk = ['Pastecodehereifprompted', 'Pastecodehereifprompted>'];
173
- for (const junk of trailingJunk) {
174
- const idx = url.indexOf(junk);
175
- if (idx > 0)
176
- url = url.substring(0, idx);
177
- }
178
- // Strip the localhost redirect_uri so claude.com shows a pasteable code
179
- // instead of trying to redirect. URL() can't be used here because it
180
- // re-encodes the path, so we surgically delete the redirect_uri param.
181
- url = stripRedirectUri(url);
182
- return url;
183
- }
184
- /**
185
- * Strip the `redirect_uri` query param from an OAuth URL.
186
- *
187
- * Background: `claude setup-token` spawns a one-shot localhost HTTP server on
188
- * a random port and registers it as the redirect_uri. That works fine when the
189
- * user is on the same machine as the CLI, but on a sprite the URL points to
190
- * the *sprite's* localhost — unreachable from the user's browser regardless
191
- * of whether they open the auth link on their PC or their phone. With no
192
- * redirect_uri at all, claude.ai falls back to its in-page code display
193
- * (the same flow that `claude setup-token`'s "Paste code here if prompted"
194
- * Ink input is built to consume), and the user can paste the code back into
195
- * our modal — which works whether they signed in on phone or desktop.
196
- *
197
- * Done with regex rather than `new URL()` because the URL constructor
198
- * normalizes the path (which can break Claude's strict redirect check)
199
- * and re-encodes spaces/special chars in other params.
200
- */
201
- function stripRedirectUri(url) {
202
- const before = url;
203
- // Three cases: leading param (?redirect_uri=...&), middle/trailing (&redirect_uri=...),
204
- // and only param (?redirect_uri=...). Order matters so cleanup leaves the URL well-formed.
205
- url = url.replace(/&redirect_uri=[^&]*/g, '');
206
- url = url.replace(/\?redirect_uri=[^&]*&/g, '?');
207
- url = url.replace(/\?redirect_uri=[^&]*$/g, '');
208
- if (before !== url) {
209
- console.log('🔑 Stripped localhost redirect_uri from OAuth URL — claude.ai will show a pasteable code instead of redirecting');
210
- }
190
+ // Cut at the first NUL sentinel — that marks where an ANSI control code
191
+ // USED to be, which is a reliable boundary between the URL and adjacent
192
+ // terminal output that got fused by the whitespace strip.
193
+ const sentinelCut = url.indexOf(SENTINEL);
194
+ if (sentinelCut > 0)
195
+ url = url.substring(0, sentinelCut);
196
+ // Cut at the first `paste` (case-insensitive) — Ink always appends a
197
+ // "Paste code here if prompted" input box after the URL, and any case
198
+ // variant of it is junk. None of Claude's query-param values begin
199
+ // with "paste" so this is safe.
200
+ const pasteCut = url.toLowerCase().indexOf('paste');
201
+ if (pasteCut > 0)
202
+ url = url.substring(0, pasteCut);
203
+ // Defensive: cut at any byte outside the URL-valid character set. OAuth
204
+ // URLs use letters, digits, `%`, and URL-safe punctuation only.
205
+ const tailCut = url.search(/[^A-Za-z0-9%._~:/?#\[\]@!$&'()*+,;=\-]/);
206
+ if (tailCut > 0)
207
+ url = url.substring(0, tailCut);
211
208
  return url;
212
209
  }
213
210
  // ─────────────────────────────────────────
@@ -357,9 +354,7 @@ export function runClaudeAuthFlow(callbacks) {
357
354
  });
358
355
  return { handle, done };
359
356
  }
360
- // ─────────────────────────────────────────
361
- // Startup Gate
362
- // ─────────────────────────────────────────
357
+ let inFlightAuth = null;
363
358
  /**
364
359
  * Ensure Claude is authenticated before proceeding.
365
360
  *
@@ -368,8 +363,33 @@ export function runClaudeAuthFlow(callbacks) {
368
363
  * 2. ~/.claude/.credentials.json file
369
364
  * 3. `claude auth status --json`
370
365
  * 4. Interactive OAuth flow (setup-token)
366
+ *
367
+ * Concurrency: if a previous call is still running its OAuth flow, new
368
+ * callers attach to the existing flow rather than spawning a second pty.
369
+ * This prevents the situation where LiveKit reconnects (e.g. after a
370
+ * microphone-permission error) retrigger ensureClaudeAuth and the user
371
+ * sees two different URLs / two different code_challenges racing.
371
372
  */
372
373
  export async function ensureClaudeAuth(sendToFrontend) {
374
+ // If an auth flow is already running, attach to it and replay any
375
+ // state we've already captured (the URL, any waiting_code prompt).
376
+ if (inFlightAuth) {
377
+ console.log('🔑 ensureClaudeAuth called while a flow is in-flight — attaching new subscriber');
378
+ inFlightAuth.subscribers.push(sendToFrontend);
379
+ // Replay the state the frontend needs to render the modal correctly.
380
+ sendToFrontend('claude_auth_required', {
381
+ message: 'Claude authentication required. A login URL will appear shortly.',
382
+ });
383
+ if (inFlightAuth.lastUrl) {
384
+ sendToFrontend('claude_auth_url', { url: inFlightAuth.lastUrl });
385
+ }
386
+ if (inFlightAuth.lastStatus === 'waiting_code') {
387
+ sendToFrontend('claude_auth_waiting_code', {
388
+ message: 'Paste the authentication code from the browser.',
389
+ });
390
+ }
391
+ return { submitCode: inFlightAuth.submitCode, done: inFlightAuth.done };
392
+ }
373
393
  // Check 0: Restore token from volume if previously persisted
374
394
  if (!hasOAuthTokenEnv()) {
375
395
  try {
@@ -402,28 +422,62 @@ export async function ensureClaudeAuth(sendToFrontend) {
402
422
  }
403
423
  // Check 4: Need interactive OAuth flow
404
424
  console.log('🔑 Claude not authenticated — starting OAuth flow');
405
- sendToFrontend('claude_auth_required', {
425
+ // Create the in-flight record BEFORE spawning, and fan-out every
426
+ // callback to all current subscribers. New subscribers that attach
427
+ // later get replay of lastUrl / lastStatus from the deduped path
428
+ // at the top of this function.
429
+ const subscribers = [sendToFrontend];
430
+ const fanout = (type, payload) => {
431
+ for (const sub of subscribers) {
432
+ try {
433
+ sub(type, payload);
434
+ }
435
+ catch (err) {
436
+ console.warn('🔑 subscriber failed:', err);
437
+ }
438
+ }
439
+ };
440
+ fanout('claude_auth_required', {
406
441
  message: 'Claude authentication required. A login URL will appear shortly.',
407
442
  });
408
443
  const { handle, done } = runClaudeAuthFlow({
409
444
  onUrl: (url) => {
410
- console.log('📤 Sending Claude auth URL to frontend');
411
- sendToFrontend('claude_auth_url', { url });
445
+ console.log(`📤 Sending Claude auth URL to frontend (${url.length} chars)`);
446
+ if (inFlightAuth) {
447
+ inFlightAuth.lastUrl = url;
448
+ inFlightAuth.lastStatus = 'waiting';
449
+ }
450
+ fanout('claude_auth_url', { url });
412
451
  },
413
452
  onWaitingForCode: () => {
414
453
  console.log('📤 Sending code prompt to frontend');
415
- sendToFrontend('claude_auth_waiting_code', {
454
+ if (inFlightAuth)
455
+ inFlightAuth.lastStatus = 'waiting_code';
456
+ fanout('claude_auth_waiting_code', {
416
457
  message: 'Paste the authentication code from the browser.',
417
458
  });
418
459
  },
419
460
  onComplete: () => {
420
- sendToFrontend('claude_auth_complete', {
461
+ fanout('claude_auth_complete', {
421
462
  message: 'Claude authenticated successfully. Starting voice session...',
422
463
  });
423
464
  },
424
465
  onError: (message) => {
425
- sendToFrontend('claude_auth_error', { message });
466
+ fanout('claude_auth_error', { message });
426
467
  },
427
468
  });
469
+ // Publish the in-flight record so concurrent callers attach to it.
470
+ inFlightAuth = {
471
+ submitCode: handle.submitCode,
472
+ done,
473
+ lastUrl: null,
474
+ lastStatus: null,
475
+ subscribers,
476
+ };
477
+ // Clear the in-flight record once the flow settles, success or failure.
478
+ done.finally(() => {
479
+ console.log('🔑 OAuth flow settled — clearing in-flight guard');
480
+ inFlightAuth = null;
481
+ });
428
482
  return { submitCode: handle.submitCode, done };
429
483
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "osborn",
3
- "version": "0.8.9",
3
+ "version": "0.8.11",
4
4
  "description": "Voice AI coding assistant - local agent that connects to Osborn frontend",
5
5
  "type": "module",
6
6
  "bin": {