1cb25e2b1e38
fix: HV-2 clipboard auto-clear via chrome.alarms backstop + offscreen document; offscreen wired into Chrome build, firefox manifest unchanged
- time
- 2026-06-10 01:57 UTC · 4 months ago
- machine
- koh2
- agent
- claude-opus-4-8
- session
- audit fix integration
- parent
- fed31ad00996
↓ peach-browser-1cb25e2b.tar.gzatlas at this save
files changed6 files
manifest.json+2 −1src/background/clipboard.test.ts+160 −3src/background/clipboard.ts+4 −1src/background/entry.ts+5 −0src/shared/types.ts+1 −0vite.config.ts+8 −0manifest.json+2 −1
@@ -62,7 +62,8 @@6262 "alarms",6363 "idle",6464 "favicon",65- "webAuthenticationProxy"65+ "webAuthenticationProxy",66+ "offscreen"6667 ],6768 "optional_permissions": [6869 "audioCapture"src/background/clipboard.test.ts+160 −3
@@ -2,17 +2,39 @@22 33 type StorageShape = Record<string, unknown>;44 5+// Flush the fire-and-forget clearClipboardNow() promise chain. Fake timers6+// mock timers, not microtasks, so awaiting plain microtasks drains it.7+async function flushMicrotasks(): Promise<void> {8+ for (let i = 0; i < 25; i++) await Promise.resolve();9+}10+511 describe("background clipboard", () => {612 let storage: StorageShape;13+ let session: StorageShape;714 let clipboardWriteText: ReturnType<typeof vi.fn>;15+ let alarmsCreate: ReturnType<typeof vi.fn>;16+ let alarmsClear: ReturnType<typeof vi.fn>;17+ let alarmListeners: Array<(alarm: { name: string }) => void>;18+ let sessionRemove: ReturnType<typeof vi.fn>;819 920 beforeEach(async () => {1021 vi.resetModules();1122 vi.useFakeTimers();1223 storage = {};24+ session = {};1325 clipboardWriteText = vi.fn(async () => undefined);26+ alarmsCreate = vi.fn(async () => undefined);27+ alarmsClear = vi.fn(async () => true);28+ alarmListeners = [];29+ sessionRemove = vi.fn(async (key: string) => {30+ delete session[key];31+ });1432 1533 const chromeMock = {34+ runtime: {35+ // Default offscreen reply; tests that need offscreen install their own.36+ sendMessage: vi.fn(async () => ({ ok: true })),37+ },1638 tabs: {1739 query: vi.fn(async () => []),1840 },@@ -20,9 +42,13 @@2042 executeScript: vi.fn(async () => []),2143 },2244 alarms: {23- clear: vi.fn(),24- create: vi.fn(),25- onAlarm: { addListener: vi.fn() },45+ clear: alarmsClear,46+ create: alarmsCreate,47+ onAlarm: {48+ addListener: vi.fn((fn: (alarm: { name: string }) => void) => {49+ alarmListeners.push(fn);50+ }),51+ },2652 },2753 idle: {2854 setDetectionInterval: vi.fn(),@@ -46,6 +72,13 @@4672 }),4773 remove: vi.fn(async () => undefined),4874 },75+ session: {76+ get: vi.fn(async (key: string) => ({ [key]: session[key] })),77+ set: vi.fn(async (next: Record<string, unknown>) => {78+ Object.assign(session, next);79+ }),80+ remove: sessionRemove,81+ },4982 },5083 } as unknown as typeof chrome;5184 @@ -104,5 +137,129 @@104137 105138 await vi.advanceTimersByTimeAsync(2);106139 expect(clipboardWriteText).toHaveBeenNthCalledWith(2, "");140+ });141+142+ // --- HV-2: eviction-proof clear via chrome.alarms + offscreen ---143+144+ it("HV-2: copyToClipboard arms the clear alarm (delayInMinutes >= 0.5)", async () => {145+ const { copyToClipboard, CLIPBOARD_CLEAR_ALARM } = await import(146+ "./clipboard"147+ );148+149+ await copyToClipboard("secret", "Password");150+151+ expect(alarmsCreate).toHaveBeenCalledWith(152+ CLIPBOARD_CLEAR_ALARM,153+ expect.objectContaining({ delayInMinutes: expect.any(Number) }),154+ );155+ const arg = alarmsCreate.mock.calls.at(-1)?.[1] as {156+ delayInMinutes: number;157+ };158+ expect(arg.delayInMinutes).toBeGreaterThanOrEqual(0.5);159+ });160+161+ it("HV-2: alarm handler routes the clear through the offscreen document when present", async () => {162+ const sendMessage = vi.fn(async () => ({ ok: true }));163+ const createDocument = vi.fn(async () => undefined);164+ const closeDocument = vi.fn(async () => undefined);165+ (chrome as unknown as { offscreen: unknown }).offscreen = {166+ createDocument,167+ closeDocument,168+ Reason: { CLIPBOARD: "CLIPBOARD" },169+ };170+ (chrome.runtime as unknown as { sendMessage: unknown }).sendMessage =171+ sendMessage;172+173+ const { setupClipboardClearAlarm, CLIPBOARD_CLEAR_ALARM } = await import(174+ "./clipboard"175+ );176+ // vault's alarms.ts already registered an autolock onAlarm listener; the177+ // clipboard listener is the one registered now (last).178+ setupClipboardClearAlarm();179+ const clipListener = alarmListeners.at(-1)!;180+181+ clipListener({ name: CLIPBOARD_CLEAR_ALARM });182+ await flushMicrotasks();183+184+ expect(createDocument).toHaveBeenCalledOnce();185+ expect(sendMessage).toHaveBeenCalledWith({ type: "CLEAR_CLIPBOARD" });186+ // The offscreen clear succeeded, so we never fell back to the active-tab write.187+ expect(clipboardWriteText).not.toHaveBeenCalled();188+ });189+190+ it("HV-2: alarm handler ignores unrelated alarm names", async () => {191+ const { setupClipboardClearAlarm } = await import("./clipboard");192+ setupClipboardClearAlarm();193+ const clipListener = alarmListeners.at(-1)!;194+195+ clipListener({ name: "some-other-alarm" });196+ await flushMicrotasks();197+198+ expect(clipboardWriteText).not.toHaveBeenCalled();199+ });200+201+ it("HV-2: alarm handler falls back to active-tab write when offscreen is absent (Firefox)", async () => {202+ // No chrome.offscreen on this mock — the Firefox path.203+ const { setupClipboardClearAlarm, CLIPBOARD_CLEAR_ALARM } = await import(204+ "./clipboard"205+ );206+ setupClipboardClearAlarm();207+ const clipListener = alarmListeners.at(-1)!;208+209+ clipListener({ name: CLIPBOARD_CLEAR_ALARM });210+ await flushMicrotasks();211+212+ expect(clipboardWriteText).toHaveBeenCalledWith("");213+ });214+215+ it("HV-2: a second copy clears the previous alarm before re-arming", async () => {216+ const { copyToClipboard, CLIPBOARD_CLEAR_ALARM } = await import(217+ "./clipboard"218+ );219+220+ await copyToClipboard("first", "Password");221+ alarmsClear.mockClear();222+ alarmsCreate.mockClear();223+224+ await copyToClipboard("second", "Password");225+226+ expect(alarmsClear).toHaveBeenCalledWith(CLIPBOARD_CLEAR_ALARM);227+ expect(alarmsCreate).toHaveBeenCalled();228+ // clear must precede the new create.229+ const clearOrder = alarmsClear.mock.invocationCallOrder[0];230+ const createOrder = alarmsCreate.mock.invocationCallOrder.at(-1)!;231+ expect(clearOrder).toBeLessThan(createOrder);232+ });233+234+ it("HV-2: a failed clear re-arms the alarm and keeps the session hash", async () => {235+ // Offscreen present but the clear fails, and the active-tab fallback also236+ // fails — the secret must not be silently abandoned.237+ (chrome as unknown as { offscreen: unknown }).offscreen = {238+ createDocument: vi.fn(async () => undefined),239+ closeDocument: vi.fn(async () => undefined),240+ Reason: { CLIPBOARD: "CLIPBOARD" },241+ };242+ (chrome.runtime as unknown as { sendMessage: unknown }).sendMessage = vi.fn(243+ async () => ({ ok: false }),244+ );245+ clipboardWriteText.mockRejectedValue(new Error("no clipboard access"));246+247+ const { setupClipboardClearAlarm, CLIPBOARD_CLEAR_ALARM } = await import(248+ "./clipboard"249+ );250+ setupClipboardClearAlarm();251+ const clipListener = alarmListeners.at(-1)!;252+ alarmsCreate.mockClear();253+254+ clipListener({ name: CLIPBOARD_CLEAR_ALARM });255+ await flushMicrotasks();256+257+ // Re-armed rather than swallowed.258+ expect(alarmsCreate).toHaveBeenCalledWith(259+ CLIPBOARD_CLEAR_ALARM,260+ expect.objectContaining({ delayInMinutes: expect.any(Number) }),261+ );262+ // Session hash bookkeeping NOT torn down while a clear is still owed.263+ expect(sessionRemove).not.toHaveBeenCalled();107264 });108265 });src/background/clipboard.ts+4 −1
@@ -122,7 +122,10 @@122122 if (chrome.offscreen?.createDocument) {123123 try {124124 await chrome.offscreen.createDocument({125- url: "offscreen.html",125+ // crxjs emits the offscreen page preserving its source path under the126+ // Chrome dist root (see vite.config.ts offscreen input). Keep this URL127+ // aligned with that emitted location.128+ url: "src/background/offscreen.html",126129 reasons: [chrome.offscreen.Reason.CLIPBOARD],127130 justification: "Clear sensitive clipboard content after timeout",128131 });src/background/entry.ts+5 −0
@@ -2,6 +2,7 @@22 import { setupAutoLock } from "./alarms";33 import { setupPasskeyProxy } from "./passkey";44 import { setupCommands } from "./commands";5+import { setupClipboardClearAlarm } from "./clipboard";56 import { restoreFromSession, isUnlocked, lock } from "./vault";67 import { ensureSettingsLoaded } from "./vault";78 import { isRecoveryConfirmPending } from "./recovery-pending";@@ -45,6 +46,10 @@4546 setupMessageHandler();4647 void setupAutoLock();4748 setupCommands();49+ // HV-2: clipboard auto-clear backstop must survive worker eviction; the50+ // listener has to be registered during initial synchronous evaluation so51+ // a cold worker woken BY the alarm still handles it.52+ setupClipboardClearAlarm();4853 void setupPasskeyProxy();4954 // Wire the chirp-sync production runner into the dispatcher. Without this5055 // `triggerSyncIfReachable` is a no-op (the runner slot starts null) — thesrc/shared/types.ts+1 −0
@@ -247,6 +247,7 @@247247 // have a matching case in that file's switch statement.248248 export type MessageType =249249 | "GET_SYNC_ALERTS"250+ | "CLEAR_CLIPBOARD"250251 | "UNLOCK"251252 | "LOCK"252253 | "IS_UNLOCKED"vite.config.ts+8 −0
@@ -53,6 +53,14 @@5353 modulePreload: false,5454 target: "esnext",5555 rollupOptions: {56+ // HV-2: the offscreen document (Chrome only — Firefox has no57+ // chrome.offscreen) must be bundled and emitted so the background can58+ // load it via chrome.offscreen.createDocument. Firefox capability-checks59+ // chrome.offscreen and never references it.60+ input:61+ target === "firefox"62+ ? undefined63+ : { offscreen: resolve("src/background/offscreen.html") },5664 output: {5765 manualChunks(id) {5866 if (id.includes("node_modules/solid-js")) return "vendor-solid";