diff --git a/tools/test-harness/src/lib/eipc.ts b/tools/test-harness/src/lib/eipc.ts index fe4a1e2..2a26e54 100644 --- a/tools/test-harness/src/lib/eipc.ts +++ b/tools/test-harness/src/lib/eipc.ts @@ -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.'] +// ..(...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.']..(...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.(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.": 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( + inspector: InspectorClient, + caseDocSuffix: string, + args: readonly unknown[] = [], + opts: InvokeEipcChannelOptions = {}, +): Promise { + 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 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 '_$__$_')`, + ); + } + 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(urlFilter, js, opts.timeoutMs); +} diff --git a/tools/test-harness/src/runners/T27_scheduled_tasks_runtime.spec.ts b/tools/test-harness/src/runners/T27_scheduled_tasks_runtime.spec.ts new file mode 100644 index 0000000..bff2686 --- /dev/null +++ b/tools/test-harness/src/runners/T27_scheduled_tasks_runtime.spec.ts @@ -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 = {}; + + 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( + 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(); + } +}); diff --git a/tools/test-harness/src/runners/T35b_mcp_config_runtime.spec.ts b/tools/test-harness/src/runners/T35b_mcp_config_runtime.spec.ts new file mode 100644 index 0000000..f2dc8fd --- /dev/null +++ b/tools/test-harness/src/runners/T35b_mcp_config_runtime.spec.ts @@ -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` 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'].. +// ` 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`. 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( + 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); + 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})`; +} diff --git a/tools/test-harness/src/runners/T37b_global_memory_runtime.spec.ts b/tools/test-harness/src/runners/T37b_global_memory_runtime.spec.ts new file mode 100644 index 0000000..beda449 --- /dev/null +++ b/tools/test-harness/src/runners/T37b_global_memory_runtime.spec.ts @@ -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']..`; `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( + 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(); + } +});