@1agh/maude 1.0.9 → 1.0.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/apps/studio/acp/probe.ts +268 -16
- package/apps/studio/client/panels/ReadinessList.jsx +7 -1
- package/apps/studio/dist/client.bundle.js +2 -2
- package/apps/studio/readiness.ts +42 -7
- package/apps/studio/test/acp-bridge.test.ts +245 -1
- package/apps/studio/test/fixtures/backgrounding-claude-auth.sh +12 -0
- package/apps/studio/test/fixtures/fake-claude-auth.mjs +10 -0
- package/apps/studio/test/fixtures/noisy-claude-auth.mjs +27 -0
- package/apps/studio/test/fixtures/wedged-claude-auth.sh +11 -0
- package/apps/studio/test/readiness.test.ts +111 -0
- package/apps/studio/whats-new.json +19 -0
- package/package.json +8 -8
package/apps/studio/acp/probe.ts
CHANGED
|
@@ -222,39 +222,284 @@ export interface ClaudeAuthStatus {
|
|
|
222
222
|
subscriptionType?: string;
|
|
223
223
|
}
|
|
224
224
|
|
|
225
|
+
// A `claude` on PATH is not always the real binary. A version-manager shim
|
|
226
|
+
// (mise, asdf, volta) or a corporate `bin` wrapper routinely fronts it, and
|
|
227
|
+
// several of those print a banner to STDOUT before `exec`ing the real thing —
|
|
228
|
+
// `stderr: 'ignore'` doesn't help, because the noise isn't on stderr.
|
|
229
|
+
//
|
|
230
|
+
// Issue #107: `~/.local/bin/claude` was a mise shim whose `mise use -g claude`
|
|
231
|
+
// confirmation line landed ahead of the JSON. `JSON.parse(wholeStdout)` threw,
|
|
232
|
+
// the blanket catch returned null, and the readiness gate told a signed-in user
|
|
233
|
+
// they were signed OUT — then sent them to a Sign-in button that could only ever
|
|
234
|
+
// end in "Sign-in timed out", because the 2 s poll re-ran the same deterministic
|
|
235
|
+
// parse failure for 120 s.
|
|
236
|
+
//
|
|
237
|
+
// So: extract the JSON OBJECT from the blob rather than parsing the blob. This
|
|
238
|
+
// widens nothing about what is TRUSTED — the binary was already resolved and
|
|
239
|
+
// spawned by us, and the caller still narrows to three fields.
|
|
240
|
+
//
|
|
241
|
+
// The scan runs BACKWARDS from the end, and every giving-up path returns null
|
|
242
|
+
// rather than a best guess. Both properties are security-review findings against
|
|
243
|
+
// the first cut of this function (defender M1, attacker F1/F2), and they are the
|
|
244
|
+
// same lesson twice: a parser that fails SOFT re-creates #107 through a new door.
|
|
245
|
+
// • Forwards + a candidate budget meant noise was scanned first and the real
|
|
246
|
+
// payload could fall outside the window — 99 bare `{}` in a 242-byte prefix
|
|
247
|
+
// pushed a genuine `loggedIn:false` out of scan range and let a forged
|
|
248
|
+
// `loggedIn:true` win, turning a fail-CLOSED `JSON.parse` throw into
|
|
249
|
+
// fail-OPEN. Backwards makes the budget bite on the noise side, where it
|
|
250
|
+
// belongs, and matches the actual invariant: a wrapper's banner is written
|
|
251
|
+
// BEFORE it execs, so the real payload is the LAST thing on stdout.
|
|
252
|
+
// • Truncation now keeps the TAIL, for the same reason. Slicing the head threw
|
|
253
|
+
// away the answer and kept the noise.
|
|
254
|
+
// • Returning "the last object that parsed at all" meant an NDJSON-logging
|
|
255
|
+
// wrapper's `{"level":"info",...}` became `loggedIn: !!undefined` → a
|
|
256
|
+
// confident false "not signed in", with the dead-end Sign-in button armed.
|
|
257
|
+
// An object with no `loggedIn` is not a status document; null is.
|
|
258
|
+
|
|
259
|
+
/** Enough for any plausible banner + payload; a shim writes a line, not a stream. */
|
|
260
|
+
const AUTH_STATUS_SCAN_LIMIT = 1 << 20;
|
|
261
|
+
/** Bounds the candidate scan so a pathological blob of `{` can't become a CPU sink. */
|
|
262
|
+
const AUTH_STATUS_MAX_CANDIDATES = 100;
|
|
263
|
+
/** Wall clock for the whole read. The probe MUST resolve — see `readBounded`. */
|
|
264
|
+
const AUTH_STATUS_TIMEOUT_MS = 5000;
|
|
265
|
+
|
|
266
|
+
/** Index of the `}` closing the `{` at `from`, or -1. String- and escape-aware, so a brace inside a JSON string never miscounts. */
|
|
267
|
+
function matchingBrace(text: string, from: number): number {
|
|
268
|
+
let depth = 0;
|
|
269
|
+
let inString = false;
|
|
270
|
+
let escaped = false;
|
|
271
|
+
for (let i = from; i < text.length; i++) {
|
|
272
|
+
const ch = text[i];
|
|
273
|
+
if (inString) {
|
|
274
|
+
if (escaped) escaped = false;
|
|
275
|
+
else if (ch === '\\') escaped = true;
|
|
276
|
+
else if (ch === '"') inString = false;
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
if (ch === '"') inString = true;
|
|
280
|
+
else if (ch === '{') depth++;
|
|
281
|
+
else if (ch === '}' && --depth === 0) return i;
|
|
282
|
+
}
|
|
283
|
+
return -1;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* The last `{…}` in `out` that parses to an object carrying `loggedIn`, found by
|
|
288
|
+
* scanning `{` positions backwards from the end. Returns null when no such object
|
|
289
|
+
* is in range — including when the candidate budget runs out, which is a scan that
|
|
290
|
+
* did not finish, not a status that says "signed out". The caller must render null
|
|
291
|
+
* as "couldn't read", never as a claim about the user.
|
|
292
|
+
*/
|
|
293
|
+
function extractAuthStatusObject(out: string): Record<string, unknown> | null {
|
|
294
|
+
// Keep the TAIL on truncation: the payload trails the noise, so the end is the
|
|
295
|
+
// half worth having.
|
|
296
|
+
const text = out.length > AUTH_STATUS_SCAN_LIMIT ? out.slice(-AUTH_STATUS_SCAN_LIMIT) : out;
|
|
297
|
+
let tried = 0;
|
|
298
|
+
let start = text.lastIndexOf('{');
|
|
299
|
+
while (start !== -1 && ++tried <= AUTH_STATUS_MAX_CANDIDATES) {
|
|
300
|
+
const end = matchingBrace(text, start);
|
|
301
|
+
if (end !== -1) {
|
|
302
|
+
try {
|
|
303
|
+
const parsed: unknown = JSON.parse(text.slice(start, end + 1));
|
|
304
|
+
if (
|
|
305
|
+
typeof parsed === 'object' &&
|
|
306
|
+
parsed !== null &&
|
|
307
|
+
!Array.isArray(parsed) &&
|
|
308
|
+
'loggedIn' in (parsed as Record<string, unknown>)
|
|
309
|
+
) {
|
|
310
|
+
return parsed as Record<string, unknown>;
|
|
311
|
+
}
|
|
312
|
+
} catch {
|
|
313
|
+
/* noise that merely looked like an object — keep walking backwards */
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
// `lastIndexOf(needle, -1)` clamps to 0 and would re-test index 0 for the
|
|
317
|
+
// whole remaining budget (round-2 defender L7) — stop instead.
|
|
318
|
+
if (start === 0) break;
|
|
319
|
+
start = text.lastIndexOf('{', start - 1);
|
|
320
|
+
}
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Read `stream` to EOF, bounded by `timeoutMs` and by a `maxBytes` sliding TAIL
|
|
326
|
+
* window. Always returns whatever bytes it managed to collect — never null.
|
|
327
|
+
*
|
|
328
|
+
* Security-review finding (defender M2 / attacker F3): the previous
|
|
329
|
+
* `await new Response(proc.stdout).text()` resolves on pipe EOF, and `proc.kill()`
|
|
330
|
+
* signals only the DIRECT child. #107's proven root cause was a self-recursive
|
|
331
|
+
* wrapper, i.e. exactly the shape that leaves descendants holding fd 1 after the
|
|
332
|
+
* kill — so the read never completed, `probeReadiness()` never resolved, and
|
|
333
|
+
* `GET /_api/preflight` hung. The honest-unknown path this whole change exists to
|
|
334
|
+
* render was unreachable in its own headline scenario.
|
|
335
|
+
*
|
|
336
|
+
* Round-2 finding (defender L6): the first cut of this function returned null on
|
|
337
|
+
* BOTH bounds, throwing away a payload it was already holding. A signed-in user
|
|
338
|
+
* whose wrapper backgrounds anything at all — a daemon, an update check, a
|
|
339
|
+
* corporate agent — printed a perfectly good status, then lost AI editing to a
|
|
340
|
+
* deadline that fired on the still-open pipe. Honest, but the answer was in the
|
|
341
|
+
* buffer. Returning it cannot fabricate a status: `extractAuthStatusObject` still
|
|
342
|
+
* demands a parsed `loggedIn`-bearing object, so a partial or garbled tail is
|
|
343
|
+
* still null.
|
|
344
|
+
*
|
|
345
|
+
* The cap is a sliding window for the same reason the scan runs backwards — the
|
|
346
|
+
* payload trails the noise, so when we must drop bytes we drop the OLDEST. The
|
|
347
|
+
* drop is byte-exact, not chunk-granular (attacker R3-2).
|
|
348
|
+
*
|
|
349
|
+
* ACCEPTED TRADE, stated plainly because the round-2 docstring overclaimed
|
|
350
|
+
* (attacker R3-1): **the deadline path is no longer fail-closed.** This function
|
|
351
|
+
* cannot distinguish "this is all of stdout" from "this is the first 5 s of
|
|
352
|
+
* stdout", so a deadline landing mid-stream answers from whatever complete object
|
|
353
|
+
* precedes the cut — a stale status where round-2 code returned null. That is
|
|
354
|
+
* accepted, not overlooked: the alternative cost a signed-in user AI editing for
|
|
355
|
+
* the far more common backgrounding-wrapper shape, and forcing the stale answer
|
|
356
|
+
* requires controlling the writer's timing, which is the owner position — from
|
|
357
|
+
* there the binary can simply return whatever status it likes.
|
|
358
|
+
*/
|
|
359
|
+
async function readBounded(
|
|
360
|
+
stream: ReadableStream<Uint8Array>,
|
|
361
|
+
maxBytes: number,
|
|
362
|
+
timeoutMs: number
|
|
363
|
+
): Promise<string> {
|
|
364
|
+
const reader = stream.getReader();
|
|
365
|
+
const chunks: Uint8Array[] = [];
|
|
366
|
+
let total = 0;
|
|
367
|
+
const deadline = Date.now() + timeoutMs;
|
|
368
|
+
try {
|
|
369
|
+
for (;;) {
|
|
370
|
+
const left = deadline - Date.now();
|
|
371
|
+
if (left <= 0) break;
|
|
372
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
373
|
+
const expired = Symbol('expired');
|
|
374
|
+
const next = await Promise.race([
|
|
375
|
+
reader.read(),
|
|
376
|
+
new Promise<typeof expired>((resolve) => {
|
|
377
|
+
timer = setTimeout(() => resolve(expired), left);
|
|
378
|
+
}),
|
|
379
|
+
]);
|
|
380
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
381
|
+
if (next === expired) break;
|
|
382
|
+
if (next.done) break;
|
|
383
|
+
if (next.value?.byteLength) {
|
|
384
|
+
chunks.push(next.value);
|
|
385
|
+
total += next.value.byteLength;
|
|
386
|
+
// Bound memory DURING the read, not by materialising the whole blob and
|
|
387
|
+
// slicing after — a fast writer has no practical ceiling inside the
|
|
388
|
+
// timeout window otherwise.
|
|
389
|
+
// Byte-exact, one branch (attacker R3-2). Shifting whole chunks
|
|
390
|
+
// overshot by up to `sizeof(oldest chunk) - 1` — measured 262 144 B
|
|
391
|
+
// over-dropped for a 150-byte overflow against a real Bun pipe, so the
|
|
392
|
+
// effective window was as little as 75 % of the advertised cap, while
|
|
393
|
+
// the sibling single-chunk branch four lines away was byte-exact. Same
|
|
394
|
+
// window, two granularities, and the doc claimed the byte one.
|
|
395
|
+
while (total > maxBytes) {
|
|
396
|
+
const first = chunks[0];
|
|
397
|
+
if (!first) break;
|
|
398
|
+
const over = total - maxBytes;
|
|
399
|
+
if (first.byteLength <= over) {
|
|
400
|
+
chunks.shift();
|
|
401
|
+
total -= first.byteLength;
|
|
402
|
+
} else {
|
|
403
|
+
chunks[0] = first.subarray(over);
|
|
404
|
+
total -= over;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
} finally {
|
|
410
|
+
try {
|
|
411
|
+
await reader.cancel();
|
|
412
|
+
} catch {
|
|
413
|
+
/* already closed */
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
const joined = new Uint8Array(total);
|
|
417
|
+
let at = 0;
|
|
418
|
+
for (const c of chunks) {
|
|
419
|
+
joined.set(c, at);
|
|
420
|
+
at += c.byteLength;
|
|
421
|
+
}
|
|
422
|
+
// A dropped leading chunk can split a UTF-8 sequence; the decoder emits U+FFFD
|
|
423
|
+
// there, which the `{`-scan simply walks past.
|
|
424
|
+
return new TextDecoder().decode(joined);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Narrow a status tag for DISPLAY. Attacker F5: `apiProvider` is interpolated
|
|
429
|
+
* verbatim into the readiness row, so an unbounded, unsanitized value is
|
|
430
|
+
* arbitrary text inside a trusted system-status line. Anything that isn't a
|
|
431
|
+
* short identifier becomes the literal `unrecognized` — deliberately NOT
|
|
432
|
+
* `undefined`, which would read as "no provider" and silently suppress the
|
|
433
|
+
* metered-billing warning (`offSubscription` in readiness.ts tests `!== 'firstParty'`).
|
|
434
|
+
*/
|
|
435
|
+
function narrowStatusTag(value: unknown): string | undefined {
|
|
436
|
+
if (typeof value !== 'string') return undefined;
|
|
437
|
+
return /^[A-Za-z0-9_-]{1,32}$/.test(value) ? value : 'unrecognized';
|
|
438
|
+
}
|
|
439
|
+
|
|
225
440
|
/**
|
|
226
441
|
* Shells `claude auth status --json` and narrows the result to only the fields
|
|
227
442
|
* ever trusted/exposed here. `email`/`orgId`/`orgName` are deliberately dropped
|
|
228
443
|
* on read — never forwarded to a caller, never logged (security review finding:
|
|
229
444
|
* raw-stdout-to-log is a real habit elsewhere in this codebase; the fix is to
|
|
230
445
|
* never let the raw payload exist past this function).
|
|
446
|
+
*
|
|
447
|
+
* Returns null for BOTH "no CLI" and "couldn't read a status out of it" — see
|
|
448
|
+
* `readiness.ts`, which must not render the latter as a positive "not signed
|
|
449
|
+
* in" claim (issue #107).
|
|
231
450
|
*/
|
|
232
451
|
export async function getClaudeAuthStatus(): Promise<ClaudeAuthStatus | null> {
|
|
233
452
|
const bin = resolveClaudePath();
|
|
234
453
|
if (!bin) return null;
|
|
235
454
|
try {
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
const timeout = setTimeout(() => proc.kill(), 5000);
|
|
242
|
-
const out = await new Response(proc.stdout).text();
|
|
243
|
-
await proc.exited;
|
|
244
|
-
clearTimeout(timeout);
|
|
245
|
-
const parsed: unknown = JSON.parse(out);
|
|
246
|
-
if (typeof parsed !== 'object' || parsed === null) return null;
|
|
247
|
-
const p = parsed as Record<string, unknown>;
|
|
455
|
+
// Whatever bytes arrived inside the deadline — possibly empty, possibly a
|
|
456
|
+
// truncated tail. `extractAuthStatusObject` is the only thing that decides
|
|
457
|
+
// whether that is a status document.
|
|
458
|
+
const p = extractAuthStatusObject(await readAuthStatusStdout(bin));
|
|
459
|
+
if (!p) return null;
|
|
248
460
|
return {
|
|
249
|
-
loggedIn:
|
|
250
|
-
|
|
251
|
-
|
|
461
|
+
// Strict `=== true`, not `!!` — a wrapper's `"loggedIn":"no"` or `:1` must
|
|
462
|
+
// not truthy-coerce into a sign-in (attacker F5's neighbour).
|
|
463
|
+
loggedIn: p.loggedIn === true,
|
|
464
|
+
apiProvider: narrowStatusTag(p.apiProvider),
|
|
465
|
+
subscriptionType: narrowStatusTag(p.subscriptionType),
|
|
252
466
|
};
|
|
253
467
|
} catch {
|
|
254
468
|
return null;
|
|
255
469
|
}
|
|
256
470
|
}
|
|
257
471
|
|
|
472
|
+
/**
|
|
473
|
+
* Spawn + bounded read, kept in its own function so the `Bun.spawn` call stays
|
|
474
|
+
* inline with its literal options — that is what gives `proc.stdout` its precise
|
|
475
|
+
* `ReadableStream` type instead of the widened `number | ReadableStream | undefined`.
|
|
476
|
+
*
|
|
477
|
+
* Deliberately does NOT await `proc.exited`: a self-recursive wrapper's
|
|
478
|
+
* descendants can outlive the kill, and this function's contract is that it
|
|
479
|
+
* always resolves. The bounded read already has whatever bytes exist.
|
|
480
|
+
*/
|
|
481
|
+
async function readAuthStatusStdout(bin: string): Promise<string> {
|
|
482
|
+
const proc = Bun.spawn([bin, 'auth', 'status', '--json'], {
|
|
483
|
+
env: scrubAgentEnv(),
|
|
484
|
+
// A wrapper that prompts would otherwise block forever on a tty-less read.
|
|
485
|
+
stdin: 'ignore',
|
|
486
|
+
stdout: 'pipe',
|
|
487
|
+
stderr: 'ignore',
|
|
488
|
+
});
|
|
489
|
+
try {
|
|
490
|
+
return await readBounded(proc.stdout, AUTH_STATUS_SCAN_LIMIT, AUTH_STATUS_TIMEOUT_MS);
|
|
491
|
+
} finally {
|
|
492
|
+
// Best-effort reap on every path, including the deadline one. This kills the
|
|
493
|
+
// DIRECT child only — an orphaned descendant tree is a known residual, but it
|
|
494
|
+
// can no longer wedge the probe (see `readBounded`).
|
|
495
|
+
try {
|
|
496
|
+
proc.kill();
|
|
497
|
+
} catch {
|
|
498
|
+
/* already exited */
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
258
503
|
/**
|
|
259
504
|
* The fuller availability check backing `GET /_api/acp/status` — the ONE
|
|
260
505
|
* signal the ChatPanel UI's connected/not-connected gate actually watches
|
|
@@ -280,7 +525,14 @@ export async function probeAcpAvailabilityAuthed(): Promise<AcpAvailability> {
|
|
|
280
525
|
return {
|
|
281
526
|
...base,
|
|
282
527
|
available: false,
|
|
283
|
-
|
|
528
|
+
// `base.available` is true here, so the CLI DID resolve — a null status
|
|
529
|
+
// therefore means "we couldn't read it", not "signed out". Say so
|
|
530
|
+
// (issue #107): telling a signed-in user they aren't is a claim they
|
|
531
|
+
// have no way to act on, and it hides the real cause.
|
|
532
|
+
reason:
|
|
533
|
+
authStatus === null
|
|
534
|
+
? "Claude Code is installed, but Maude couldn't read its sign-in state — check Help ▸ Check AI editing readiness."
|
|
535
|
+
: 'Claude Code is installed but not signed in — sign in to connect.',
|
|
284
536
|
};
|
|
285
537
|
}
|
|
286
538
|
return base;
|
|
@@ -292,7 +292,13 @@ function Row({ item, refresh }) {
|
|
|
292
292
|
) : null}
|
|
293
293
|
</span>
|
|
294
294
|
) : null}
|
|
295
|
-
{
|
|
295
|
+
{/* Shown whenever a path resolved — NOT only in the signin state. DDR-166
|
|
296
|
+
Decision 3 calls this binding ("so a pre-existing PATH-hijacked `claude`
|
|
297
|
+
isn't invisible to the one human capable of noticing it's wrong"), and
|
|
298
|
+
gating it on `action === 'signin'` hid it in the one state whose most
|
|
299
|
+
likely cause IS something unexpected fronting the binary (issue #107,
|
|
300
|
+
attacker finding F4). */}
|
|
301
|
+
{item.resolvedPath ? (
|
|
296
302
|
<span className="rdy-fix-tx rdy-resolved-path">
|
|
297
303
|
{item.resolvedViaMaude ? 'Installed by Maude: ' : 'Found on your PATH: '}
|
|
298
304
|
<code>{item.resolvedPath}</code>
|