diff --git a/README.md b/README.md index fa7b386..58d919d 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,46 @@ picklab session destroy --all Every screenshot, log, and action lands in `.picklab/runs//` with a manifest, so a run is inspectable and reproducible after the fact. +### Evidence recording + +Computer-use tools record one session-scoped evidence run by default. MCP +desktop, Android, and session actions share the same append-only timeline as +browser DevTools actions. Destroying a session, or reaping a dead one, finalizes +the run and writes a static `report.html` filmstrip. + +A finalized evidence run under `.picklab/runs//` contains: + +- `manifest.json` — run identity, status, and evidence metadata +- `actions.jsonl` — authoritative, append-only sanitized action timeline +- `report.html` — escaped, no-script human filmstrip generated at finalization +- `screenshots/` and `logs/` — associated artifacts, when explicitly captured + +Typed values are stored only as length and input type. Network failures keep +only allowlisted method, URL origin/path without its query, status, resource +type, timing, and sanitized error metadata; headers and bodies are never kept. +PickLab does not take implicit screenshots for input actions. Explicit +screenshot tools still capture the screen exactly as displayed. + +The journal and associated artifacts have a 100 MiB recording threshold per +run. The record that crosses the threshold may exceed it; PickLab then writes a +durable metadata-only truncation marker and stops appending further payloads. +Only the latest 20 finalized evidence runs are retained; active/running and +legacy runs are never pruned. + +Evidence recording is enabled by default. Disable the action timeline for a +project in `.picklab/config.json`: + +```json +{ + "evidence": { + "enabled": false + } +} +``` + +This does not block an explicitly requested screenshot command. Screenshot +pixels cannot be redacted; see [SECURITY.md](SECURITY.md#recorded-evidence-and-screenshots). + ### Concurrent sessions Each session gets its own isolated display or emulator, so several agents and projects can run labs side by side. When a command or tool is called without an explicit session id, the default resolves per project: only running sessions created for the same project directory are considered. Pass `session` ids (CLI: `--session `) to target a specific lab, including one belonging to another project. @@ -180,6 +220,8 @@ Resources, addressable as `picklab://` URIs: - `picklab://runs/{runId}/manifest` — run manifest - `picklab://runs/{runId}/screenshots/{name}` — screenshots - `picklab://runs/{runId}/logs/{name}` — logs +- `picklab://runs/{runId}/actions` — sanitized action timeline JSON +- `picklab://runs/{runId}/report` — static HTML evidence filmstrip - `picklab://sessions/{sessionId}/status` — session liveness The status includes a read-only viewer endpoint/readiness report when VNC is present. MCP never opens a host GUI; only the CLI launches viewer windows. @@ -208,6 +250,8 @@ A TypeScript monorepo. `@pickforge/picklab` is the published package; the rest a - Relay stdout is protocol-only. A pending JSON-RPC record is capped at 16 MiB. Upstream diagnostic lines are capped at 64 KiB, redacted, and forwarded only to stderr; an over-limit line is dropped with a safe notice. Upstream update checks and usage statistics are disabled. - VNC binds to loopback only by default: `x11vnc` is started with `-localhost`, so the server listens on `127.0.0.1` and is not reachable from the network. Tunnel over SSH for remote access. Normal `--vnc` and `picklab watch` observation is server-enforced read-only (`-viewonly`); viewer exit never stops the session or its Xvfb/VNC processes. `--vnc-control` is an explicit writable escape hatch for human secret entry and does not yet coordinate with agent input. - Artifacts are redacted by default: logcat output strips tokens and secrets before it is stored or returned. Only `android adb` is raw, and it says so. +- Evidence timelines persist only allowlisted metadata; typed values become length/type metadata, and network headers, bodies, and URL queries are dropped. Static HTML reports escape page-controlled text and use a no-script, no-network CSP. +- Screenshot files contain raw pixels and cannot be redacted. Avoid explicit captures on screens containing secrets, and use `evidence.enabled: false` when an action timeline is not appropriate. See [SECURITY.md](SECURITY.md#recorded-evidence-and-screenshots). - PickLab provisions a dedicated locked lab user (`picklab-lab`) and a dedicated AVD (`picklab-avd`) so lab workloads do not borrow your personal resources. Running session processes under the lab user is planned post-MVP. - Agent config edits are atomic, with backups of the previous config. diff --git a/SECURITY.md b/SECURITY.md index 3856d05..3cca796 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -18,6 +18,46 @@ fuller model. and it says so. - Agent config edits are atomic, with a backup of the previous config. +## Recorded evidence and screenshots + +Computer-use evidence is stored as local project data under +`.picklab/runs//`. `actions.jsonl` is the authoritative sanitized +timeline; finalization produces an escaped, no-script `report.html` with a +restrictive content security policy and no external requests. + +The recorder persists only allowlisted metadata. Typed and filled text becomes +length plus input type. Failed network records keep method, URL origin/path +without the query, status, resource type, timing, and a sanitized error +classification. Request/response headers and bodies are dropped. Cookie, +authorization, session, JWT, CSRF, OTP, query-token, and CDP capability values +are redacted or omitted before persistence. + +**Screenshots are different: pixels cannot be redacted.** An explicitly +requested desktop, Android, or DevTools screenshot stores the screen exactly as +displayed and may therefore contain passwords, tokens, personal data, or other +secrets. PickLab never takes an implicit screenshot for typing/fill actions. +Do not explicitly capture a sensitive screen. Files are ordinary local files, +not encrypted storage; anyone who can read the project directory can read its +evidence. + +Action evidence is enabled by default. Disable it per project when recording is +not appropriate: + +```json +{ + "evidence": { + "enabled": false + } +} +``` + +This opt-out disables the action timeline and its screenshot association; it +does not block a screenshot command that was explicitly requested. The journal +and associated artifacts have a 100 MiB recording threshold per run. The +crossing record may exceed it; PickLab then writes one durable metadata-only +truncation marker and stops appending payloads. PickLab retains the latest 20 +finalized evidence runs, while active/running and legacy runs are not pruned. + ## VNC binds to loopback (SEC-01 — mitigated) The desktop VNC server (`x11vnc`) is started with `-localhost`, so it listens on diff --git a/docs/releases/UNRELEASED.md b/docs/releases/UNRELEASED.md index 2d33e69..6520bff 100644 --- a/docs/releases/UNRELEASED.md +++ b/docs/releases/UNRELEASED.md @@ -307,6 +307,17 @@ then reset this file. diagnostics, malformed/unmatched traffic, pending-call closure, disabled configuration, evidence-write failure isolation, and bounded diagnostics. Two independent changed-HEAD reviews are clean. +- Final evidence integration: `bun run typecheck` and `bun run build` pass; + the full and coverage suites pass 77 files / 945 passed / 2 skipped with all + global thresholds met. A failed DevTools relay flow produces an automatically + finalized, useful HTML report without persisted typed text, URL query tokens, + headers, bodies, JWTs, CDP capability values, or OTPs. Focused lifecycle tests + cover report publication on explicit destruction and dead-session reaping, + inclusion of the final `session_destroy` action, rejection of mismatched + manifest/pointer identities, and fresh relay-target resolution after browser + recreation. README and security guidance document recording, resources, the + 100 MiB threshold, 20-run retention, opt-out behavior, and unredactable + screenshot pixels. Two independent changed-HEAD reviews are clean. ### Not tested yet @@ -317,4 +328,4 @@ then reset this file. ### Release blockers -- None known for the DevTools relay slice. +- None known for the final evidence integration slice. diff --git a/packages/browser/test/evidence-integration.test.ts b/packages/browser/test/evidence-integration.test.ts new file mode 100644 index 0000000..708e49e --- /dev/null +++ b/packages/browser/test/evidence-integration.test.ts @@ -0,0 +1,229 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { EventEmitter } from "node:events"; +import { Readable, Writable } from "node:stream"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + createSession, + destroySessionRecord, + listRuns, + readActions, + runsDir, + type EnvLike, + type SessionRecord, +} from "@pickforge/picklab-core"; +import { + runDevtoolsMcpRelay, + type DevtoolsMcpExecutable, + type LiveBrowserSession, + type RelaySignalSource, +} from "../src/index.js"; +import { createDevtoolsEvidenceRecorder } from "../src/devtools-evidence.js"; + +const TYPED_PASSWORD = "typed-password-4821"; +const QUERY_TOKEN = "query-token-5932"; +const JWT = + "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJwaWNrbGFiIn0.signature-value"; +const CDP_GUID = "01234567-89ab-cdef-0123-456789abcdef"; +const OTP = "739204"; + +let root: string; +let projectDir: string; + +beforeEach(async () => { + root = await fs.promises.mkdtemp( + path.join(os.tmpdir(), "picklab-evidence-integration-"), + ); + projectDir = path.join(root, "project"); + await fs.promises.mkdir(projectDir); +}); + +afterEach(async () => { + await fs.promises.rm(root, { recursive: true, force: true }); +}); + +class Signals extends EventEmitter implements RelaySignalSource { + override on( + signal: "SIGINT" | "SIGTERM" | "SIGHUP", + listener: () => void, + ): this { + return super.on(signal, listener); + } + + override off( + signal: "SIGINT" | "SIGTERM" | "SIGHUP", + listener: () => void, + ): this { + return super.off(signal, listener); + } +} + +function session(sessionId: string): LiveBrowserSession { + const record: SessionRecord = { + id: sessionId, + type: "browser", + createdAt: "2026-07-13T00:00:00.000Z", + status: "running", + projectDir, + desktop: { display: ":91", xvfbPid: 11 }, + browser: { + browserPid: 12, + browserStartTimeTicks: 13, + binaryPath: "/fake/chrome", + profileMode: "ephemeral", + profileDir: "/fake/profile", + cdpPort: 9222, + }, + }; + return { record, cdpPort: 9222, browserUrl: "http://127.0.0.1:9222" }; +} + +function executable(script: string): DevtoolsMcpExecutable { + return { + packageJsonPath: path.join(root, "package.json"), + packageRoot: root, + binPath: script, + version: "1.5.0", + }; +} + +function sink(chunks: Buffer[]): Writable { + return new Writable({ + write(chunk: Buffer, _encoding, callback) { + chunks.push(Buffer.from(chunk)); + callback(); + }, + }); +} + +async function recursiveText(dir: string): Promise { + const chunks: string[] = []; + const walk = async (current: string): Promise => { + for (const entry of await fs.promises.readdir(current, { + withFileTypes: true, + })) { + const full = path.join(current, entry.name); + if (entry.isDirectory()) { + await walk(full); + } else if (entry.isFile()) { + chunks.push(await fs.promises.readFile(full, "utf8")); + } + } + }; + await walk(dir); + return chunks.join("\n"); +} + +describe("browser evidence integration", () => { + it("turns a failed relay flow into a useful secret-free HTML report", async () => { + const script = path.join(root, "failed-flow.mjs"); + await fs.promises.writeFile( + script, + [ + 'let input = "";', + 'process.stdin.setEncoding("utf8");', + 'process.stdin.on("data", (chunk) => { input += chunk; });', + 'process.stdin.on("end", () => {', + ' for (const line of input.trim().split("\\n")) {', + " const request = JSON.parse(line);", + " const result = request.id === 1", + " ? { content: [] }", + " : {", + " isError: true,", + ` content: [{ type: "text", text: "Authorization: Bearer ${JWT}; otp=${OTP}; /devtools/browser/${CDP_GUID}" }],`, + " structuredContent: {", + " networkRequests: [{", + ' method: "GET",', + ` url: "https://example.com/fail?token=${QUERY_TOKEN}",`, + ' status: 503, resourceType: "fetch", durationMs: 42,', + ` error: "token=${QUERY_TOKEN}",`, + ` requestHeaders: { authorization: "Bearer ${JWT}" },`, + ` responseBody: "otp=${OTP}"`, + " }]", + " }", + " };", + ' process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: request.id, result }) + "\\n");', + " }", + "});", + ].join("\n"), + "utf8", + ); + const registryEnv: EnvLike = { + PICKLAB_HOME: path.join(root, "picklab-home"), + }; + const sessionId = ( + await createSession({ type: "browser", projectDir }, registryEnv) + ).id; + const evidence = await createDevtoolsEvidenceRecorder({ + projectDir, + sessionId, + }); + expect(evidence).toBeDefined(); + const requests = [ + { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: "fill", + arguments: { uid: "1_1", value: TYPED_PASSWORD }, + }, + }, + { + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { + name: "navigate_page", + arguments: { + type: "url", + url: `https://example.com/fail?token=${QUERY_TOKEN}`, + }, + }, + }, + ]; + const output: Buffer[] = []; + + await expect( + runDevtoolsMcpRelay({ + session: session(sessionId), + executable: executable(script), + input: Readable.from( + requests.map((request) => `${JSON.stringify(request)}\n`), + ), + output: sink(output), + diagnostics: sink([]), + hooks: { + beforeForward: evidence!.beforeForward, + afterResponse: evidence!.afterResponse, + }, + signalSource: new Signals(), + shutdownTimeoutMs: 100, + }), + ).resolves.toEqual({ code: 0, signal: null }); + expect(Buffer.concat(output).toString()).toContain(QUERY_TOKEN); + + await destroySessionRecord(sessionId, registryEnv, "failed"); + const [finalized] = await listRuns(projectDir); + expect(finalized).toBeDefined(); + const runDir = path.join(runsDir(projectDir), finalized!.runId); + const reportPath = path.join(runDir, "report.html"); + const records = await readActions(runDir); + const html = await fs.promises.readFile(reportPath, "utf8"); + + expect(records).toHaveLength(3); + expect(html).toContain("chrome_devtools/fill"); + expect(html).toContain("chrome_devtools/navigate_page"); + expect(html).toContain("network_failure"); + expect(html).toContain("https://example.com/fail"); + expect(html).toContain("503"); + expect(html).toContain("DevTools tool failed"); + expect(finalized!.status).toBe("failed"); + + const stored = await recursiveText(runDir); + for (const secret of [TYPED_PASSWORD, QUERY_TOKEN, JWT, CDP_GUID, OTP]) { + expect(stored).not.toContain(secret); + } + }); +}); diff --git a/packages/browser/test/session.test.ts b/packages/browser/test/session.test.ts index fb38b7d..fb87bc9 100644 --- a/packages/browser/test/session.test.ts +++ b/packages/browser/test/session.test.ts @@ -27,6 +27,7 @@ import { createBrowserSession, destroyBrowserSession, getBrowserSessionStatus, + resolveLiveBrowserSession, type BrowserSessionHandle, } from "../src/index.js"; import { fakePath, writeExecutable, writeFakeChrome } from "./fakes.js"; @@ -200,6 +201,52 @@ describe.skipIf(!hasXvfb)("createBrowserSession (fake binaries)", () => { } }, TEST_TIMEOUT_MS); + it("resolves a recreated session as the relay target without stale state", async () => { + const env = spawnEnvFor("ready"); + let first: BrowserSessionHandle | undefined; + let second: BrowserSessionHandle | undefined; + try { + first = await createBrowserSession({ + projectDir, + registryEnv, + env, + cdpTimeoutMs: 5000, + }); + const initial = await resolveLiveBrowserSession({ + projectDir, + env: registryEnv, + }); + expect(initial.record.id).toBe(first.id); + expect(initial.cdpPort).toBe(first.cdpPort); + + await destroyBrowserSession(first.id, registryEnv); + first = undefined; + second = await createBrowserSession({ + projectDir, + registryEnv, + env, + cdpTimeoutMs: 5000, + }); + const recreated = await resolveLiveBrowserSession({ + projectDir, + env: registryEnv, + }); + expect(recreated.record.id).toBe(second.id); + expect(recreated.cdpPort).toBe(second.cdpPort); + expect(recreated.browserUrl).toBe( + `http://127.0.0.1:${second.cdpPort}`, + ); + expect(recreated.record.id).not.toBe(initial.record.id); + } finally { + if (first !== undefined) { + await destroyBrowserSession(first.id, registryEnv).catch(() => {}); + } + if (second !== undefined) { + await destroyBrowserSession(second.id, registryEnv).catch(() => {}); + } + } + }, TEST_TIMEOUT_MS); + it("keeps a stable group leader when a browser launcher exits", async () => { const session = await createBrowserSession({ projectDir, diff --git a/packages/core/src/evidence.ts b/packages/core/src/evidence.ts index 1ff4f8c..03392bc 100644 --- a/packages/core/src/evidence.ts +++ b/packages/core/src/evidence.ts @@ -221,6 +221,18 @@ function isSafeRunId(runId: string): boolean { return SAFE_NAME_PATTERN.test(runId) && !runId.includes(".."); } +function manifestMatchesPointer( + manifest: RunManifest, + pointer: ActiveEvidencePointer, + sessionId: string, +): boolean { + return ( + manifest.runId === pointer.runId && + manifest.sessionId === sessionId && + pointer.sessionId === sessionId + ); +} + /** Absolute path of the active-run pointer for a session. */ export function activePointerPath( projectDir: string, @@ -358,7 +370,11 @@ export async function resolveActivePointer( const manifest = await readManifest( path.join(runsDir(projectDir), pointer.runId), ); - if (manifest === undefined || manifest.status !== "running") { + if ( + manifest === undefined || + manifest.status !== "running" || + !manifestMatchesPointer(manifest, pointer, sessionId) + ) { return { status: "stale", raw, pointer }; } // A running manifest is only truly active while its recorded owner lives. A @@ -835,7 +851,11 @@ export async function finalizeActiveEvidenceRun( resolution.status === "active" ? resolution.manifest : await readManifest(runDir); - if (manifest === undefined || !isEvidenceRun(manifest)) { + if ( + manifest === undefined || + !isEvidenceRun(manifest) || + !manifestMatchesPointer(manifest, pointer, sessionId) + ) { if ( await clearActivePointer(projectDir, sessionId, { expectRaw: resolution.raw, diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 19e914e..9f9bb60 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -2,10 +2,12 @@ import { randomBytes } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { finalizeActiveEvidenceRun } from "./evidence.js"; +import { writeEvidenceReport } from "./evidence-render.js"; import { ensureDir, isProfileConfined, sessionsDir, + runsDir, type EnvLike, } from "./paths.js"; import { @@ -446,11 +448,17 @@ export async function destroySessionRecord( } const record = await getSession(id, env); if (record !== undefined) { - await finalizeActiveEvidenceRun( + const finalized = await finalizeActiveEvidenceRun( record.projectDir, record.id, evidenceStatus, - ).catch(() => {}); + ).catch(() => undefined); + if (finalized !== undefined) { + await writeEvidenceReport( + path.join(runsDir(record.projectDir), finalized.runId), + finalized, + ).catch(() => {}); + } } await fs.promises.rm(sessionPath(id, env), { force: true }); } diff --git a/packages/core/test/evidence.test.ts b/packages/core/test/evidence.test.ts index c084167..d871abe 100644 --- a/packages/core/test/evidence.test.ts +++ b/packages/core/test/evidence.test.ts @@ -346,6 +346,28 @@ describe("pointer resolution and clearing", () => { ); }); + it("rejects a manifest whose identity differs from its active pointer", async () => { + const sessionId = "desk-mismatch"; + const { run } = await beginEvidenceRun(project, sessionId); + const manifestPath = path.join(run.dir, "manifest.json"); + const manifest = JSON.parse( + await fs.promises.readFile(manifestPath, "utf8"), + ); + manifest.runId = "../../outside"; + await fs.promises.writeFile(manifestPath, `${JSON.stringify(manifest)}\n`); + + expect((await resolveActivePointer(project, sessionId)).status).toBe( + "stale", + ); + await expect( + finalizeActiveEvidenceRun(project, sessionId), + ).resolves.toBeUndefined(); + expect(fs.existsSync(activePointerPath(project, sessionId))).toBe(false); + expect(fs.existsSync(path.join(project, "outside", "report.html"))).toBe( + false, + ); + }); + it("recovers a corrupt pointer by starting a fresh run", async () => { await fs.promises.mkdir(runsRoot(), { recursive: true }); await fs.promises.writeFile( diff --git a/packages/core/test/session.test.ts b/packages/core/test/session.test.ts index 607b5aa..a545088 100644 --- a/packages/core/test/session.test.ts +++ b/packages/core/test/session.test.ts @@ -265,6 +265,8 @@ describe("session registry", () => { ), ), ).toMatchObject({ status: "completed" }); + expect(await fs.promises.readFile(path.join(run.dir, "report.html"), "utf8")) + .toContain("completed"); expect(fs.existsSync(activePointerPath(projectDir, session.id))).toBe(false); }); @@ -303,6 +305,8 @@ describe("session registry", () => { ), ).toMatchObject({ status: "failed", evidenceTruncated: false }); expect(await readActions(run.dir)).toHaveLength(1); + expect(await fs.promises.readFile(path.join(run.dir, "report.html"), "utf8")) + .toContain("desktop_click"); expect(fs.existsSync(activePointerPath(projectDir, stale.id))).toBe(false); }); diff --git a/packages/mcp-server/src/evidence.ts b/packages/mcp-server/src/evidence.ts index 151b17f..06346f7 100644 --- a/packages/mcp-server/src/evidence.ts +++ b/packages/mcp-server/src/evidence.ts @@ -5,12 +5,15 @@ import { appendAction, beginEvidenceRun, isEvidenceEnabled, + isEvidenceRun, loadConfig, sanitizeActionTarget, sanitizeErrorText, sanitizeTypedValue, + writeEvidenceReport, type EvidenceAction, type RunHandle, + type RunManifest, type SanitizedTypedValue, } from "@pickforge/picklab-core"; import type { ServerContext, ToolReport } from "./context.js"; @@ -26,6 +29,7 @@ export interface McpEvidenceOptions { target?: Record; typedValue?: { value: string; inputType?: string }; artifacts?: (result: T, run: RunHandle) => readonly string[]; + refreshReportAfterRecord?: boolean; } function evidenceStatus(error: unknown): EvidenceAction["status"] { @@ -53,6 +57,25 @@ async function evidenceRun( .run; } +async function refreshFinalizedReport(run: RunHandle): Promise { + const parsed: unknown = JSON.parse( + await fs.promises.readFile(path.join(run.dir, "manifest.json"), "utf8"), + ); + if (typeof parsed !== "object" || parsed === null) { + throw new Error(`Invalid evidence manifest for run ${run.runId}`); + } + const manifest = parsed as RunManifest; + if ( + !isEvidenceRun(manifest) || + manifest.runId !== run.runId || + manifest.sessionId !== run.manifest.sessionId || + manifest.status === "running" + ) { + return; + } + await writeEvidenceReport(run.dir, manifest); +} + async function confinedArtifacts( run: RunHandle, candidates: readonly string[], @@ -145,6 +168,9 @@ export async function withMcpEvidence( action.error = sanitizeErrorText(result.errors!.join("; ")); } await appendAction(run.dir, action); + if (options.refreshReportAfterRecord === true) { + await refreshFinalizedReport(run); + } } catch (error) { reportEvidenceFailure(options.tool, error); } diff --git a/packages/mcp-server/src/tools/session.ts b/packages/mcp-server/src/tools/session.ts index 82fe5b0..c9ebd5b 100644 --- a/packages/mcp-server/src/tools/session.ts +++ b/packages/mcp-server/src/tools/session.ts @@ -404,6 +404,7 @@ export function registerSessionTools( sessionId: record.id, tool: "session_destroy", target: { name: record.type }, + refreshReportAfterRecord: true, }, async () => { await destroyRecord(ctx, record); diff --git a/packages/mcp-server/test/android.test.ts b/packages/mcp-server/test/android.test.ts index cfcde9f..f3ac336 100644 --- a/packages/mcp-server/test/android.test.ts +++ b/packages/mcp-server/test/android.test.ts @@ -386,6 +386,18 @@ describe("android_start (fake sdk)", () => { { tool: "android_start", status: "ok" }, { tool: "session_destroy", status: "ok" }, ]); + expect( + await fs.promises.readFile( + path.join( + startDirs.projectDir, + ".picklab", + "runs", + finalizedManifest!.runId, + "report.html", + ), + "utf8", + ), + ).toContain("session_destroy"); expect( fs.existsSync(activePointerPath(startDirs.projectDir, session.id)), ).toBe(false);