- BLOCKER B1: add `tabs` to manifest.json permissions (DEC-011 Amendment 1 cites Phase 2 D-P2-02 meta.urls feature as justification). Honors D-P2-02 "all tabs visible" wording verbatim. Updates manifest-i18n test expected permission list lockstep. - F1: add A28 harness assertion for REQ-archive-layout strict zip-layout verification (5 entries, no extras). - F2: createArchive empty-tracker fallback removed; logs warn + sets urls:[] instead of fake [extension-origin URL]. 02-01 RED test pins empty-tracker → urls:[]. - F3: 02-02 Task 3 prose deliberation struck; typed `blob-url-mint-failed` throw is the resolved-only contract. - F4: 02-02 Task 3 verify block adds full-suite `npm test` after focused test runs. - A27 strict-mode (Plan 02-04): REQUIRES both URLs in meta.urls; FAILS on length < 2. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
499 lines
28 KiB
Markdown
499 lines
28 KiB
Markdown
---
|
|
phase: 02-stabilize-export-pipeline
|
|
plan: 02
|
|
type: auto
|
|
wave: 2
|
|
depends_on: [01]
|
|
files_modified:
|
|
- src/offscreen/recorder.ts
|
|
- src/shared/types.ts
|
|
- src/background/index.ts
|
|
autonomous: true
|
|
requirements:
|
|
- REQ-archive-export-latency
|
|
tags:
|
|
- blob-url-migration
|
|
- p0-6-fix
|
|
- offscreen-port-bridge
|
|
- chrome-downloads-onchanged
|
|
- url-createObjectURL
|
|
- url-revokeObjectURL
|
|
- d-p2-01
|
|
must_haves:
|
|
truths:
|
|
- "downloadArchive in src/background/index.ts no longer constructs a `data:application/zip;base64,` URL; it requests a Blob URL from offscreen via the existing long-lived port (D-17) and passes that to chrome.downloads.download."
|
|
- "Offscreen recorder.ts handles a new PortMessageType variant (CREATE_DOWNLOAD_URL + DOWNLOAD_URL) that mints `URL.createObjectURL(blob)` and returns the URL string to SW."
|
|
- "Archive Blob travels SW→offscreen via base64-on-wire (reusing src/shared/binary.ts blobToBase64 + base64ToBlob — same wire-format precedent as D-12 video segments)."
|
|
- "chrome.downloads.onChanged listener wired in SW: when state.current==='complete' or 'interrupted' for the tracked downloadId, dispatch URL.revokeObjectURL to offscreen via the same port (REVOKE_DOWNLOAD_URL message)."
|
|
- "A 6 MB archive (above Chrome's ~2 MB data-URL cap) downloads to disk without 'Network error' or any failure; tests/background/blob-url-download.test.ts Test 2 GREEN."
|
|
- "All 3 tests in tests/background/blob-url-download.test.ts flip RED→GREEN."
|
|
- "Tier-1 FORBIDDEN_HOOK_STRINGS gate remains at 12 entries (this plan adds NO test-hook surfaces; the new port messages are PRODUCTION surface — they ride existing keepalivePort in the same way REQUEST_BUFFER/BUFFER do)."
|
|
- "UAT harness 24/24 GREEN preserved (no harness-level changes in this plan; A5 + A12 download-flow assertions empirically prove the Blob URL path end-to-end)."
|
|
- "Always-on charter preserved: SAVE creates a zip; recorder stays in REC. No finally-block state reset in saveArchive (Plan 01-09 Amendment 3 invariant)."
|
|
artifacts:
|
|
- path: "src/offscreen/recorder.ts"
|
|
provides: "CREATE_DOWNLOAD_URL + REVOKE_DOWNLOAD_URL message handlers on existing keepalivePort"
|
|
contains: "URL\\.createObjectURL|URL\\.revokeObjectURL|CREATE_DOWNLOAD_URL"
|
|
- path: "src/shared/types.ts"
|
|
provides: "PortMessageType union extended with CREATE_DOWNLOAD_URL + DOWNLOAD_URL + REVOKE_DOWNLOAD_URL"
|
|
contains: "CREATE_DOWNLOAD_URL|DOWNLOAD_URL"
|
|
- path: "src/background/index.ts"
|
|
provides: "downloadArchive rewritten to bridge through offscreen for URL.createObjectURL + chrome.downloads.onChanged listener for revoke"
|
|
contains: "chrome\\.downloads\\.onChanged|REVOKE_DOWNLOAD_URL|blob:"
|
|
key_links:
|
|
- from: "src/background/index.ts:downloadArchive"
|
|
to: "src/offscreen/recorder.ts (via keepalivePort)"
|
|
via: "port.postMessage({ type: 'CREATE_DOWNLOAD_URL', requestId, dataBase64 }) then await DOWNLOAD_URL response"
|
|
pattern: "CREATE_DOWNLOAD_URL.*requestId|DOWNLOAD_URL.*requestId"
|
|
- from: "src/background/index.ts:chrome.downloads.onChanged listener"
|
|
to: "src/offscreen/recorder.ts REVOKE_DOWNLOAD_URL handler"
|
|
via: "delta.state.current === 'complete' → port.postMessage({ type: 'REVOKE_DOWNLOAD_URL', url })"
|
|
pattern: "chrome\\.downloads\\.onChanged\\.addListener"
|
|
- from: "src/offscreen/recorder.ts:onPortMessage"
|
|
to: "URL.createObjectURL"
|
|
via: "case CREATE_DOWNLOAD_URL: decode base64ToBlob → URL.createObjectURL(blob) → respond { type: 'DOWNLOAD_URL', requestId, url }"
|
|
pattern: "URL\\.createObjectURL"
|
|
---
|
|
|
|
<objective>
|
|
Migrate the archive download path from base64 `data:` URL (current src/background/index.ts:709-710)
|
|
to an offscreen-minted `blob:` URL per D-P2-01. The SW cannot call `URL.createObjectURL` (DEC-006);
|
|
the offscreen document can. Wire a new port-bridge that ships the archive Blob SW→offscreen
|
|
(reusing the D-12 base64 wire-format from src/shared/binary.ts), mints the URL in offscreen,
|
|
returns the URL string to SW, and the SW invokes chrome.downloads.download with that URL.
|
|
Wire chrome.downloads.onChanged to dispatch URL.revokeObjectURL after download completion.
|
|
|
|
Purpose: closes the audit P0-6 + lifts the ~2 MB data-URL cap that currently breaks the canonical
|
|
5-10 MB operator bug-report archive (CONTEXT.md `<specifics>` "Real-archive-size assumption").
|
|
|
|
Output:
|
|
- 3 source-code modifications in src/offscreen/recorder.ts, src/shared/types.ts, src/background/index.ts.
|
|
- All 3 tests in tests/background/blob-url-download.test.ts flip RED→GREEN.
|
|
- 153 + N (from Plan 02-01) → 153 + N + 0 vitest count (no new tests in this plan; flips the existing
|
|
3 RED to GREEN; existing tests preserved unchanged).
|
|
- UAT harness 24/24 GREEN preserved.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.planning/PROJECT.md
|
|
@.planning/ROADMAP.md
|
|
@.planning/STATE.md
|
|
@.planning/phases/02-stabilize-export-pipeline/02-CONTEXT.md
|
|
@.planning/phases/02-stabilize-export-pipeline/02-01-PLAN.md
|
|
|
|
# Source code under modification
|
|
@src/background/index.ts
|
|
@src/offscreen/recorder.ts
|
|
@src/shared/types.ts
|
|
@src/shared/binary.ts
|
|
|
|
# Precedent for port-bridge wire format
|
|
@.planning/phases/01-stabilize-video-pipeline/01-07-SUMMARY.md
|
|
|
|
# Tests that flip GREEN under this plan
|
|
@tests/background/blob-url-download.test.ts
|
|
|
|
<interfaces>
|
|
<!-- Wire format for the new port messages — embedded so executor doesn't dig. -->
|
|
|
|
Extended PortMessageType union (src/shared/types.ts):
|
|
```typescript
|
|
export type PortMessageType =
|
|
| 'PING'
|
|
| 'PONG'
|
|
| 'REQUEST_BUFFER'
|
|
| 'BUFFER'
|
|
| 'CREATE_DOWNLOAD_URL' // NEW (SW → offscreen): "here is a Blob; mint a URL for it"
|
|
| 'DOWNLOAD_URL' // NEW (offscreen → SW): "here is the minted URL"
|
|
| 'REVOKE_DOWNLOAD_URL'; // NEW (SW → offscreen): "you can revoke this URL now"
|
|
|
|
export interface PortMessage {
|
|
type: PortMessageType;
|
|
requestId?: string;
|
|
segments?: TransferredVideoSegment[]; // BUFFER only
|
|
// NEW for CREATE_DOWNLOAD_URL: archive bytes as base64 + type for Blob reconstruction
|
|
dataBase64?: string;
|
|
mimeType?: string;
|
|
// NEW for DOWNLOAD_URL: the minted blob:URL string
|
|
url?: string;
|
|
}
|
|
```
|
|
|
|
Existing keepalivePort lifecycle (do NOT touch):
|
|
- src/offscreen/recorder.ts:790-840 — connectPort() + onPortMessage + reconnectPort
|
|
- src/background/index.ts:340-510 (approx, around getVideoBufferFromOffscreen) — SW-side onConnect handler + perRequestListeners map
|
|
|
|
Existing chrome.downloads.download call site (src/background/index.ts:712-716):
|
|
```typescript
|
|
await chrome.downloads.download({
|
|
url: url, // ← this string flips from data: to blob:
|
|
filename: filename,
|
|
saveAs: false
|
|
});
|
|
```
|
|
|
|
Existing blobToBase64 / base64ToBlob (src/shared/binary.ts:42-85) — REUSE AS-IS.
|
|
</interfaces>
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 1: Extend PortMessageType + PortMessage in src/shared/types.ts (D-P2-01 wire contract)</name>
|
|
<files>src/shared/types.ts</files>
|
|
<behavior>
|
|
- PortMessageType union grows from 4 to 7 entries: PING, PONG, REQUEST_BUFFER, BUFFER, CREATE_DOWNLOAD_URL, DOWNLOAD_URL, REVOKE_DOWNLOAD_URL.
|
|
- PortMessage interface adds optional `dataBase64?: string`, `mimeType?: string`, `url?: string` — same optional-tagged-union pattern as the existing `segments?:`.
|
|
- Type-level test (Task 2 from Plan 02-01 if it covers types): `meta.json` urls field NOT touched here (that is Plan 02-03 territory). This task is wire-format only.
|
|
</behavior>
|
|
<action>
|
|
Edit `src/shared/types.ts` to extend PortMessageType + PortMessage per the interfaces block above.
|
|
|
|
Add a docstring block above the union explaining: "CREATE_DOWNLOAD_URL + DOWNLOAD_URL + REVOKE_DOWNLOAD_URL
|
|
are the D-P2-01 Blob URL migration triplet (P0-6 fix). SW posts CREATE_DOWNLOAD_URL with the
|
|
archive bytes as base64; offscreen mints URL.createObjectURL and responds with DOWNLOAD_URL;
|
|
SW calls chrome.downloads.download(url); when chrome.downloads.onChanged reports 'complete' or
|
|
'interrupted', SW posts REVOKE_DOWNLOAD_URL so offscreen can free the URL. The base64 wire-format
|
|
reuses the D-12 precedent from src/shared/binary.ts — chrome.runtime.Port JSON-serializes payloads,
|
|
so Blob → empty object; base64 round-trips cleanly. See .planning/phases/02-stabilize-export-pipeline/
|
|
02-CONTEXT.md D-P2-01 for the full architectural rationale."
|
|
|
|
No other type changes in this task — SessionMetadata.urls is Plan 02-03.
|
|
</action>
|
|
<verify>
|
|
<automated>npx tsc --noEmit 2>&1 | head -20</automated>
|
|
</verify>
|
|
<done>
|
|
tsc --noEmit clean. PortMessageType union has 7 entries. PortMessage interface has the 3 new
|
|
optional fields with the docstring rationale. Atomic commit:
|
|
`feat(02-02): wire-format — extend PortMessage with CREATE_DOWNLOAD_URL/DOWNLOAD_URL/REVOKE_DOWNLOAD_URL (D-P2-01)`.
|
|
</done>
|
|
</task>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 2: Offscreen handler — mint + revoke Blob URLs on port bridge</name>
|
|
<files>src/offscreen/recorder.ts</files>
|
|
<behavior>
|
|
- `onPortMessage` (src/offscreen/recorder.ts:614) grows two new branches:
|
|
(a) `type === 'CREATE_DOWNLOAD_URL'`: extract requestId, dataBase64, mimeType;
|
|
call `base64ToBlob(dataBase64, mimeType)` (reuse src/shared/binary.ts);
|
|
call `URL.createObjectURL(blob)` to get the blob:URL string;
|
|
post back `{ type: 'DOWNLOAD_URL', requestId, url }` on keepalivePort.
|
|
(b) `type === 'REVOKE_DOWNLOAD_URL'`: extract url string; call `URL.revokeObjectURL(url)`;
|
|
no response (fire-and-forget).
|
|
- Defense-in-depth: if dataBase64 is empty / malformed, respond with `{ type: 'DOWNLOAD_URL', requestId, url: '' }` and log a warn — let SW timeout fire (mirrors the encodeAndSendBuffer pattern at recorder.ts:670).
|
|
- Maintain a module-scoped Set<string> of minted URLs (for hygiene — emit a warn if REVOKE arrives for a URL not in the set, but always call revokeObjectURL anyway; URL.revokeObjectURL on an unknown URL is a no-op per WHATWG spec).
|
|
- In-flight guard: a second CREATE_DOWNLOAD_URL while one is in flight is allowed (each gets its own requestId) — unlike encodeAndSendBuffer which gates concurrent calls because they share segment state. URL minting is stateless per-Blob.
|
|
- No changes to recording lifecycle / segment-rotation / D-13 / D-17 keepalive / __MOKOSH_UAT__ test hooks.
|
|
</behavior>
|
|
<action>
|
|
Edit `src/offscreen/recorder.ts` to add two new cases inside the existing `onPortMessage` function
|
|
(after the REQUEST_BUFFER branch at line ~626):
|
|
|
|
```typescript
|
|
if (type === 'CREATE_DOWNLOAD_URL') {
|
|
const requestId = (message as { requestId?: unknown }).requestId;
|
|
const dataBase64 = (message as { dataBase64?: unknown }).dataBase64;
|
|
const mimeType = (message as { mimeType?: unknown }).mimeType ?? 'application/zip';
|
|
if (typeof requestId !== 'string' || requestId.length === 0) {
|
|
logger.warn('CREATE_DOWNLOAD_URL without requestId — dropping');
|
|
return;
|
|
}
|
|
if (typeof dataBase64 !== 'string') {
|
|
logger.warn('CREATE_DOWNLOAD_URL with non-string dataBase64 — dropping');
|
|
return;
|
|
}
|
|
void handleCreateDownloadUrl(requestId, dataBase64, typeof mimeType === 'string' ? mimeType : 'application/zip');
|
|
return;
|
|
}
|
|
if (type === 'REVOKE_DOWNLOAD_URL') {
|
|
const url = (message as { url?: unknown }).url;
|
|
if (typeof url !== 'string' || url.length === 0) {
|
|
logger.warn('REVOKE_DOWNLOAD_URL without url — dropping');
|
|
return;
|
|
}
|
|
try {
|
|
URL.revokeObjectURL(url);
|
|
mintedDownloadUrls.delete(url);
|
|
} catch (err) {
|
|
logger.warn('URL.revokeObjectURL threw:', err);
|
|
}
|
|
return;
|
|
}
|
|
```
|
|
|
|
Add the helper + state right before onPortMessage:
|
|
```typescript
|
|
// D-P2-01 Blob URL hygiene set: track minted URLs so we can warn (not error)
|
|
// on unexpected revoke ops. URL.revokeObjectURL on an unknown URL is a no-op
|
|
// per the WHATWG spec, so this is purely diagnostic.
|
|
const mintedDownloadUrls = new Set<string>();
|
|
|
|
async function handleCreateDownloadUrl(requestId: string, dataBase64: string, mimeType: string): Promise<void> {
|
|
if (keepalivePort === null) {
|
|
logger.warn('CREATE_DOWNLOAD_URL: port unavailable at handler entry — dropping');
|
|
return;
|
|
}
|
|
let url = '';
|
|
try {
|
|
const blob = base64ToBlob(dataBase64, mimeType);
|
|
if (blob.size === 0) {
|
|
logger.warn('CREATE_DOWNLOAD_URL: empty blob from base64 decode — responding with empty url');
|
|
} else {
|
|
url = URL.createObjectURL(blob);
|
|
mintedDownloadUrls.add(url);
|
|
logger.log(`Minted Blob URL: ${url.substring(0, 30)}... (size: ${blob.size} bytes)`);
|
|
}
|
|
} catch (err) {
|
|
logger.error('CREATE_DOWNLOAD_URL: base64ToBlob or createObjectURL threw:', err);
|
|
}
|
|
try {
|
|
keepalivePort.postMessage({ type: 'DOWNLOAD_URL', requestId, url });
|
|
} catch (err) {
|
|
logger.warn('DOWNLOAD_URL post failed (port may have disconnected):', err);
|
|
}
|
|
}
|
|
```
|
|
|
|
Verify the import `import { blobToBase64, base64ToBlob } from '../shared/binary';` at the top
|
|
already imports base64ToBlob (recorder.ts line 18 imports blobToBase64 only — extend to include base64ToBlob).
|
|
|
|
No changes to bootstrap, connectPort, ping loop, or segment-rotation.
|
|
|
|
Per D-P2-01 architectural rationale: this lives in the offscreen because SW lacks
|
|
URL.createObjectURL (DEC-006). The offscreen does, and we already have the keepalivePort
|
|
open (D-17). Reusing the port (not opening a new one) avoids two connect-overhead penalties
|
|
per save flow.
|
|
</action>
|
|
<verify>
|
|
<automated>npx tsc --noEmit 2>&1 | head -10 ; npm run build 2>&1 | tail -10</automated>
|
|
</verify>
|
|
<done>
|
|
tsc clean. npm run build clean. Two new cases in onPortMessage; one new helper handleCreateDownloadUrl;
|
|
one new module-scoped Set mintedDownloadUrls; import line widened to include base64ToBlob.
|
|
Atomic commit:
|
|
`feat(02-02): offscreen — CREATE/REVOKE Blob URL handlers on keepalivePort (D-P2-01)`.
|
|
</done>
|
|
</task>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 3: SW downloadArchive rewrite + chrome.downloads.onChanged revoke wiring</name>
|
|
<files>src/background/index.ts</files>
|
|
<behavior>
|
|
- `downloadArchive(archiveBlob)` (line 695) rewritten:
|
|
(1) Encode archiveBlob to base64 via blobToBase64 (already imported at line 2).
|
|
(2) Generate a requestId (use crypto.randomUUID() — SW supports it; see getVideoBufferFromOffscreen pattern).
|
|
(3) Post `{ type: 'CREATE_DOWNLOAD_URL', requestId, dataBase64, mimeType: 'application/zip' }` on the existing port (offscreenPort variable — see Plan 01-04 wiring at src/background/index.ts:onConnect host).
|
|
(4) Await the matching `DOWNLOAD_URL` response (per-request listener map pattern, mirroring REQUEST_BUFFER → BUFFER from getVideoBufferFromOffscreen).
|
|
(5) If response.url === '' or no response within timeout (5000ms), throw a typed error: `new Error('blob-url-mint-failed: offscreen unresponsive or empty url')`. NO FALLBACK to legacy data: URL — the blob: URL is the contract; failure surfaces via the typed error so the operator gets a clear failure mode (routed through saveArchive's catch block to the RECORDING_ERROR channel, mirroring the EmptyVideoBufferError pattern). Silent corrupt-archive paths are forbidden.
|
|
(6) Call chrome.downloads.download({ url, filename, saveAs: false }) capturing the returned downloadId.
|
|
(7) Store downloadId → url in a module-scoped Map<number, string> (`pendingRevokes`) so chrome.downloads.onChanged can dispatch the revoke.
|
|
- chrome.downloads.onChanged listener registered at module init (after the existing chrome.runtime.onMessage listener registration around line 843):
|
|
```typescript
|
|
chrome.downloads.onChanged.addListener((delta) => {
|
|
if (!delta.state) return;
|
|
if (delta.state.current === 'complete' || delta.state.current === 'interrupted') {
|
|
const url = pendingRevokes.get(delta.id);
|
|
if (url !== undefined) {
|
|
pendingRevokes.delete(delta.id);
|
|
if (offscreenPort !== null) {
|
|
try {
|
|
offscreenPort.postMessage({ type: 'REVOKE_DOWNLOAD_URL', url });
|
|
} catch (err) {
|
|
logger.warn('REVOKE_DOWNLOAD_URL post failed:', err);
|
|
}
|
|
} else {
|
|
// Offscreen port unavailable — the URL leaks in offscreen until the
|
|
// document is torn down (which happens on browser close / extension
|
|
// reload). Not a security issue (URLs are extension-origin scoped),
|
|
// just a per-session memory leak diagnostically noted.
|
|
logger.warn(`offscreenPort null at revoke time; url ${url.substring(0,30)}... leaks until offscreen teardown`);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
```
|
|
- Defense-in-depth: NEVER `await import(...)` from src/background/index.ts (MV3 SW dynamic-import blocker — Plan 01-11 SUMMARY). All new logic is eager top-of-module.
|
|
- Always-on charter preserved: no changes to saveArchive (line 741); downloadArchive's caller signature unchanged; saveArchive's no-finally invariant from Plan 01-09 Amendment 3 untouched.
|
|
</behavior>
|
|
<action>
|
|
Edit `src/background/index.ts`:
|
|
|
|
1. Add module-scoped `pendingRevokes: Map<number, string>` near the existing state declarations
|
|
(line 68 region) with docstring citing D-P2-01.
|
|
|
|
2. Add module-scoped `pendingDownloadUrlResolvers: Map<string, (url: string) => void>` for the
|
|
requestId → resolver pattern (mirroring `pendingBufferResolvers` if it exists, or the
|
|
per-request listener pattern in getVideoBufferFromOffscreen — read that function to align
|
|
the implementation).
|
|
|
|
3. Extend the existing SW-side port `onMessage` handler (registered in onConnect — read
|
|
src/background/index.ts around the keepalive port plumbing to find the exact location)
|
|
to handle the new DOWNLOAD_URL message:
|
|
```typescript
|
|
if (msg.type === 'DOWNLOAD_URL' && typeof msg.requestId === 'string') {
|
|
const resolver = pendingDownloadUrlResolvers.get(msg.requestId);
|
|
if (resolver !== undefined) {
|
|
pendingDownloadUrlResolvers.delete(msg.requestId);
|
|
resolver(typeof msg.url === 'string' ? msg.url : '');
|
|
}
|
|
return;
|
|
}
|
|
```
|
|
|
|
4. Rewrite `downloadArchive(archiveBlob)` (line 695-718):
|
|
```typescript
|
|
async function downloadArchive(archiveBlob: Blob) {
|
|
const now = new Date();
|
|
const dateStr = now.toISOString().replace(/[:.]/g, '-').split('T')[0];
|
|
const timeStr = now.toTimeString().split(' ')[0].replace(/:/g, '-');
|
|
const filename = `session_report_${dateStr}_${timeStr}.zip`;
|
|
|
|
logger.log(`Downloading archive: ${filename} (${archiveBlob.size} bytes)`);
|
|
|
|
// D-P2-01: mint blob:URL via offscreen (SW lacks URL.createObjectURL per DEC-006).
|
|
// Bridge: encode → port post CREATE_DOWNLOAD_URL → await DOWNLOAD_URL → call chrome.downloads.
|
|
const dataBase64 = await blobToBase64(archiveBlob);
|
|
const requestId = crypto.randomUUID();
|
|
const urlPromise = new Promise<string>((resolve) => {
|
|
pendingDownloadUrlResolvers.set(requestId, resolve);
|
|
});
|
|
if (offscreenPort === null) {
|
|
throw new Error('blob-url-mint-failed: offscreen port unavailable');
|
|
}
|
|
offscreenPort.postMessage({
|
|
type: 'CREATE_DOWNLOAD_URL',
|
|
requestId,
|
|
dataBase64,
|
|
mimeType: 'application/zip',
|
|
});
|
|
const timeoutMs = 5000;
|
|
const url = await Promise.race([
|
|
urlPromise,
|
|
new Promise<string>((_, reject) =>
|
|
setTimeout(() => reject(new Error('blob-url-mint-timeout')), timeoutMs)
|
|
),
|
|
]);
|
|
if (url === '') {
|
|
throw new Error('blob-url-mint-failed: offscreen returned empty url');
|
|
}
|
|
const downloadId = await chrome.downloads.download({
|
|
url,
|
|
filename,
|
|
saveAs: false,
|
|
});
|
|
if (typeof downloadId === 'number') {
|
|
pendingRevokes.set(downloadId, url);
|
|
}
|
|
logger.log(`Archive download started: id=${downloadId}, blob-url=${url.substring(0, 30)}...`);
|
|
}
|
|
```
|
|
|
|
5. Register chrome.downloads.onChanged listener at module init (defensive try/catch like the
|
|
other listener registrations at line 843 region):
|
|
```typescript
|
|
try {
|
|
if (chrome.downloads?.onChanged?.addListener) {
|
|
chrome.downloads.onChanged.addListener((delta) => {
|
|
if (!delta.state) return;
|
|
const newState = delta.state.current;
|
|
if (newState !== 'complete' && newState !== 'interrupted') return;
|
|
const url = pendingRevokes.get(delta.id);
|
|
if (url === undefined) return;
|
|
pendingRevokes.delete(delta.id);
|
|
if (offscreenPort !== null) {
|
|
try {
|
|
offscreenPort.postMessage({ type: 'REVOKE_DOWNLOAD_URL', url });
|
|
logger.log(`Dispatched REVOKE_DOWNLOAD_URL for downloadId=${delta.id}`);
|
|
} catch (err) {
|
|
logger.warn('REVOKE_DOWNLOAD_URL post failed:', err);
|
|
}
|
|
} else {
|
|
logger.warn(`offscreenPort null at revoke time; url ${url.substring(0,30)}... leaks until offscreen teardown`);
|
|
}
|
|
});
|
|
}
|
|
} catch (err) {
|
|
logger.warn('chrome.downloads.onChanged.addListener failed:', err);
|
|
}
|
|
```
|
|
|
|
NOTE on `offscreenPort` variable: the existing SW-side onConnect handler stores the port reference
|
|
somewhere (read `src/background/index.ts` around line 350-510 OR grep for `chrome.runtime.onConnect`
|
|
to find the exact variable). Reuse that — do NOT introduce a parallel port reference.
|
|
|
|
Per D-P2-01 — this completes the architectural triangle: SW packages zip → SW asks offscreen
|
|
to mint URL → offscreen mints → SW downloads → onChanged fires → SW asks offscreen to revoke.
|
|
Matches CONTEXT.md `<decisions>` D-P2-01 verbatim, including the chrome.downloads.onChanged
|
|
listener for URL.revokeObjectURL lifecycle.
|
|
</action>
|
|
<verify>
|
|
<automated>npx tsc --noEmit 2>&1 | head -20 ; npm run build 2>&1 | tail -10 ; npx vitest run tests/background/blob-url-download.test.ts 2>&1 | tail -20 ; npm test 2>&1 | tail -30</automated>
|
|
</verify>
|
|
<done>
|
|
tsc clean. npm run build clean. tests/background/blob-url-download.test.ts → 3/3 GREEN.
|
|
All other vitest tests (153 baseline + Plan 02-01 RED tests that are not yet GREEN — only the
|
|
blob-url-download.test.ts ones flip here) preserved.
|
|
Pre-commit Tier-1 grep gate: GREEN (12 FORBIDDEN_HOOK_STRINGS unchanged — no new test-hook symbols).
|
|
Atomic commit:
|
|
`feat(02-02): SW — downloadArchive via offscreen-minted Blob URL + revoke lifecycle (D-P2-01 closes P0-6)`.
|
|
</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<threat_model>
|
|
## Trust Boundaries
|
|
|
|
| Boundary | Description |
|
|
|----------|-------------|
|
|
| SW → offscreen via port | Existing T-1-04 mitigation (sender-id check) preserved — no new boundary |
|
|
| chrome.downloads.onChanged event source | Chrome browser → SW; trustworthy per MV3 platform contract |
|
|
| Blob URL in offscreen origin | URL minted in offscreen document origin (chrome-extension://<id>); accessible only to extension contexts and the chrome.downloads internals; NOT exposed to web pages |
|
|
|
|
## STRIDE Threat Register
|
|
|
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
|
|-----------|----------|-----------|-------------|-----------------|
|
|
| T-02-02-01 | Information Disclosure | Blob URL leaks to web pages via shared-context bug | accept | URLs are extension-origin scoped per WHATWG URL spec; web pages cannot navigate to chrome-extension:// blob URLs without manifest web_accessible_resources entries (none added). Verified via grep: zero new web_accessible_resources in this plan. |
|
|
| T-02-02-02 | Denial of Service | Unbounded pendingRevokes Map growth if onChanged never fires | mitigate | pendingRevokes entries are deleted on every onChanged 'complete'/'interrupted' delta; SW idle teardown (~30s) drops the entire Map; per-session leak is bounded by O(saves-per-session) which is operationally <100. Diagnostic warn logged if offscreenPort is null at revoke time (URL leaks until offscreen teardown — acceptable). |
|
|
| T-02-02-03 | Tampering | Malicious offscreen returns a non-blob: URL string in DOWNLOAD_URL response | mitigate | offscreen is same-extension origin (sender-id check); chrome.downloads.download validates the URL scheme and Chrome will reject non-blob:/non-data:/non-http(s): URLs at the platform layer. Defense-in-depth: SW could add `if (!url.startsWith('blob:')) throw` — add this guard as part of the validation chain (downloadArchive's url=='' check is extended to also reject non-blob: prefixes). |
|
|
| T-02-02-04 | Elevation of Privilege | base64ToBlob in offscreen accepts arbitrary mimeType from SW port message | accept | mimeType is consumer-level metadata only; URL.createObjectURL doesn't grant any new privileges based on Blob.type. The risk is purely cosmetic (wrong file extension on download), not an EoP. |
|
|
| T-02-02-05 | Repudiation | downloadArchive failure surfaces only as a logger.warn — operator sees a missing zip with no diagnostic | mitigate | Throw a typed Error ('blob-url-mint-failed' / 'blob-url-mint-timeout' / 'offscreen port unavailable') → saveArchive catch block routes through the existing RECORDING_ERROR channel (see src/background/index.ts:809-829 EmptyVideoBufferError path) — operator sees a recovery notification. Plan-checker verifies the catch path is wired. |
|
|
</threat_model>
|
|
|
|
<verification>
|
|
- `npx tsc --noEmit` → clean.
|
|
- `npm run build` → clean.
|
|
- `npm run build:test` → clean (test bundle still builds; __MOKOSH_UAT__ token does not interact with new port messages — they are production surfaces).
|
|
- `npx vitest run tests/background/blob-url-download.test.ts --reporter=verbose` → 3/3 GREEN.
|
|
- `npx vitest run` (full suite) → previously 153/153 GREEN preserved, PLUS blob-url-download.test.ts +3 GREEN. Plan 02-01's other RED tests (meta-json-urls-schema.test.ts + strict-meta-json-validation.test.ts) REMAIN RED — flipped GREEN in Plan 02-03.
|
|
- `npx vitest run tests/background/no-test-hooks-in-prod-bundle.test.ts` → 12 FORBIDDEN_HOOK_STRINGS unchanged → GREEN.
|
|
- `npm run test:uat` → 24/24 GREEN (UAT preserved; harness inspects the existing zip-on-disk shape via A5/A12/A13, which the Blob URL path produces identically).
|
|
- Grep gate: `grep -c "data:application/zip;base64," src/background/index.ts` → 0 (the legacy path is gone). `grep -c "blob:" src/background/index.ts` ≥ 1.
|
|
- Manual operator empirical (deferred to Plan 02-04 operator checkpoint): save with the current build; verify the produced zip lands in Downloads + opens cleanly; verify Chrome DevTools Network panel shows blob: URL (not data:) as the download source. (Plan 02-04 wires this into the harness if possible.)
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
1. tests/background/blob-url-download.test.ts 3/3 GREEN.
|
|
2. UAT harness 24/24 GREEN preserved.
|
|
3. Production bundle ships chrome.downloads.onChanged listener (1 grep match in dist/) + zero `data:application/zip;base64,` (0 grep matches in dist/).
|
|
4. Tier-1 FORBIDDEN_HOOK_STRINGS = 12 (unchanged).
|
|
5. No `await import(...)` in src/background/index.ts (Plan 01-11 SUMMARY invariant).
|
|
6. Always-on charter preserved: saveArchive in src/background/index.ts:741 still has NO `finally` block resetting recorder state.
|
|
</success_criteria>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/02-stabilize-export-pipeline/02-02-SUMMARY.md`
|
|
documenting:
|
|
- 3 source files modified; line-count deltas.
|
|
- Wire-format extension: 3 new PortMessageType variants; in-flight resolver Map; revoke lifecycle Map.
|
|
- Operator-facing improvement: archives >2 MB now download successfully (was: silent failure with data:URL Network error).
|
|
- Forward link to Plan 02-04 harness extension for empirical A24+ verification.
|
|
</output>
|