@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.
@@ -214,8 +214,22 @@ export async function probeReadiness(): Promise<ReadinessReport> {
214
214
  const authStatus =
215
215
  e2eForce === 'signed-out' ? { loggedIn: false } : claude ? await getClaudeAuthStatus() : null;
216
216
  const signedIn = !!authStatus?.loggedIn;
217
- const offSubscription =
218
- signedIn && authStatus?.apiProvider && authStatus.apiProvider !== 'firstParty';
217
+ // Issue #107 — a resolvable CLI whose auth status we could not READ is NOT
218
+ // the same as a signed-out one, and must not be reported as one. Claiming
219
+ // "not signed in" to a user who is signed in is a false accusation they
220
+ // can't argue with, and it routes them to a Sign-in button that can only
221
+ // dead-end (the poll re-runs the same failing probe until it times out).
222
+ // The probe's own tolerance for wrapper stdout noise now lives in probe.ts;
223
+ // this is the honest fallback for whatever noise that still can't read.
224
+ const authUnreadable = !!claude && e2eForce !== 'signed-out' && authStatus === null;
225
+ // Anything that is not an explicit `firstParty` counts as off-subscription,
226
+ // ABSENT included (attacker R2-3). The old `authStatus?.apiProvider &&`
227
+ // truthiness guard meant a metered user could suppress DDR-123's billing
228
+ // warning by OMITTING the field rather than forging `firstParty` — the exact
229
+ // outcome `narrowStatusTag`'s sentinel was written to prevent, reached through
230
+ // a different input shape. Over-warning is the safe direction here.
231
+ const offSubscription = signedIn && authStatus?.apiProvider !== 'firstParty';
232
+ const providerLabel = authStatus?.apiProvider ?? 'provider not reported';
219
233
  // DDR-166 Decision 5 — the settings-UI opt-out (prefs.rs `claude_auto_setup`,
220
234
  // DEFAULT ON) reaches the dev-server as an env var set fresh at each sidecar
221
235
  // spawn. Disabled → fall back to guide-only: no automated action offered,
@@ -226,21 +240,42 @@ export async function probeReadiness(): Promise<ReadinessReport> {
226
240
  id: 'claude',
227
241
  label: 'Claude Code (the `claude` CLI)',
228
242
  required: true,
229
- status: claude && signedIn ? 'present' : 'missing',
243
+ status: claude && signedIn ? 'present' : authUnreadable ? 'unknown' : 'missing',
230
244
  detail: !claude
231
245
  ? 'Not found on PATH.'
232
246
  : signedIn
233
247
  ? offSubscription
234
- ? `Signed in, but not on a Claude subscription (${authStatus?.apiProvider}) — AI editing may bill metered API usage.`
248
+ ? `Signed in, but not on a Claude subscription (${providerLabel}) — AI editing may bill metered API usage.`
235
249
  : `Signed in${authStatus?.subscriptionType ? ` · ${authStatus.subscriptionType}` : ''} — AI editing runs on your own Pro/Max subscription.`
236
- : 'Installed, but not signed in.',
250
+ : authUnreadable
251
+ ? "Installed, but Maude couldn't read its sign-in state."
252
+ : 'Installed, but not signed in.',
237
253
  remediation: !claude
238
254
  ? 'Install Claude Code, then sign in. AI editing runs on your own Pro/Max subscription — never API billing.'
239
255
  : signedIn
240
256
  ? undefined
241
- : 'Sign in to connect it to your Pro/Max subscription.',
257
+ : authUnreadable
258
+ ? // Deliberately does NOT interpolate the resolved path (attacker
259
+ // R2-2). This row carries a Copy affordance, and `copyFix` strips
260
+ // the backticks — so a path containing shell metacharacters would
261
+ // land on the clipboard inside a sentence the user is being told to
262
+ // paste. Worse, it would turn the possibly-hijacked binary that
263
+ // DDR-166 Decision 3 shows them precisely so they can DOUBT it into
264
+ // an instruction to run it. The path renders on its own line below.
265
+ 'Maude ran `claude auth status --json` and could not read a status back. The usual cause is a version-manager shim or wrapper (mise, asdf, volta) fronting the real binary — check the path below, fix or quiet the wrapper, then Re-check.'
266
+ : 'Sign in to connect it to your Pro/Max subscription.',
242
267
  installCommand: !claude ? 'curl -fsSL https://claude.ai/install.sh | bash' : undefined,
243
- action: !autoSetupEnabled ? undefined : !claude ? 'install' : !signedIn ? 'signin' : undefined,
268
+ // No Sign-in button when the status is unreadable: `pollForSignin` in
269
+ // ReadinessList.jsx polls THIS probe, so a button whose success signal we
270
+ // can't read can only ever end in "Sign-in timed out" (issue #107). The
271
+ // remediation text above is the actionable path in that state.
272
+ action: !autoSetupEnabled
273
+ ? undefined
274
+ : !claude
275
+ ? 'install'
276
+ : !signedIn && !authUnreadable
277
+ ? 'signin'
278
+ : undefined,
244
279
  resolvedPath: claude ?? undefined,
245
280
  resolvedViaMaude: !!claude && claude === process.env.MAUDE_CLAUDE_BIN,
246
281
  });
