@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/readiness.ts
CHANGED
|
@@ -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
|
-
|
|
218
|
-
|
|
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 (${
|
|
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
|
-
:
|
|
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
|
-
:
|
|
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
|
-
|
|
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 {
|
|
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.
|
|
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.
|
|
58
|
-
"@1agh/maude-darwin-x64": "1.0.
|
|
59
|
-
"@1agh/maude-linux-arm64": "1.0.
|
|
60
|
-
"@1agh/maude-linux-arm64-musl": "1.0.
|
|
61
|
-
"@1agh/maude-linux-x64": "1.0.
|
|
62
|
-
"@1agh/maude-linux-x64-musl": "1.0.
|
|
63
|
-
"@1agh/maude-win32-x64": "1.0.
|
|
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",
|