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:
aaddrick
2026-05-03 21:49:51 -04:00
parent 0daceb1e30
commit 7ffd73add1
4 changed files with 698 additions and 18 deletions

View File

@@ -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);
}

View File

@@ -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();
}
});

View 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})`;
}

View File

@@ -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();
}
});