@@ -9,7 +9,11 @@ import { tmpdir } from 'node:os';
9
9
  import { join } from 'node:path';
10
10
 
11
11
  import { AcpBridge } from '../acp/bridge.ts';
12
- import { probeAcpAvailability, probeAcpAvailabilityAuthed } from '../acp/probe.ts';
12
+ import {
13
+ getClaudeAuthStatus,
14
+ probeAcpAvailability,
15
+ probeAcpAvailabilityAuthed,
16
+ } from '../acp/probe.ts';
13
17
 
14
18
  const FIXTURE = join(import.meta.dir, 'fixtures', 'mock-acp-agent.mjs');
15
19
  const TEST_ENV_KEYS = [
@@ -272,6 +276,7 @@ describe('probeAcpAvailabilityAuthed — DDR-166 T0d, logged-out must not read a
272
276
 
273
277
  afterEach(() => {
274
278
  delete process.env.FAKE_CLAUDE_LOGGED_IN;
279
+ delete process.env.FAKE_CLAUDE_STDOUT_NOISE;
275
280
  });
276
281
 
277
282
  test('installed but NOT signed in reports available:false (the bug this closes)', async () => {
@@ -298,4 +303,243 @@ describe('probeAcpAvailabilityAuthed — DDR-166 T0d, logged-out must not read a
298
303
  expect(probe.available).toBe(false);
299
304
  expect(probe.reason ?? '').toMatch(/Claude Code/i);
300
305
  });
306
+
307
+ // Issue #107 — the reported `claude` was a mise shim printing a banner to
308
+ // STDOUT before the JSON. Parsing the whole blob threw, the catch returned
309
+ // null, and a signed-in user was told they were signed out.
310
+ test('signed in through a stdout-noisy shim still reports available:true (#107)', async () => {
311
+ process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
312
+ process.env.MAUDE_CLAUDE_BIN = AUTH_FIXTURE;
313
+ process.env.FAKE_CLAUDE_LOGGED_IN = '1';
314
+ process.env.FAKE_CLAUDE_STDOUT_NOISE = '1';
315
+ const probe = await probeAcpAvailabilityAuthed();
316
+ expect(probe.available).toBe(true);
317
+ });
318
+
319
+ test('an UNREADABLE status says so — it does not accuse the user of being signed out (#107)', async () => {
320
+ process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
321
+ process.env.MAUDE_CLAUDE_BIN = join(import.meta.dir, 'fixtures', 'noisy-claude-auth.mjs');
322
+ process.env.FAKE_CLAUDE_OUT = 'mise: something went sideways\nno json here\n';
323
+ try {
324
+ const probe = await probeAcpAvailabilityAuthed();
325
+ expect(probe.available).toBe(false);
326
+ expect(probe.reason ?? '').toContain("couldn't read");
327
+ expect(probe.reason ?? '').not.toMatch(/not signed in/i);
328
+ } finally {
329
+ delete process.env.FAKE_CLAUDE_OUT;
330
+ }
331
+ });
332
+
333
+ test('signed OUT through a stdout-noisy shim is still reported as signed out (no false positive)', async () => {
334
+ process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
335
+ process.env.MAUDE_CLAUDE_BIN = AUTH_FIXTURE;
336
+ process.env.FAKE_CLAUDE_LOGGED_IN = '0';
337
+ process.env.FAKE_CLAUDE_STDOUT_NOISE = '1';
338
+ const probe = await probeAcpAvailabilityAuthed();
339
+ expect(probe.available).toBe(false);
340
+ expect(probe.reason ?? '').toMatch(/signed in/i);
341
+ });
342
+ });
343
+
344
+ // Issue #107 — direct coverage of the stdout shape tolerated by the auth probe.
345
+ // A wrapper on PATH (mise/asdf/volta, corporate `bin` shims) can prepend noise
346
+ // to stdout; the fast path (clean JSON) and the honest failure (nothing
347
+ // parseable → null) both have to keep working alongside it.
348
+ describe('getClaudeAuthStatus — wrapper-stdout tolerance', () => {
349
+ const NOISY = join(import.meta.dir, 'fixtures', 'noisy-claude-auth.mjs');
350
+
351
+ afterEach(() => {
352
+ delete process.env.FAKE_CLAUDE_OUT;
353
+ delete process.env.FAKE_CLAUDE_HEAD;
354
+ delete process.env.FAKE_CLAUDE_PAD_BYTES;
355
+ delete process.env.FAKE_CLAUDE_TAIL;
356
+ });
357
+
358
+ const statusFor = async (stdout: string) => {
359
+ process.env.MAUDE_CLAUDE_BIN = NOISY;
360
+ process.env.FAKE_CLAUDE_OUT = stdout;
361
+ return getClaudeAuthStatus();
362
+ };
363
+
364
+ const PAYLOAD = '{"loggedIn":true,"apiProvider":"firstParty","subscriptionType":"max"}';
365
+
366
+ test('clean JSON — the unchanged fast path', async () => {
367
+ expect(await statusFor(PAYLOAD)).toEqual({
368
+ loggedIn: true,
369
+ apiProvider: 'firstParty',
370
+ subscriptionType: 'max',
371
+ });
372
+ });
373
+
374
+ test('a banner line ahead of the JSON (the reported mise shim)', async () => {
375
+ const out = `mise ~/.config/mise/config.toml tools: claude@2.1.246\n${PAYLOAD}`;
376
+ expect((await statusFor(out))?.loggedIn).toBe(true);
377
+ });
378
+
379
+ test('noise carrying braces of its own does not outrank the real payload', async () => {
380
+ const out = `warning: {"loggedIn":false} is not a real status\nresolving {claude}\n${PAYLOAD}`;
381
+ expect((await statusFor(out))?.subscriptionType).toBe('max');
382
+ });
383
+
384
+ test('trailing noise after the JSON', async () => {
385
+ expect((await statusFor(`${PAYLOAD}\nmise: done in 12ms\n`))?.loggedIn).toBe(true);
386
+ });
387
+
388
+ test('a brace inside a string value never miscounts the object bounds', async () => {
389
+ const out = `noise\n{"orgName":"a } brace \\" in a string","loggedIn":true}\n`;
390
+ expect((await statusFor(out))?.loggedIn).toBe(true);
391
+ });
392
+
393
+ test('nothing parseable still returns null — an unreadable status is not a signed-in one', async () => {
394
+ expect(await statusFor('mise: command failed\nno json here at all\n')).toBeNull();
395
+ });
396
+
397
+ test('a signed-out payload behind noise stays signed out', async () => {
398
+ expect((await statusFor(`mise banner\n{"loggedIn":false}`))?.loggedIn).toBe(false);
399
+ });
400
+
401
+ // ── Security-review regressions (defender M1, attacker F1/F2/F5) ──
402
+ // Every one of these is a way the FIRST cut of this parser failed SOFT and
403
+ // re-created #107 — a confident false claim — through a new door.
404
+
405
+ test('an object with no `loggedIn` is not a status document → null, never a false "signed out" (F2)', async () => {
406
+ // A plain NDJSON-logging wrapper. The first cut returned this as the
407
+ // "fallback", and `!!undefined` rendered it as "Installed, but not signed
408
+ // in." with the dead-end Sign-in button armed.
409
+ const out =
410
+ '{"level":"info","msg":"resolving claude"}\n{"level":"error","msg":"exec failed"}\n';
411
+ expect(await statusFor(out)).toBeNull();
412
+ });
413
+
414
+ test('a JSON error document is not a status document either (F2)', async () => {
415
+ expect(await statusFor('{"error":"ENOENT","code":127}')).toBeNull();
416
+ });
417
+
418
+ test('a signed-in user behind 150 JSON log lines is still read as signed in (M1)', async () => {
419
+ // Forwards + a 100-candidate budget put the real payload outside the scan
420
+ // window and demoted a signed-in user. Backwards spends the budget on noise.
421
+ const noise = Array.from({ length: 150 }, (_, i) => `{"level":"debug","msg":"tool ${i}"}`).join(
422
+ '\n'
423
+ );
424
+ expect((await statusFor(`${noise}\n${PAYLOAD}`))?.loggedIn).toBe(true);
425
+ });
426
+
427
+ test('a flood prefix cannot push the real payload out of range and forge loggedIn (F1)', async () => {
428
+ // The verbatim exploit: a 242-byte prefix — one forged object plus 99 bare
429
+ // `{}` fillers — burned the whole candidate budget so the genuine
430
+ // signed-OUT payload never got looked at. Fail-closed became fail-open.
431
+ const forged = '{"loggedIn":true,"apiProvider":"firstParty","subscriptionType":"max"}';
432
+ const out = forged + '{}'.repeat(99) + '{"loggedIn":false,"apiProvider":"firstParty"}';
433
+ const status = await statusFor(out);
434
+ expect(status?.loggedIn).toBe(false);
435
+ expect(status?.subscriptionType).toBeUndefined();
436
+ });
437
+
438
+ test('padding past the read cap drops the OLDEST bytes, so the forged head loses (F1, variant 2)', async () => {
439
+ // The exploit padded 1 MiB after a forged object so the real payload fell
440
+ // outside the scan window. The read cap is a sliding TAIL window now, so the
441
+ // bytes that get dropped are the attacker's, and the trailing real payload
442
+ // is what survives.
443
+ //
444
+ // The padding is generated by the fixture, not passed through the
445
+ // environment — a multi-MiB env var exceeds the exec limit and the spawn
446
+ // fails with E2BIG, which reads as a null status and would make this test
447
+ // pass for entirely the wrong reason.
448
+ process.env.MAUDE_CLAUDE_BIN = NOISY;
449
+ process.env.FAKE_CLAUDE_HEAD =
450
+ '{"loggedIn":true,"apiProvider":"firstParty","subscriptionType":"max"}';
451
+ process.env.FAKE_CLAUDE_PAD_BYTES = String((1 << 20) + 64);
452
+ process.env.FAKE_CLAUDE_TAIL = '{"loggedIn":false}';
453
+ const status = await getClaudeAuthStatus();
454
+ expect(status?.loggedIn).toBe(false); // the trailing real payload
455
+ expect(status?.subscriptionType).toBeUndefined(); // the forged head was dropped
456
+ });
457
+
458
+ test('a large but sub-cap blob still reads the trailing payload', async () => {
459
+ const out = `${'noise\n'.repeat(20000)}${PAYLOAD}`; // ~120 kB, well under the cap
460
+ expect((await statusFor(out))?.subscriptionType).toBe('max');
461
+ });
462
+
463
+ test('a `{` at index 0 that does not parse costs one candidate, not the whole budget (R2-1)', async () => {
464
+ // `lastIndexOf(needle, -1)` clamps to 0, so index 0 was re-tested for the
465
+ // rest of the budget — a 116x cost amplification measured at ~2.2 s of
466
+ // blocked event loop per probe, at a 1.5-2 s poll cadence. The payload must
467
+ // still be found, and fast.
468
+ const out = `{unparseable at index zero\n${PAYLOAD}`;
469
+ const started = Date.now();
470
+ expect((await statusFor(out))?.subscriptionType).toBe('max');
471
+ expect(Date.now() - started).toBeLessThan(5000);
472
+ });
473
+
474
+ test('a payload whose `{` IS index 0 is still found (the R2-1 fix must not skip it)', async () => {
475
+ expect((await statusFor(PAYLOAD))?.subscriptionType).toBe('max');
476
+ });
477
+
478
+ test('the read cap drops the exact overflow, not a whole chunk (R3-2)', async () => {
479
+ // Chunk-granular shifting over-dropped up to `sizeof(oldest chunk) - 1` —
480
+ // 262 144 B measured for a 150-byte overflow against a real Bun pipe. Here
481
+ // the payload sits 50 bytes into the stream and the stream is 10 bytes over
482
+ // the cap: byte-exact drops 10 bytes of leading junk and keeps it, while a
483
+ // whole-chunk drop takes the payload with it.
484
+ // The assertion has to depend on the HEAD surviving — asserting on the tail
485
+ // passes under both granularities and proves nothing (verified: the first
486
+ // cut of this test was green against the chunk-granular implementation it
487
+ // was written to reject). So the tail carries NO `loggedIn`, and the only
488
+ // status document in the stream is the one 50 bytes in.
489
+ const OVERFLOW = 10;
490
+ const head = `${'z'.repeat(50)}${PAYLOAD}`;
491
+ const tail = '\nmise: done\n';
492
+ process.env.MAUDE_CLAUDE_BIN = NOISY;
493
+ process.env.FAKE_CLAUDE_HEAD = head;
494
+ process.env.FAKE_CLAUDE_PAD_BYTES = String((1 << 20) + OVERFLOW - head.length - tail.length);
495
+ process.env.FAKE_CLAUDE_TAIL = tail;
496
+ expect((await getClaudeAuthStatus())?.subscriptionType).toBe('max');
497
+ });
498
+
499
+ test('a hostile apiProvider is bounded for display but still trips the billing warning (F5)', async () => {
500
+ // Unbounded attacker text used to render verbatim inside a trusted status
501
+ // row. It must be neutralised WITHOUT becoming undefined — that would read
502
+ // as "no provider" and silently suppress the metered-billing warning.
503
+ const evil = `{"loggedIn":true,"apiProvider":"expired — re-authorize at https://evil.example/${'A'.repeat(300)}"}`;
504
+ const status = await statusFor(evil);
505
+ expect(status?.apiProvider).toBe('unrecognized');
506
+ expect(status?.apiProvider).not.toBe('firstParty'); // → offSubscription warning still fires
507
+ });
508
+
509
+ test('a non-string loggedIn is not truthy-coerced into a sign-in', async () => {
510
+ expect((await statusFor('{"loggedIn":"yes"}'))?.loggedIn).toBe(false);
511
+ expect((await statusFor('{"loggedIn":1}'))?.loggedIn).toBe(false);
512
+ });
513
+ });
514
+
515
+ // Defender M2 / attacker F3 — the probe MUST always resolve. `.text()` waits for
516
+ // pipe EOF and `proc.kill()` signals only the direct child, so a wrapper whose
517
+ // descendants keep fd 1 open (exactly #107's self-recursive shape) wedged the
518
+ // read forever: probeReadiness() never resolved, /_api/preflight never answered,
519
+ // and the honest "couldn't read" row was unreachable in its own headline case.
520
+ describe('getClaudeAuthStatus — a wedged CLI must not wedge the probe', () => {
521
+ test('resolves to null within the deadline when a descendant holds stdout open', async () => {
522
+ process.env.MAUDE_CLAUDE_BIN = join(import.meta.dir, 'fixtures', 'wedged-claude-auth.sh');
523
+ const started = Date.now();
524
+ const status = await getClaudeAuthStatus();
525
+ const elapsed = Date.now() - started;
526
+ expect(status).toBeNull(); // unreadable — NOT a "signed out" claim
527
+ expect(elapsed).toBeLessThan(15000); // bounded by AUTH_STATUS_TIMEOUT_MS (5 s) + slack
528
+ }, 30000);
529
+
530
+ // Round-2 defender L6 — the deadline must not throw away an answer already
531
+ // held. A signed-in user whose wrapper backgrounds anything at all printed a
532
+ // perfectly good status and still lost AI editing.
533
+ test('a status printed before a descendant wedges the pipe is still read', async () => {
534
+ process.env.MAUDE_CLAUDE_BIN = join(
535
+ import.meta.dir,
536
+ 'fixtures',
537
+ 'backgrounding-claude-auth.sh'
538
+ );
539
+ const started = Date.now();
540
+ const status = await getClaudeAuthStatus();
541
+ expect(status?.loggedIn).toBe(true);
542
+ expect(status?.subscriptionType).toBe('max');
543
+ expect(Date.now() - started).toBeLessThan(15000);
544
+ }, 30000);
301
545
  });
@@ -0,0 +1,12 @@
1
+ #!/bin/sh
2
+ # Test fixture for issue #107 round-2 defender L6 — a `claude` stand-in that
3
+ # prints a perfectly good status document and THEN leaves a descendant holding
4
+ # fd 1 (a daemon, an update check, a corporate agent). The pipe never reaches
5
+ # EOF, so the read hits its deadline — but the answer is already in the buffer
6
+ # and must not be thrown away.
7
+ if [ "$1" = "auth" ] && [ "$2" = "status" ]; then
8
+ echo '{"loggedIn":true,"apiProvider":"firstParty","subscriptionType":"max"}'
9
+ sleep 120 &
10
+ exit 0
11
+ fi
12
+ exit 1
@@ -2,9 +2,19 @@
2
2
  // Test fixture standing in for the `claude` CLI — implements only the one
3
3
  // subcommand probeAcpAvailabilityAuthed()/getClaudeAuthStatus() shell out to.
4
4
  // FAKE_CLAUDE_LOGGED_IN=1 controls the reported sign-in state.
5
+ //
6
+ // FAKE_CLAUDE_STDOUT_NOISE=1 reproduces issue #107: a version-manager shim
7
+ // (mise/asdf/volta) or corporate wrapper fronting the real binary and printing
8
+ // a banner to STDOUT ahead of the JSON. The second noise line deliberately
9
+ // carries braces of its own, pinning that a brace-bearing banner can't outrank
10
+ // the real payload.
5
11
  const [, , cmd, sub] = process.argv;
6
12
  if (cmd === 'auth' && sub === 'status') {
7
13
  const loggedIn = process.env.FAKE_CLAUDE_LOGGED_IN === '1';
14
+ if (process.env.FAKE_CLAUDE_STDOUT_NOISE === '1') {
15
+ process.stdout.write('mise ~/.config/mise/config.toml tools: claude@2.1.246\n');
16
+ process.stdout.write('resolving {claude} from {global} config\n');
17
+ }
8
18
  process.stdout.write(
9
19
  JSON.stringify({ loggedIn, apiProvider: loggedIn ? 'firstParty' : undefined })
10
20
  );
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+ // Test fixture for issue #107 — a `claude` stand-in whose `auth status --json`
3
+ // stdout is exactly what the test asks for. Lets a test pin the wrapper-noise
4
+ // shape it cares about (banner line, brace-bearing noise, trailing chatter,
5
+ // pure garbage) without a fixture per case.
6
+ //
7
+ // FAKE_CLAUDE_OUT verbatim stdout, for the small cases.
8
+ // FAKE_CLAUDE_HEAD / FAKE_CLAUDE_PAD_BYTES / FAKE_CLAUDE_TAIL
9
+ // head + N generated filler bytes + tail. The padding is
10
+ // generated HERE because a multi-MiB env var exceeds the
11
+ // exec argument/environment limit — passing it in makes the
12
+ // spawn fail with E2BIG, which a test would misread as the
13
+ // parser rejecting the input.
14
+ //
15
+ // No `process.exit()` after the write: on a pipe a large write completes
16
+ // asynchronously, and an explicit exit truncates it. Let the process end on its
17
+ // own once stdout has drained, so multi-MiB cases deliver every byte.
18
+ const [, , cmd, sub] = process.argv;
19
+ if (cmd !== 'auth' || sub !== 'status') {
20
+ process.exitCode = 1;
21
+ } else if (process.env.FAKE_CLAUDE_PAD_BYTES) {
22
+ process.stdout.write(process.env.FAKE_CLAUDE_HEAD ?? '');
23
+ process.stdout.write('x'.repeat(Number(process.env.FAKE_CLAUDE_PAD_BYTES)));
24
+ process.stdout.write(process.env.FAKE_CLAUDE_TAIL ?? '');
25
+ } else {
26
+ process.stdout.write(process.env.FAKE_CLAUDE_OUT ?? '');
27
+ }
@@ -0,0 +1,11 @@
1
+ #!/bin/sh
2
+ # Test fixture for issue #107 (defender M2 / attacker F3) — a `claude` stand-in
3
+ # that reproduces the wedge shape: it exits immediately, but leaves a background
4
+ # descendant holding fd 1 (stdout). `proc.kill()` signals only the direct child,
5
+ # so the pipe never reaches EOF and a read that waits for EOF hangs forever.
6
+ # `readBounded` must give up on its own deadline instead.
7
+ if [ "$1" = "auth" ] && [ "$2" = "status" ]; then
8
+ sleep 120 &
9
+ exit 0
10
+ fi
11
+ exit 1
@@ -139,6 +139,117 @@ describe('readiness — plugin auto-bootstrap (native, DDR-143/DDR-168)', () =>
139
139
  });
140
140
  });
