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.
- package/dist/claude-auth.d.ts +6 -0
- package/dist/claude-auth.js +115 -61
- package/package.json +1 -1
package/dist/claude-auth.d.ts
CHANGED
|
@@ -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;
|
package/dist/claude-auth.js
CHANGED
|
@@ -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
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
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
|
-
//
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
//
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
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(
|
|
411
|
-
|
|
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
|
-
|
|
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
|
-
|
|
461
|
+
fanout('claude_auth_complete', {
|
|
421
462
|
message: 'Claude authenticated successfully. Starting voice session...',
|
|
422
463
|
});
|
|
423
464
|
},
|
|
424
465
|
onError: (message) => {
|
|
425
|
-
|
|
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
|
}
|