mirror of
https://github.com/aaddrick/claude-desktop-debian.git
synced 2026-05-17 00:26:21 +03:00
test(harness): session 8 runners + invokeEipcChannel primitive (3 new specs + 1 primitive extension, 87% → 91% coverage)
Adds three Tier 2 invocation probes — T35b / T37b paired with the
existing T35 / T37 Tier 1 fingerprints (session 4), plus T27 as the
case-doc Tier 2 reframe of "Scheduled task fires and notifies" (no
prior fingerprint sibling, mirrors T26's no-fingerprint shape). All
three call eipc handlers through the renderer-side wrapper at
\`window['claude.<scope>'].<Iface>.<method>\` and assert the
documented response shape:
- T35b — \`claude.settings/MCP/getMcpServersConfig\` returns a
non-array object (Record<string, MCPServerConfig>).
- T37b — \`claude.web/CoworkMemory/readGlobalMemory\` returns
\`string | null\`.
- T27 — both \`claude.web/CoworkScheduledTasks\` and
\`claude.web/CCDScheduledTasks\` \`getAllScheduledTasks\` return
arrays (parallel-scope assertion: Cowork = chat-side / Routines
sidebar; CCD = Code-tab).
New \`invokeEipcChannel(inspector, suffix, args?, opts?)\` API on
\`lib/eipc.ts\` resolves the case-doc-anchored suffix through the
existing \`findEipcChannel\` walker, splits the full
\`<scope>_$_<iface>_$_<method>\` suffix to recover the wrapper path,
then calls through \`evalInRenderer('claude.ai',
"window['claude.<scope>'].<Iface>.<method>(...args)")\`. Renderer-
side rather than main-side direct-call because the per-handler
origin gates (\`le()\` / \`Vi()\` / \`mm()\` in the bundle) are
duck-typed structural checks that a fake event passes — but going
through the wrapper carries an honest \`senderFrame\` and aligns
test surface with real attack surface. Main-side direct call stays
available as a fallback for non-claude.ai webContents (no current
consumer).
Three parallel investigation subagents confirmed the gate semantics
empirically — see plan-doc session 8 status section for the
findings, the wrapper-namespace catalogue (9 \`window['claude.*']\`
namespaces), the \`mainView.js:792\`-onwards exposure-gate \`Qc()\`
behavior, and the operon-scope exposure-vs-registration question
flagged for session 9.
All three pass on KDE-W (Plasma 6 Wayland, XWayland) — T27 27.7s,
T35b 33.2s, T37b 25.8s, ~1.5m total sequential. \`npm run
typecheck\` clean.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -35,25 +35,35 @@
|
||||
// So the wait-for-channel poll just needs claude.ai to be alive +
|
||||
// finished initial handler registration, NOT a specific route.
|
||||
//
|
||||
// What this primitive does NOT do
|
||||
// -------------------------------
|
||||
// Read-only enumeration. No invocation. The `_invokeHandlers` map
|
||||
// holds the handler functions but they expect a synthesized
|
||||
// `IpcMainInvokeEvent` with a real sender, and most claude.web_$_*
|
||||
// handlers have side effects (`startSideChat` writes to the user's
|
||||
// account; `openInEditor` shells out to a real editor). T22/T31/T33/T38
|
||||
// only need handler PRESENCE — that's strictly stronger than the asar
|
||||
// fingerprint (a handler registered at runtime is a handler that
|
||||
// actually wired up, not just a string in the bundle).
|
||||
// What this primitive does
|
||||
// ------------------------
|
||||
// Read-only enumeration via `getEipcChannels` / `findEipcChannel` /
|
||||
// `waitForEipcChannel(s)`. Handler PRESENCE checks (T22b / T31b / T33b
|
||||
// / T38b) — that's strictly stronger than the asar fingerprint (a
|
||||
// handler registered at runtime is a handler that actually wired up,
|
||||
// not just a string in the bundle).
|
||||
//
|
||||
// Future T35 Phase 2 / T37 Phase 2 may want invocation against
|
||||
// read-only handlers like `claude.settings/MCP/getMcpServersConfig`
|
||||
// or `claude.web/CoworkMemory/readGlobalMemory`. When that happens
|
||||
// the right shape is a separate `invokeEipcChannel(inspector, suffix,
|
||||
// args)` helper here — keep this file the single home for eipc surface
|
||||
// abstractions. Don't add it speculatively until a consumer needs it
|
||||
// (same anti-speculation rule as `lib/electron-mocks.ts` /
|
||||
// `lib/input.ts` / `lib/input-niri.ts`).
|
||||
// Plus `invokeEipcChannel` (session 8 addition) — calls a registered
|
||||
// handler through the renderer-side wrapper at `window['claude.<scope>']
|
||||
// .<Iface>.<method>(...args)`. The wrapper is exposed by `mainView.js`
|
||||
// preload via `contextBridge.exposeInMainWorld` after a frame + origin
|
||||
// gate (top-level frame, origin in `{claude.ai, claude.com,
|
||||
// preview.claude.ai, preview.claude.com, localhost}`). Because the
|
||||
// `inspector.evalInRenderer('claude.ai', ...)` path runs inside the
|
||||
// claude.ai renderer, the wrapper is present and the synthesized
|
||||
// `IpcMainInvokeEvent` carries an honest `senderFrame` — the alternative
|
||||
// of pulling the function out of `_invokeHandlers` and synthesizing a
|
||||
// fake event with `senderFrame.url = 'https://claude.ai/'` works (the
|
||||
// gates are duck-typed structural checks) but spoofs a security-relevant
|
||||
// claim. Going through the wrapper keeps the test surface aligned with
|
||||
// real attack surface.
|
||||
//
|
||||
// `invokeEipcChannel` is read-by-default but doesn't enforce a
|
||||
// read-only allowlist — the safety property is that consumers pass
|
||||
// case-doc-anchored suffixes verbatim, which limits the blast radius
|
||||
// to whatever the case doc said the test should poke. Don't pass
|
||||
// `start*` / `set*` / `write*` / `run*` / `openIn*` suffixes; those
|
||||
// mutate user state.
|
||||
//
|
||||
// Framing opacity
|
||||
// ---------------
|
||||
@@ -293,3 +303,111 @@ export async function waitForEipcChannels(
|
||||
);
|
||||
return result ?? lastSnapshot;
|
||||
}
|
||||
|
||||
export interface InvokeEipcChannelOptions {
|
||||
// Renderer URL filter. Default 'claude.ai' — the only webContents
|
||||
// whose origin passes the wrapper-exposure gate (`Qc()` in
|
||||
// `mainView.js`: `https://claude.ai`, `https://claude.com`,
|
||||
// preview.*, localhost). The `find_in_page` and `main_window`
|
||||
// webContents register `claude.settings/*` handlers in their
|
||||
// per-wc IPC scope but their renderers run from `file://`, so
|
||||
// `window['claude.settings']` is never exposed there and invocation
|
||||
// through them would need a different (main-side, fake-event)
|
||||
// approach not implemented in this primitive.
|
||||
urlFilter?: string;
|
||||
// Inspector eval timeout. Default = InspectorClient.defaultTimeoutMs
|
||||
// (30s). Read-only handlers like `getMcpServersConfig` /
|
||||
// `readGlobalMemory` / `getAllScheduledTasks` return well within
|
||||
// 1s on a warm app; the 30s budget is for cold-cache cases.
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
// Invoke an eipc handler through the renderer-side wrapper at
|
||||
// `window['claude.<scope>'].<Iface>.<method>(...args)`. The suffix is
|
||||
// resolved against the per-wc registry first (same matching rules as
|
||||
// `findEipcChannel` — accepts both fully-qualified
|
||||
// `claude.web_$_LocalSessions_$_getPrChecks` and the more concise
|
||||
// `LocalSessions_$_getPrChecks`) and the scope/iface/method triplet is
|
||||
// pulled from the resolved full suffix.
|
||||
//
|
||||
// Why through the renderer wrapper, not a direct main-side call:
|
||||
// handlers register via `e.ipc.handle(framedName, async (event, args)
|
||||
// => { if (!le(event)) throw ...; return A.<method>(args); })` — the
|
||||
// origin gate is inlined at registration time (variants `le`/`Vi`/`mm`
|
||||
// in the bundle, all duck-typed structural checks against
|
||||
// `event.senderFrame.url` and `event.senderFrame.parent === null`).
|
||||
// Pulling the function out of `_invokeHandlers` and calling it with a
|
||||
// synthesized event whose `senderFrame.url` is `'https://claude.ai/'`
|
||||
// works (the gate is structural, not `instanceof`-checked) but spoofs
|
||||
// the gate's security claim. The wrapper IS at claude.ai, so the
|
||||
// synthesized event carries an honest senderFrame and the test surface
|
||||
// matches real attack surface.
|
||||
//
|
||||
// Errors:
|
||||
// - "no handler registered with suffix": the registry walk returned
|
||||
// nothing matching. Same shape as `findEipcChannel` returning null;
|
||||
// waitForEipcChannel first if your spec needs the populate-on-init
|
||||
// poll.
|
||||
// - "eipc namespace missing in renderer: claude.<scope>": the wrapper
|
||||
// isn't exposed on this renderer. Either the urlFilter selected a
|
||||
// webContents whose origin failed `Qc()`, or the build flipped the
|
||||
// scope's exposure gate. Check `evalInRenderer(urlFilter,
|
||||
// 'Object.keys(window).filter(k => k.startsWith("claude."))')`.
|
||||
// - String-form rejection from the renderer eval: the gate / arg-
|
||||
// validator / result-validator inside the handler closure rejected.
|
||||
// The framed channel name appears in the error message — use it to
|
||||
// pinpoint which handler rejected.
|
||||
//
|
||||
// Args are JSON-marshaled into the renderer eval. Return value is
|
||||
// JSON-deserialized via `evalInRenderer`'s `executeJavaScript` path.
|
||||
// Non-JSON-serializable handler returns (Date, Buffer, circular refs)
|
||||
// would mangle through this primitive — none of the current Tier 2
|
||||
// case-doc consumers return such shapes; flag if a future one does.
|
||||
export async function invokeEipcChannel<T = unknown>(
|
||||
inspector: InspectorClient,
|
||||
caseDocSuffix: string,
|
||||
args: readonly unknown[] = [],
|
||||
opts: InvokeEipcChannelOptions = {},
|
||||
): Promise<T> {
|
||||
const urlFilter = opts.urlFilter ?? 'claude.ai';
|
||||
const channel = await findEipcChannel(inspector, caseDocSuffix, {
|
||||
urlFilter,
|
||||
});
|
||||
if (!channel) {
|
||||
throw new Error(
|
||||
`invokeEipcChannel: no handler registered with suffix ` +
|
||||
`'${caseDocSuffix}' on a webContents matching ` +
|
||||
`'${urlFilter}'`,
|
||||
);
|
||||
}
|
||||
// Full suffix is `<scope>_$_<iface>_$_<method>`. Scope contains a
|
||||
// dot (e.g. claude.web) but the `_$_` separator is unambiguous —
|
||||
// a 3-part split gives [scope, iface, method] cleanly.
|
||||
const parts = channel.suffix.split('_$_');
|
||||
if (parts.length !== 3) {
|
||||
throw new Error(
|
||||
`invokeEipcChannel: bad suffix shape '${channel.suffix}' ` +
|
||||
`(expected '<scope>_$_<iface>_$_<method>')`,
|
||||
);
|
||||
}
|
||||
const [scope, iface, method] = parts;
|
||||
const argsJson = JSON.stringify(args);
|
||||
const js = `(async () => {
|
||||
const ns = window[${JSON.stringify(scope)}];
|
||||
if (!ns) throw new Error(
|
||||
'eipc namespace missing in renderer: ' + ${JSON.stringify(scope)}
|
||||
);
|
||||
const ifaceObj = ns[${JSON.stringify(iface)}];
|
||||
if (!ifaceObj) throw new Error(
|
||||
'eipc interface missing: ' + ${JSON.stringify(iface)} +
|
||||
' (under ' + ${JSON.stringify(scope)} + ')'
|
||||
);
|
||||
const fn = ifaceObj[${JSON.stringify(method)}];
|
||||
if (typeof fn !== 'function') throw new Error(
|
||||
'eipc method not a function: ' + ${JSON.stringify(method)} +
|
||||
' (under ' + ${JSON.stringify(scope)} + '.' + ${JSON.stringify(iface)} + ')'
|
||||
);
|
||||
return await fn.apply(ifaceObj, ${argsJson});
|
||||
})()`;
|
||||
return inspector.evalInRenderer<T>(urlFilter, js, opts.timeoutMs);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { launchClaude } from '../lib/electron.js';
|
||||
import { createIsolation, type Isolation } from '../lib/isolation.js';
|
||||
import { captureSessionEnv } from '../lib/diagnostics.js';
|
||||
import { invokeEipcChannel, waitForEipcChannels } from '../lib/eipc.js';
|
||||
|
||||
// T27 — Scheduled task readback handlers invocable at runtime (Tier 2
|
||||
// reframe of the case-doc T27 "Scheduled task fires and notifies"
|
||||
// case; full case-doc form requires login + creating a Manual task +
|
||||
// clicking Run now and observing notification, which is Tier 3 work).
|
||||
//
|
||||
// Backs T27 in docs/testing/cases/routines.md. The case-doc anchors
|
||||
// are `:282332` (`runNow(A)` — manual dispatch), `:512837`
|
||||
// (`Rc.showNotification(...,scheduled-${l},...)` — desktop
|
||||
// notification on completion), and `:282654`
|
||||
// (`getJitterSecondsForTask` — deterministic per-task offset). The
|
||||
// natural Tier 2 reframe is the read-side handler that lists scheduled
|
||||
// tasks at runtime — wire-confirming that the scheduling registry is
|
||||
// plumbed through to a reachable read endpoint, which is what feeds
|
||||
// the Routines sidebar list and the Run-now dispatch path.
|
||||
//
|
||||
// Two parallel scopes register the same `getAllScheduledTasks` shape:
|
||||
// - `claude.web/CoworkScheduledTasks` — Cowork (chat-side / Routines
|
||||
// sidebar) scheduled tasks.
|
||||
// - `claude.web/CCDScheduledTasks` — Claude Code Desktop (Code-tab)
|
||||
// scheduled tasks.
|
||||
// Both register on the claude.ai webContents per session 7's
|
||||
// eipc-registry probe. Asserting both as a load-bearing pair captures
|
||||
// the case-doc surface that mentions both Manual tasks (Cowork-shaped)
|
||||
// and Hourly tasks across the next-hour boundary (CCD-shaped).
|
||||
//
|
||||
// Why no Tier 1 fingerprint sibling
|
||||
// ---------------------------------
|
||||
// Sessions 1-7 didn't ship a T27 fingerprint runner — the case-doc
|
||||
// anchors lean on `runNow(A)` / `Rc.showNotification` / `getJitter`,
|
||||
// which are minified-symbol-shaped (single-letter callsites) and
|
||||
// don't form a high-confidence string fingerprint. The eipc registry
|
||||
// names ARE high-confidence (full case-doc-shape strings), so T27
|
||||
// ships directly as the runtime probe — same shape as T26's
|
||||
// "Routines page renders" runner that also has no fingerprint
|
||||
// sibling.
|
||||
//
|
||||
// Why the runtime probe is meaningful
|
||||
// -----------------------------------
|
||||
// `getAllScheduledTasks` returning an array shape proves the handler
|
||||
// is wired AND the read path through to the per-account scheduled-
|
||||
// tasks store works. The Routines sidebar list (case-doc T26 case)
|
||||
// and the Run-now dispatch path (case-doc T27 case) both depend on
|
||||
// this read endpoint. A wiring regression that breaks either case
|
||||
// would surface as a thrown error / wrong-type response here.
|
||||
//
|
||||
// Skip semantics
|
||||
// --------------
|
||||
// `seedFromHost: true` is required — same reasoning as T35b/T37b.
|
||||
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const EXPECTED_SUFFIXES = [
|
||||
'CoworkScheduledTasks_$_getAllScheduledTasks',
|
||||
'CCDScheduledTasks_$_getAllScheduledTasks',
|
||||
] as const;
|
||||
|
||||
test('T27 — Scheduled task readback handlers invocable at runtime', async (
|
||||
{},
|
||||
testInfo,
|
||||
) => {
|
||||
testInfo.annotations.push({ type: 'severity', description: 'Critical' });
|
||||
testInfo.annotations.push({
|
||||
type: 'surface',
|
||||
description: 'Routines runtime (eipc invocation)',
|
||||
});
|
||||
|
||||
await testInfo.attach('session-env', {
|
||||
body: JSON.stringify(captureSessionEnv(), null, 2),
|
||||
contentType: 'application/json',
|
||||
});
|
||||
|
||||
let isolation: Isolation;
|
||||
try {
|
||||
isolation = await createIsolation({ seedFromHost: true });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
test.skip(true, `seedFromHost unavailable: ${msg}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const app = await launchClaude({ isolation });
|
||||
try {
|
||||
const ready = await app.waitForReady('userLoaded');
|
||||
await testInfo.attach('claude-ai-url', {
|
||||
body: ready.claudeAiUrl ?? '(no claude.ai webContents observed)',
|
||||
contentType: 'text/plain',
|
||||
});
|
||||
if (!ready.postLoginUrl) {
|
||||
test.skip(
|
||||
true,
|
||||
'seeded auth did not reach post-login URL — host config ' +
|
||||
'may be stale (signed out, expired session, etc.)',
|
||||
);
|
||||
return;
|
||||
}
|
||||
await testInfo.attach('post-login-url', {
|
||||
body: ready.postLoginUrl,
|
||||
contentType: 'text/plain',
|
||||
});
|
||||
|
||||
// First confirm registration of the pair, then invoke each.
|
||||
const resolved = await waitForEipcChannels(
|
||||
ready.inspector,
|
||||
EXPECTED_SUFFIXES,
|
||||
);
|
||||
|
||||
// Per-suffix invocation result for the diagnostic attachment.
|
||||
// Empty arrays on the dev box (no scheduled tasks created); a
|
||||
// configured-host run would produce non-empty arrays.
|
||||
const invocations: Record<string, {
|
||||
channelResolved: unknown;
|
||||
responseShape: string;
|
||||
responseLength: number | null;
|
||||
}> = {};
|
||||
|
||||
for (const suffix of EXPECTED_SUFFIXES) {
|
||||
const channel = resolved.get(suffix);
|
||||
let responseShape = 'not-invoked';
|
||||
let responseLength: number | null = null;
|
||||
if (channel) {
|
||||
const result = await invokeEipcChannel<unknown>(
|
||||
ready.inspector,
|
||||
suffix,
|
||||
[],
|
||||
);
|
||||
if (Array.isArray(result)) {
|
||||
responseShape = `array(length=${result.length})`;
|
||||
responseLength = result.length;
|
||||
} else if (result === null) {
|
||||
responseShape = 'null';
|
||||
} else {
|
||||
responseShape = typeof result;
|
||||
}
|
||||
// Per-suffix expectation, attached BEFORE expect() so a
|
||||
// failure carries the partial diagnostics in JUnit.
|
||||
invocations[suffix] = {
|
||||
channelResolved: channel,
|
||||
responseShape,
|
||||
responseLength,
|
||||
};
|
||||
expect(
|
||||
Array.isArray(result),
|
||||
`[T27] ${suffix} response is an array ` +
|
||||
`(got ${responseShape}) — case-doc anchor ` +
|
||||
':282332 (`runNow(A)`) and :512837 ' +
|
||||
'(`Rc.showNotification(...,scheduled-${l},...)`) ' +
|
||||
'both consume an array-shaped scheduled-tasks list',
|
||||
).toBe(true);
|
||||
} else {
|
||||
invocations[suffix] = {
|
||||
channelResolved: null,
|
||||
responseShape,
|
||||
responseLength,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
await testInfo.attach('scheduled-tasks-invocations', {
|
||||
body: JSON.stringify(
|
||||
{
|
||||
expectedSuffixes: EXPECTED_SUFFIXES,
|
||||
invocations,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
contentType: 'application/json',
|
||||
});
|
||||
|
||||
for (const suffix of EXPECTED_SUFFIXES) {
|
||||
expect(
|
||||
resolved.get(suffix),
|
||||
`[T27] eipc channel ending in '${suffix}' is registered on ` +
|
||||
'the claude.ai webContents — load-bearing for the ' +
|
||||
'Routines sidebar list (Cowork) and Code-tab scheduled ' +
|
||||
'tasks (CCD); case-doc anchors index.js:282332 / :512837',
|
||||
).not.toBeNull();
|
||||
}
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
204
tools/test-harness/src/runners/T35b_mcp_config_runtime.spec.ts
Normal file
204
tools/test-harness/src/runners/T35b_mcp_config_runtime.spec.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { launchClaude } from '../lib/electron.js';
|
||||
import { createIsolation, type Isolation } from '../lib/isolation.js';
|
||||
import { captureSessionEnv } from '../lib/diagnostics.js';
|
||||
import { invokeEipcChannel, waitForEipcChannel } from '../lib/eipc.js';
|
||||
|
||||
// T35b — MCP server config handler invocable at runtime (Tier 2
|
||||
// sibling of T35's Tier 1 asar fingerprint).
|
||||
//
|
||||
// Backs T35 in docs/testing/cases/extensibility.md ("MCP server config
|
||||
// picked up"). T35 the Tier 1 fingerprint asserts the chat-tab vs
|
||||
// Code-tab MCP-config separation is wired in the bundle (the four
|
||||
// load-bearing strings: `claude_desktop_config.json`, `.claude.json`,
|
||||
// `.mcp.json`, `"user","project","local"`). T35b the Tier 2 runtime
|
||||
// probe asserts the read-side handler that exposes the parsed Code-tab
|
||||
// MCP config — `claude.settings/MCP/getMcpServersConfig` — is
|
||||
// registered AND callable through the eipc wrapper, returning a
|
||||
// parseable record shape.
|
||||
//
|
||||
// Why the fingerprint is not enough
|
||||
// ---------------------------------
|
||||
// String presence in the bundle survives a half-applied refactor or a
|
||||
// dead-code path. Runtime invocation proves the handler actually
|
||||
// executed `e.ipc.handle(...)` during webContents init, the
|
||||
// renderer-side wrapper at `window['claude.settings'].MCP.
|
||||
// getMcpServersConfig` was exposed (i.e. the origin gate let the
|
||||
// renderer claim claude.ai), and the impl returned the documented
|
||||
// `Record<string, MCPServerConfig>` shape. If the wiring regresses
|
||||
// (the `setImplementation` block throws on a side effect, the
|
||||
// wrapper-exposure gate flips, the impl returns the wrong type), the
|
||||
// fingerprint still passes but T35b fails.
|
||||
//
|
||||
// Why this works (session 8 finding)
|
||||
// ----------------------------------
|
||||
// `claude.settings/*` handlers register on the per-`webContents` IPC
|
||||
// scope (`webContents.ipc._invokeHandlers`, Electron 17+) and are
|
||||
// exposed to the renderer at `window['claude.settings'].<Iface>.
|
||||
// <method>` by `mainView.js`'s `contextBridge.exposeInMainWorld` after
|
||||
// passing `Qc()` (top-frame check + origin allow-list:
|
||||
// `https://claude.ai`, `https://claude.com`, preview.*, localhost).
|
||||
// `lib/eipc.ts`'s `invokeEipcChannel` calls through that wrapper via
|
||||
// `inspector.evalInRenderer('claude.ai', ...)`, which runs against the
|
||||
// claude.ai renderer where the wrapper is exposed.
|
||||
//
|
||||
// Assertion shape
|
||||
// ---------------
|
||||
// Empty-config (host has no `~/.claude.json` / `.mcp.json` MCP
|
||||
// servers) returns `{}`; configured-host returns
|
||||
// `Record<string, MCPServerConfig>`. The spec asserts the response is
|
||||
// a plain object (not null, not undefined, not an array, not a
|
||||
// primitive). That's the strongest assertion that doesn't depend on
|
||||
// host MCP-config state — confirms the handler is wired AND
|
||||
// invocable AND returns the documented record shape.
|
||||
//
|
||||
// Skip semantics
|
||||
// --------------
|
||||
// `seedFromHost: true` is required — without a signed-in claude.ai,
|
||||
// the renderer never reaches claude.ai origin and the wrapper isn't
|
||||
// exposed. Hosts with no signed-in Claude Desktop skip cleanly via
|
||||
// `createIsolation`'s throw, mirroring T22b/T31b's pattern.
|
||||
//
|
||||
// The `seedFromHost` side effect (kills the running host Claude
|
||||
// Desktop to release LevelDB / SQLite writer locks) is documented in
|
||||
// `lib/host-claude.ts`. The host config dir itself is left untouched.
|
||||
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const EXPECTED_SUFFIX = 'MCP_$_getMcpServersConfig';
|
||||
// `forceReload: false` — case-doc-anchored bool arg shape; the
|
||||
// validator accepts `null | boolean`. `false` keeps the call read-only
|
||||
// (no re-read of `~/.claude.json` from disk).
|
||||
const FORCE_RELOAD = false;
|
||||
|
||||
test('T35b — MCP config handler invocable at runtime', async (
|
||||
{},
|
||||
testInfo,
|
||||
) => {
|
||||
testInfo.annotations.push({ type: 'severity', description: 'Critical' });
|
||||
testInfo.annotations.push({
|
||||
type: 'surface',
|
||||
description: 'MCP / Code tab (eipc invocation)',
|
||||
});
|
||||
|
||||
await testInfo.attach('session-env', {
|
||||
body: JSON.stringify(captureSessionEnv(), null, 2),
|
||||
contentType: 'application/json',
|
||||
});
|
||||
|
||||
let isolation: Isolation;
|
||||
try {
|
||||
isolation = await createIsolation({ seedFromHost: true });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
test.skip(true, `seedFromHost unavailable: ${msg}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const app = await launchClaude({ isolation });
|
||||
try {
|
||||
const ready = await app.waitForReady('userLoaded');
|
||||
await testInfo.attach('claude-ai-url', {
|
||||
body: ready.claudeAiUrl ?? '(no claude.ai webContents observed)',
|
||||
contentType: 'text/plain',
|
||||
});
|
||||
if (!ready.postLoginUrl) {
|
||||
test.skip(
|
||||
true,
|
||||
'seeded auth did not reach post-login URL — host config ' +
|
||||
'may be stale (signed out, expired session, etc.)',
|
||||
);
|
||||
return;
|
||||
}
|
||||
await testInfo.attach('post-login-url', {
|
||||
body: ready.postLoginUrl,
|
||||
contentType: 'text/plain',
|
||||
});
|
||||
|
||||
// Wait for handler registration first — gives a clean
|
||||
// "registered but uninvocable" failure mode if the wrapper-
|
||||
// exposure gate has flipped (registration would still happen
|
||||
// on the per-wc registry; only the renderer-side wrapper would
|
||||
// be missing).
|
||||
const channel = await waitForEipcChannel(
|
||||
ready.inspector,
|
||||
EXPECTED_SUFFIX,
|
||||
);
|
||||
expect(
|
||||
channel,
|
||||
`[T35b] eipc channel ending in '${EXPECTED_SUFFIX}' is registered ` +
|
||||
'on the claude.ai webContents (case-doc anchor index.js:176766 ' +
|
||||
'~/.claude.json reader / :215418 .mcp.json scanner)',
|
||||
).not.toBeNull();
|
||||
|
||||
const result = await invokeEipcChannel<unknown>(
|
||||
ready.inspector,
|
||||
EXPECTED_SUFFIX,
|
||||
[FORCE_RELOAD],
|
||||
);
|
||||
|
||||
const shape = describeShape(result);
|
||||
await testInfo.attach('mcp-config-response', {
|
||||
body: JSON.stringify(
|
||||
{
|
||||
expectedSuffix: EXPECTED_SUFFIX,
|
||||
forceReload: FORCE_RELOAD,
|
||||
resolvedChannel: channel,
|
||||
responseShape: shape,
|
||||
// Truncate large responses — most users will have 0-5
|
||||
// MCP servers configured, but the case-doc allows
|
||||
// arbitrary growth and we don't want a test failure
|
||||
// to dump a 100KB response into the JUnit XML.
|
||||
responseSample: truncate(result, 4000),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
contentType: 'application/json',
|
||||
});
|
||||
|
||||
expect(
|
||||
result,
|
||||
`[T35b] getMcpServersConfig response is non-null`,
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
result,
|
||||
`[T35b] getMcpServersConfig response is defined`,
|
||||
).not.toBeUndefined();
|
||||
expect(
|
||||
typeof result,
|
||||
`[T35b] getMcpServersConfig response is an object ` +
|
||||
`(got ${typeof result})`,
|
||||
).toBe('object');
|
||||
expect(
|
||||
Array.isArray(result),
|
||||
`[T35b] getMcpServersConfig response is a record, not an array ` +
|
||||
`— case-doc anchor :176766 reads ~/.claude.json into a ` +
|
||||
`name-keyed map`,
|
||||
).toBe(false);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Describe the response shape without dumping its full contents.
|
||||
// Useful for diagnostic attachments where the actual MCP config might
|
||||
// hold tokens or PII a user wouldn't want in the JUnit log.
|
||||
function describeShape(value: unknown): string {
|
||||
if (value === null) return 'null';
|
||||
if (value === undefined) return 'undefined';
|
||||
if (Array.isArray(value)) return `array(length=${value.length})`;
|
||||
if (typeof value === 'object') {
|
||||
const keys = Object.keys(value as Record<string, unknown>);
|
||||
return `object(keys=${keys.length}, sample=${JSON.stringify(
|
||||
keys.slice(0, 5),
|
||||
)})`;
|
||||
}
|
||||
return `${typeof value}(${JSON.stringify(value).slice(0, 60)})`;
|
||||
}
|
||||
|
||||
function truncate(value: unknown, max: number): unknown {
|
||||
const s = JSON.stringify(value);
|
||||
if (!s || s.length <= max) return value;
|
||||
return `${s.slice(0, max)}…(truncated, total=${s.length})`;
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { launchClaude } from '../lib/electron.js';
|
||||
import { createIsolation, type Isolation } from '../lib/isolation.js';
|
||||
import { captureSessionEnv } from '../lib/diagnostics.js';
|
||||
import { invokeEipcChannel, waitForEipcChannel } from '../lib/eipc.js';
|
||||
|
||||
// T37b — Global CLAUDE.md memory readback handler invocable at
|
||||
// runtime (Tier 2 sibling of T37's Tier 1 asar fingerprint).
|
||||
//
|
||||
// Backs T37 in docs/testing/cases/extensibility.md ("`CLAUDE.md`
|
||||
// memory loads"). T37 the Tier 1 fingerprint asserts three load-
|
||||
// bearing strings in the bundle: `[GlobalMemory] Copied CLAUDE.md`
|
||||
// (the log line at :455188 emitted when `zhA(accountId, orgId)` copies
|
||||
// global account memory to per-session `.claude/CLAUDE.md`),
|
||||
// `CLAUDE.md` (the filename literal, both project and global), and
|
||||
// `CLAUDE_CONFIG_DIR` (the env-var resolver `cE()` at :283107).
|
||||
//
|
||||
// T37b the Tier 2 runtime probe asserts the read-side handler that
|
||||
// exposes the global memory store — `claude.web/CoworkMemory/
|
||||
// readGlobalMemory` — is registered AND callable, returning the
|
||||
// documented `string | null` shape (string = stored memory body, null
|
||||
// = no global memory written for this account).
|
||||
//
|
||||
// Why this is the right Tier 2 handler for T37
|
||||
// --------------------------------------------
|
||||
// The case-doc anchor `:455188` describes the memory-COPY path: at
|
||||
// session start, the global account memory `zhA(accountId, orgId)` is
|
||||
// COPIED into the per-session `.claude/CLAUDE.md`. The READ-side
|
||||
// handler that exposes the same global-memory store is
|
||||
// `CoworkMemory/readGlobalMemory` (read-only, no side effects). Wire-
|
||||
// confirming that handler at runtime is the natural Tier 2 reframe of
|
||||
// the case-doc anchor — it proves the global-memory store is plumbed
|
||||
// through to a reachable read endpoint, which is the same store the
|
||||
// `:455188` copy path reads from.
|
||||
//
|
||||
// The other anchors (`:259691` working-dir scan; `:283107`
|
||||
// `CLAUDE_CONFIG_DIR` resolver) are file-system-side and stay in the
|
||||
// Tier 1 fingerprint — there's no runtime IPC handler that reads
|
||||
// project `CLAUDE.md` or resolves the config dir on demand.
|
||||
//
|
||||
// Why the fingerprint is not enough
|
||||
// ---------------------------------
|
||||
// String presence in the bundle survives a half-applied refactor.
|
||||
// Runtime invocation proves the handler is wired through to a real
|
||||
// account-memory store (whatever that is — server-side or local
|
||||
// cache) and returns the documented type. If the wiring regresses
|
||||
// (the impl throws on a missing schema, returns a serialized envelope
|
||||
// instead of plain string, etc.), the fingerprint still passes but
|
||||
// T37b fails.
|
||||
//
|
||||
// Why this works (session 8 finding)
|
||||
// ----------------------------------
|
||||
// Same path as T35b — `claude.web/*` handlers expose to the renderer
|
||||
// via `window['claude.web'].<Iface>.<method>`; `lib/eipc.ts`'s
|
||||
// `invokeEipcChannel` calls through the wrapper via
|
||||
// `inspector.evalInRenderer('claude.ai', ...)`. See
|
||||
// `lib/eipc.ts`'s leading comment for the full path.
|
||||
//
|
||||
// Assertion shape
|
||||
// ---------------
|
||||
// Returns `string | null`:
|
||||
// - `string` = stored global memory body for this account
|
||||
// - `null` = no global memory written for this account (the dev box
|
||||
// sees this when seedFromHost copies an account that hasn't written
|
||||
// global memory)
|
||||
// Either is a clean Tier 2 wire-confirmation.
|
||||
//
|
||||
// Skip semantics
|
||||
// --------------
|
||||
// `seedFromHost: true` is required — same reasoning as T35b/T22b.
|
||||
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const EXPECTED_SUFFIX = 'CoworkMemory_$_readGlobalMemory';
|
||||
|
||||
test('T37b — Global memory readback handler invocable at runtime', async (
|
||||
{},
|
||||
testInfo,
|
||||
) => {
|
||||
testInfo.annotations.push({ type: 'severity', description: 'Critical' });
|
||||
testInfo.annotations.push({
|
||||
type: 'surface',
|
||||
description: 'Memory / Code tab session prompt (eipc invocation)',
|
||||
});
|
||||
|
||||
await testInfo.attach('session-env', {
|
||||
body: JSON.stringify(captureSessionEnv(), null, 2),
|
||||
contentType: 'application/json',
|
||||
});
|
||||
|
||||
let isolation: Isolation;
|
||||
try {
|
||||
isolation = await createIsolation({ seedFromHost: true });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
test.skip(true, `seedFromHost unavailable: ${msg}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const app = await launchClaude({ isolation });
|
||||
try {
|
||||
const ready = await app.waitForReady('userLoaded');
|
||||
await testInfo.attach('claude-ai-url', {
|
||||
body: ready.claudeAiUrl ?? '(no claude.ai webContents observed)',
|
||||
contentType: 'text/plain',
|
||||
});
|
||||
if (!ready.postLoginUrl) {
|
||||
test.skip(
|
||||
true,
|
||||
'seeded auth did not reach post-login URL — host config ' +
|
||||
'may be stale (signed out, expired session, etc.)',
|
||||
);
|
||||
return;
|
||||
}
|
||||
await testInfo.attach('post-login-url', {
|
||||
body: ready.postLoginUrl,
|
||||
contentType: 'text/plain',
|
||||
});
|
||||
|
||||
const channel = await waitForEipcChannel(
|
||||
ready.inspector,
|
||||
EXPECTED_SUFFIX,
|
||||
);
|
||||
expect(
|
||||
channel,
|
||||
`[T37b] eipc channel ending in '${EXPECTED_SUFFIX}' is registered ` +
|
||||
'on the claude.ai webContents (case-doc anchor ' +
|
||||
'index.js:455188 — global account memory ' +
|
||||
'`zhA(accountId, orgId)` copied to per-session ' +
|
||||
'`.claude/CLAUDE.md`)',
|
||||
).not.toBeNull();
|
||||
|
||||
const result = await invokeEipcChannel<unknown>(
|
||||
ready.inspector,
|
||||
EXPECTED_SUFFIX,
|
||||
[],
|
||||
);
|
||||
|
||||
await testInfo.attach('global-memory-response', {
|
||||
body: JSON.stringify(
|
||||
{
|
||||
expectedSuffix: EXPECTED_SUFFIX,
|
||||
resolvedChannel: channel,
|
||||
responseType: result === null ? 'null' : typeof result,
|
||||
// Memory body could contain personal or sensitive
|
||||
// content — record only the type + length; never
|
||||
// dump the body into JUnit.
|
||||
responseLength:
|
||||
typeof result === 'string' ? result.length : null,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
contentType: 'application/json',
|
||||
});
|
||||
|
||||
// `string | null` is the documented type. Reject anything else
|
||||
// — an envelope, an object, a number — as a wiring regression.
|
||||
const isStringOrNull = result === null || typeof result === 'string';
|
||||
expect(
|
||||
isStringOrNull,
|
||||
`[T37b] readGlobalMemory response is string|null ` +
|
||||
`(got ${result === null ? 'null' : typeof result}) ` +
|
||||
'— case-doc anchor :455188 reads global account memory ' +
|
||||
'as a single string body',
|
||||
).toBe(true);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user