141
141
 
142
+ // Issue #107 — the `claude` row must never assert "not signed in" about a CLI
143
+ // whose auth status it could not READ. That lie is what a mise-shim user hit:
144
+ // signed in in the terminal, told otherwise by the app, and routed to a Sign-in
145
+ // button whose poll re-ran the same failing probe until it timed out.
146
+ describe('readiness — claude row, unreadable auth status (#107)', () => {
147
+ const claudeItem = async (bin: string) => {
148
+ const saved = process.env.MAUDE_CLAUDE_BIN;
149
+ process.env.MAUDE_CLAUDE_BIN = bin;
150
+ try {
151
+ return (await probeReadiness()).items.find((i) => i.id === 'claude')!;
152
+ } finally {
153
+ if (saved === undefined) delete process.env.MAUDE_CLAUDE_BIN;
154
+ else process.env.MAUDE_CLAUDE_BIN = saved;
155
+ }
156
+ };
157
+
158
+ test('resolvable CLI + unreadable status → unknown, not a "not signed in" claim', async () => {
159
+ // `sh` resolves as a binary but answers nothing useful to `auth status --json`.
160
+ const item = await claudeItem('/bin/sh');
161
+ expect(item.status).toBe('unknown');
162
+ expect(item.detail).not.toContain('not signed in');
163
+ expect(item.detail).toContain("couldn't read");
164
+ });
165
+
166
+ test('the remediation names the wrapper cause but keeps the path OUT of the copyable sentence', async () => {
167
+ const item = await claudeItem('/bin/sh');
168
+ expect(item.remediation ?? '').toMatch(/shim|wrapper/i);
169
+ // The row has a Copy affordance and `copyFix` strips backticks, so a path
170
+ // with shell metacharacters would ride onto the clipboard inside a sentence
171
+ // the user is told to paste — and it would turn the possibly-hijacked binary
172
+ // into an instruction to RUN it (attacker R2-2). It renders on its own line.
173
+ expect(item.remediation ?? '').not.toContain('/bin/sh');
174
+ // Nor does it coach an un-content-pinned PATH override at a confused user
175
+ // (attacker F4 — Maude would be clipboard-loading the incantation).
176
+ expect(item.remediation ?? '').not.toContain('MAUDE_CLAUDE_BIN');
177
+ });
178
+
179
+ test('the resolved path stays visible in the unknown state (DDR-166 Decision 3)', async () => {
180
+ // The row must still say WHICH binary it resolved — that display is the
181
+ // control against an invisible PATH hijack, and the unknown state is exactly
182
+ // where a hijack is most likely.
183
+ expect((await claudeItem('/bin/sh')).resolvedPath).toBe('/bin/sh');
184
+ });
185
+
186
+ test('no Sign-in button offered — its success signal is the probe that just failed', async () => {
187
+ expect((await claudeItem('/bin/sh')).action).toBeUndefined();
188
+ });
189
+
190
+ test('unknown still leaves the report not-ready (claude is a required item)', async () => {
191
+ const saved = process.env.MAUDE_CLAUDE_BIN;
192
+ process.env.MAUDE_CLAUDE_BIN = '/bin/sh';
193
+ try {
194
+ expect((await probeReadiness()).ready).toBe(false);
195
+ } finally {
196
+ if (saved === undefined) delete process.env.MAUDE_CLAUDE_BIN;
197
+ else process.env.MAUDE_CLAUDE_BIN = saved;
198
+ }
199
+ });
200
+
201
+ // Attacker R2-3 — a metered user could suppress DDR-123's billing warning by
202
+ // OMITTING apiProvider rather than forging 'firstParty'. Absent is not
203
+ // first-party; over-warning is the safe direction.
204
+ test('an absent apiProvider still trips the metered-billing warning', async () => {
205
+ const noisy = join(import.meta.dir, 'fixtures', 'noisy-claude-auth.mjs');
206
+ const saved = process.env.MAUDE_CLAUDE_BIN;
207
+ process.env.MAUDE_CLAUDE_BIN = noisy;
208
+ process.env.FAKE_CLAUDE_OUT = '{"loggedIn":true}'; // signed in, no provider field
209
+ try {
210
+ const item = (await probeReadiness()).items.find((i) => i.id === 'claude')!;
211
+ expect(item.status).toBe('present');
212
+ expect(item.detail).toContain('may bill metered API usage');
213
+ expect(item.detail).not.toContain('Pro/Max subscription.');
214
+ } finally {
215
+ delete process.env.FAKE_CLAUDE_OUT;
216
+ if (saved === undefined) delete process.env.MAUDE_CLAUDE_BIN;
217
+ else process.env.MAUDE_CLAUDE_BIN = saved;
218
+ }
219
+ });
220
+
221
+ test('an explicit firstParty still reads as on-subscription, with no warning', async () => {
222
+ const noisy = join(import.meta.dir, 'fixtures', 'noisy-claude-auth.mjs');
223
+ const saved = process.env.MAUDE_CLAUDE_BIN;
224
+ process.env.MAUDE_CLAUDE_BIN = noisy;
225
+ process.env.FAKE_CLAUDE_OUT =
226
+ '{"loggedIn":true,"apiProvider":"firstParty","subscriptionType":"max"}';
227
+ try {
228
+ const item = (await probeReadiness()).items.find((i) => i.id === 'claude')!;
229
+ expect(item.detail).toContain('Pro/Max subscription');
230
+ expect(item.detail).not.toContain('metered');
231
+ } finally {
232
+ delete process.env.FAKE_CLAUDE_OUT;
233
+ if (saved === undefined) delete process.env.MAUDE_CLAUDE_BIN;
234
+ else process.env.MAUDE_CLAUDE_BIN = saved;
235
+ }
236
+ });
237
+
238
+ test('a signed-out CLI is still reported as signed out, with the Sign-in action intact', async () => {
239
+ const saved = process.env.MAUDE_E2E_FORCE_CLAUDE_STATUS;
240
+ process.env.MAUDE_E2E_FORCE_CLAUDE_STATUS = 'signed-out';
241
+ try {
242
+ const item = (await probeReadiness()).items.find((i) => i.id === 'claude')!;
243
+ expect(item.status).toBe('missing');
244
+ expect(item.detail).toContain('not signed in');
245
+ expect(item.action).toBe('signin');
246
+ } finally {
247
+ if (saved === undefined) delete process.env.MAUDE_E2E_FORCE_CLAUDE_STATUS;
248
+ else process.env.MAUDE_E2E_FORCE_CLAUDE_STATUS = saved;
249
+ }
250
+ });
251
+ });
252
+
142
253
  describe('readiness — resolveOnPath', () => {
143
254
  test('finds a binary that is on PATH', async () => {
144
255
  const hit = await resolveOnPath('sh');
@@ -1,6 +1,25 @@
1
1
  {
2
2
  "$schema": "./whats-new.schema.json",
3
3
  "entries": [
4
+ {
5
+ "id": "canvas-media-plays-on-linux",
6
+ "version": "1.0.10",
7
+ "date": "2026-08-26",
8
+ "kind": "fix",
9
+ "title": "Canvas video and audio work on Linux",
10
+ "summary": "Opening a canvas with video or audio on Linux could take the whole view down with no message — once because the GStreamer plugins it needs are optional on most distros, and again because the GPU video path faults on AMD graphics. The .deb now pulls those plugins in, Maude steers around the AMD fault by itself, and if the plugins are missing anyway it tells you, with the exact install command for your distro.",
11
+ "learnMore": "https://maude.sh/docs/desktop#media-playback-on-linux",
12
+ "surface": "design-ui"
13
+ },
14
+ {
15
+ "id": "a-stopped-server-says-so",
16
+ "version": "1.0.10",
17
+ "date": "2026-08-26",
18
+ "kind": "fix",
19
+ "title": "A stopped server now says so",
20
+ "summary": "If the server behind your project stopped for good, Maude used to leave the window sitting there — nothing loading, nothing saving, and no reason given. Now it tells you which project stopped, confirms your files on disk are untouched, and offers Try again. A one-off crash hours into a session also no longer counts against the give-up limit the way a genuine restart loop does.",
21
+ "surface": "design-ui"
22
+ },
4
23
  {
5
24
  "id": "exports-match-what-you-see",
6
25
  "version": "1.0.8",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1agh/maude",
3
- "version": "1.0.9",
3
+ "version": "1.0.11",
4
4
  "description": "Marketplace of Claude Code plugins by Michal Dovrtěl: `design` (canvas-first design iteration) + `flow` (generic agentic workflow loop with .ai second brain). Ships the `maude` CLI (with `mdcc` legacy alias) to scaffold workspace, run the design dev server, and manage configs.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -54,13 +54,13 @@
54
54
  "test:e2e:desktop:parity:cloud": "pnpm --filter @maude/desktop-e2e e2e:parity:cloud"
55
55
  },
56
56
  "optionalDependencies": {
57
- "@1agh/maude-darwin-arm64": "1.0.9",
58
- "@1agh/maude-darwin-x64": "1.0.9",
59
- "@1agh/maude-linux-arm64": "1.0.9",
60
- "@1agh/maude-linux-arm64-musl": "1.0.9",
61
- "@1agh/maude-linux-x64": "1.0.9",
62
- "@1agh/maude-linux-x64-musl": "1.0.9",
63
- "@1agh/maude-win32-x64": "1.0.9"
57
+ "@1agh/maude-darwin-arm64": "1.0.11",
58
+ "@1agh/maude-darwin-x64": "1.0.11",
59
+ "@1agh/maude-linux-arm64": "1.0.11",
60
+ "@1agh/maude-linux-arm64-musl": "1.0.11",
61
+ "@1agh/maude-linux-x64": "1.0.11",
62
+ "@1agh/maude-linux-x64-musl": "1.0.11",
63
+ "@1agh/maude-win32-x64": "1.0.11"
64
64
  },
65
65
  "files": [
66
66
  "cli",