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
6 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 −0
manifest.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"

view file

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

view file

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

view file

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) — the

view file

src/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"

view file

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";

view file