23 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, requirements_addressed, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | requirements_addressed | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 01-stabilize-video-pipeline | 05 | execute | 2 |
|
|
true |
|
|
|
Purpose: REQ-video-ring-buffer's data flow on export is popup → SAVE_ARCHIVE → SW → REQUEST_BUFFER (via port) → offscreen → BUFFER (via port) → SW → assemble ZIP → download. The SW NEVER holds a chunk locally —
which is the only way the buffer survives SW idle unloads (D-16).
Output: A heavily-shrunk src/background/index.ts that compiles cleanly,
holds zero video-buffer state, talks to the offscreen exclusively via
runtime messaging + the long-lived port, and tolerates the IndexedDB
remnant by deleting it on first install.
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md @.planning/phases/01-stabilize-video-pipeline/01-RESEARCH.md @.planning/phases/01-stabilize-video-pipeline/01-PATTERNS.md @src/background/index.ts @src/shared/types.ts @src/offscreen/recorder.ts Port contract (already implemented in offscreen by Plan 04): - Port name: `'video-keepalive'` - Offscreen → SW outbound: `{ type: 'PING' }` every 25 s (informational; SW just receives it as keepalive traffic) - Offscreen → SW outbound on REQUEST_BUFFER: `{ type: 'BUFFER', chunks: VideoChunk[] }` - SW → offscreen inbound: `{ type: 'REQUEST_BUFFER' }`SW side must:
- Register
chrome.runtime.onConnect.addListenerthat filters by port name AND validates sender ID - Store the connected port in a module-level
let videoPort: chrome.runtime.Port | null - Clear the reference on disconnect (offscreen will reconnect; SW gets a new onConnect call)
- Expose
getVideoBufferFromOffscreen(): Promise<VideoBufferResponse>that:- Returns
{chunks: []}early if no port is connected - Otherwise sends
{type: 'REQUEST_BUFFER'}over the port - Resolves when a
{type: 'BUFFER', chunks: ...}reply arrives - Times out at 2 s with
{chunks: []}
- Returns
Existing SW behaviors to PRESERVE:
mergeVideoChunks(chunks: VideoChunk[]): Blob(unchanged)createArchive(...)(unchanged signature; callsmergeVideoChunksand zips with JSZip)downloadArchive(blob)(unchanged)captureScreenshot()(unchanged — Phase 3 owns popup-side rework)chrome.runtime.onInstalledlistener (existing, gets an indexedDB cleanup line added)onMessagecases:REQUEST_PERMISSIONS,GET_VIDEO_BUFFER,SAVE_ARCHIVE— KEPT but their bodies change to talk to offscreen via port
Removed message types (from Plan 03's edit to src/shared/types.ts):
'VIDEO_CHUNK'— handler block deleted'VIDEO_CHUNK_SAVED'— handler block deleted
chrome.offscreen.Reason.DISPLAY_MEDIA: try reasons: [chrome.offscreen.Reason.DISPLAY_MEDIA] first. If the current @types/chrome (0.0.268) does NOT expose DISPLAY_MEDIA and tsc --noEmit fails, fall back to a narrowing cast (no as any): reasons: ['DISPLAY_MEDIA' as chrome.offscreen.Reason]. The executor verifies which form compiles by running npx tsc --noEmit after the edit.
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
extension contexts → SW onConnect |
Any extension context can attempt to open a port to the SW; only the offscreen has a legitimate reason for the 'video-keepalive' port |
popup / content script → SW onMessage |
Existing trust boundary; this plan adds T-1-04 sender-id check on the SW-side onMessage too |
STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|---|---|---|---|---|
| T-1-04 | Spoofing — port-hijack on SW side | chrome.runtime.onConnect handler |
mitigate | The SW onConnect listener filters port.name === 'video-keepalive' AND port.sender?.id === chrome.runtime.id. A non-extension caller cannot open a runtime port at all (Chrome enforces); the sender-id check is defense-in-depth for the within-extension case. Grep gate: grep -v '^#' src/background/index.ts | grep -c "port.sender?.id !== chrome.runtime.id" returns at least 1. |
| T-1-NEW-05-01 | Information Disclosure — buffer pulled by unauthorized message | onMessage handlers (SAVE_ARCHIVE, GET_VIDEO_BUFFER) |
mitigate | The SW-side onMessage listener already exists; this plan adds a sender.id === chrome.runtime.id guard at the top of the listener (per RESEARCH.md Security Domain table). |
| T-1-NEW-05-02 | Tampering — stale IndexedDB orphan | indexedDB.deleteDatabase('VideoRecorderDB') in onInstalled |
mitigate | After this phase deletes the inline plugin's IndexedDB code, browser profiles that previously ran the old build still have a VideoRecorderDB database. The SW onInstalled listener calls indexedDB.deleteDatabase('VideoRecorderDB') idempotently to clear it. The call is harmless if the DB never existed. Grep gate: grep -c "indexedDB.deleteDatabase('VideoRecorderDB')" src/background/index.ts returns 1. |
| </threat_model> |
(1) Delete addVideoChunkFromBlob function — currently lines 26-45 inclusive (the entire // Кольцевой буфер видео block through the closing }). Also remove the preceding // Кольцевой буфер видео comment.
(2) Delete cleanupVideoBuffer function — currently lines 47-75 inclusive.
(3) Delete firstChunkSaved from module state — line 17 (let firstChunkSaved = false; // Флаг что первый чанк уже сохранен).
(4) Delete videoBuffer from module state — line 16 (let videoBuffer: VideoChunk[] = [];).
(5) Replace the chrome.tabCapture call inside startVideoCapture — currently lines 126-144. The replacement is VERBATIM:
// Создаём offscreen документ (с reason DISPLAY_MEDIA per D-02)
await ensureOffscreen();
// Просим offscreen запустить запись — getDisplayMedia вызывается там
// (D-01: больше нет chrome.tabCapture.getMediaStreamId).
logger.log('Sending START_RECORDING to offscreen...');
try {
await chrome.runtime.sendMessage({
type: 'START_RECORDING'
});
logger.log('START_RECORDING sent successfully');
} catch (msgError) {
logger.error('Failed to send START_RECORDING:', msgError);
throw msgError;
}
The existing const [tab] = ... line at the top of startVideoCapture and the if (!tab.id || !tab.url) throw ... check can be retained — they don't harm anything. Phase 1 keeps them.
(6) Replace ensureOffscreen reason — currently line 90 reads reasons: ['USER_MEDIA'] as any,. Replace VERBATIM:
reasons: [chrome.offscreen.Reason.DISPLAY_MEDIA],
If chrome.offscreen.Reason.DISPLAY_MEDIA is NOT in the current @types/chrome (0.0.268) and tsc --noEmit fails, fall back to (no as any):
reasons: ['DISPLAY_MEDIA' as chrome.offscreen.Reason],
(7) Update the justification text on line 91 to match RESEARCH.md Example C:
justification: 'Continuous screen recording for operator session diagnostics'
(8) Delete setupKeepalive function — currently lines 156-165.
(9) Delete the setupKeepalive() call inside initialize — currently line 525.
(10) Delete the VIDEO_CHUNK case from the onMessage switch — currently lines 457-466.
(11) Delete the VIDEO_CHUNK_SAVED case — currently lines 468-473.
(12) Delete loadChunkFromIndexedDB function — currently lines 482-505.
(13) Delete openIndexedDB function — currently lines 507-520.
After ALL these deletions, run npx tsc --noEmit. It MUST exit 0. If VideoChunk is reported as unused after the deletes, that indicates a function that needs it was inadvertently lost; STOP and audit.
npx tsc --noEmit && [ $(grep -cE "addVideoChunkFromBlob|cleanupVideoBuffer|setupKeepalive|loadChunkFromIndexedDB|openIndexedDB|getMediaStreamId|chrome.alarms" src/background/index.ts) -eq 0 ]
<acceptance_criteria>
- npx tsc --noEmit exits 0
- grep -v '^#' src/background/index.ts | grep -c "addVideoChunkFromBlob" returns 0
- grep -v '^#' src/background/index.ts | grep -c "cleanupVideoBuffer" returns 0
- grep -v '^#' src/background/index.ts | grep -c "setupKeepalive" returns 0
- grep -v '^#' src/background/index.ts | grep -c "loadChunkFromIndexedDB" returns 0
- grep -v '^#' src/background/index.ts | grep -c "openIndexedDB" returns 0
- grep -v '^#' src/background/index.ts | grep -c "getMediaStreamId" returns 0
- grep -v '^#' src/background/index.ts | grep -c "VIDEO_CHUNK_SAVED" returns 0
- grep -v '^#' src/background/index.ts | grep -c "chrome.alarms" returns 0
- grep -c "DISPLAY_MEDIA" src/background/index.ts returns 1
- grep -c "as any" src/background/index.ts returns 0 (CLAUDE.md rule)
- File line count reduced from 536 to roughly 380-400 lines (allow ±40)
</acceptance_criteria>
SW shed every legacy path. tsc clean. File ~30% smaller. Ready for the port-host wiring in Task 2.
(1) Module-level state additions — after the existing let cachedScreenshot: Blob | null = null; line, ADD VERBATIM:
// Port from offscreen (D-17). Re-assigned on every (re)connect.
let videoPort: chrome.runtime.Port | null = null;
// Offscreen readiness Promise — set up at module load, resolved on first
// OFFSCREEN_READY message (Pattern 4). startVideoCapture awaits this before
// sending START_RECORDING, so we never lose the popup's transient activation
// to a race with the offscreen bootstrap.
let offscreenReadyResolve: (() => void) | null = null;
const offscreenReady: Promise<void> = new Promise((res) => {
offscreenReadyResolve = res;
});
(2) onConnect handler — add VERBATIM AFTER the ensureOffscreen function block. Place it BEFORE startVideoCapture so the listener is registered before any code might trigger a port open:
// SW-side port host (D-17, RESEARCH.md Pattern 5). The offscreen opens this
// port on bootstrap and reconnects on disconnect. We use it for: (a)
// keepalive traffic (PING) — Chrome 110+ resets the SW idle timer on every
// port message; (b) on-demand REQUEST_BUFFER round-trip during SAVE_ARCHIVE.
chrome.runtime.onConnect.addListener((port) => {
// T-1-04 mitigation: only accept ports from this extension
if (port.name !== 'video-keepalive') {
return;
}
if (port.sender?.id !== chrome.runtime.id) {
logger.warn('Rejecting port with mismatched sender:', port.sender?.id);
port.disconnect();
return;
}
logger.log('Offscreen port connected');
videoPort = port;
port.onDisconnect.addListener(() => {
logger.log('Offscreen port disconnected; offscreen will reconnect');
videoPort = null;
});
// Inbound traffic is mostly PING (ignored) and BUFFER (handled by the
// per-request listener installed in getVideoBufferFromOffscreen).
});
(3) Buffer-fetch function — add VERBATIM AFTER the onConnect block above:
const BUFFER_FETCH_TIMEOUT_MS = 2_000;
async function getVideoBufferFromOffscreen(): Promise<VideoBufferResponse> {
if (videoPort === null) {
logger.warn('No offscreen port available; returning empty buffer');
return { chunks: [] };
}
const port = videoPort;
return new Promise<VideoBufferResponse>((resolve) => {
const timer = setTimeout(() => {
port.onMessage.removeListener(handler);
logger.warn(`Buffer fetch timed out after ${BUFFER_FETCH_TIMEOUT_MS} ms`);
resolve({ chunks: [] });
}, BUFFER_FETCH_TIMEOUT_MS);
const handler = (msg: unknown) => {
if (
typeof msg === 'object' &&
msg !== null &&
(msg as { type?: unknown }).type === 'BUFFER'
) {
clearTimeout(timer);
port.onMessage.removeListener(handler);
const chunks = (msg as { chunks?: VideoChunk[] }).chunks ?? [];
resolve({ chunks });
}
};
port.onMessage.addListener(handler);
port.postMessage({ type: 'REQUEST_BUFFER' });
});
}
(4) Delete getVideoBuffer (the synchronous version that returned the local array) — find and delete the function currently around lines ~168-172 post-deletes (function getVideoBuffer(): VideoBufferResponse { return { chunks: videoBuffer }; }). It no longer compiles after Task 1 deletes videoBuffer.
(5) Replace the GET_VIDEO_BUFFER case — in the onMessage switch, find case 'GET_VIDEO_BUFFER': and replace its body VERBATIM:
case 'GET_VIDEO_BUFFER':
getVideoBufferFromOffscreen().then((resp) => sendResponse(resp));
return true;
(6) Update saveArchive to use the port-based fetch — find the const videoBuffer = getVideoBuffer(); line (currently around line 332 in the post-Task-1 file). Replace VERBATIM:
const videoBufferResp = await getVideoBufferFromOffscreen();
logger.log(`Video buffer: ${videoBufferResp.chunks.length} chunks`);
And update the subsequent createArchive(videoBuffer, ...) call to use videoBufferResp instead. Specifically:
const archiveBlob = await createArchive(
videoBufferResp,
rrwebEvents,
userEvents,
screenshot
);
(7) Add OFFSCREEN_READY case + await in startVideoCapture — two sub-edits:
(7a) Add a new case to the onMessage switch, placed AFTER case 'SAVE_ARCHIVE': and BEFORE the default: block. VERBATIM:
case 'OFFSCREEN_READY':
logger.log('OFFSCREEN_READY received');
offscreenReadyResolve?.();
offscreenReadyResolve = null;
return false;
(7b) In startVideoCapture, ADD await offscreenReady; AFTER await ensureOffscreen();. The relevant excerpt becomes:
await ensureOffscreen();
await offscreenReady;
logger.log('Sending START_RECORDING to offscreen...');
// ... rest unchanged
(8) Sender check on onMessage — at the very top of the existing chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { callback, rename _sender to sender and add a guard. Replace the listener header VERBATIM:
chrome.runtime.onMessage.addListener((message: Message, sender, sendResponse) => {
if (sender.id !== chrome.runtime.id) {
logger.warn('Rejecting message with mismatched sender:', sender.id);
return false;
}
logger.log('Received message:', message.type, message);
(9) onInstalled IndexedDB cleanup — find the existing chrome.runtime.onInstalled.addListener block and REPLACE entirely VERBATIM:
chrome.runtime.onInstalled.addListener((details) => {
logger.log('Extension installed/updated:', details.reason);
// RESEARCH.md Runtime State Inventory — clean up orphaned IndexedDB from
// pre-Phase-01 builds. Idempotent: no-op if DB never existed.
try {
indexedDB.deleteDatabase('VideoRecorderDB');
logger.log('Cleaned up orphaned VideoRecorderDB (if present)');
} catch (e) {
logger.warn('indexedDB.deleteDatabase failed:', e);
}
initialize();
});
After ALL these edits, run:
npx tsc --noEmit
npx vitest run
Both MUST exit 0.
npx tsc --noEmit && npx vitest run && grep -c "video-keepalive" src/background/index.ts && grep -c "VideoRecorderDB" src/background/index.ts
<acceptance_criteria>
- npx tsc --noEmit exits 0
- npx vitest run exits 0 (all 8 offscreen tests still pass — Plan 05 only touches SW, but the offscreen tests should not regress)
- grep -c "chrome.runtime.onConnect.addListener" src/background/index.ts returns 1
- grep -c "'video-keepalive'" src/background/index.ts returns at least 1
- grep -c "port.sender?.id !== chrome.runtime.id" src/background/index.ts returns 1 (T-1-04 mitigation)
- grep -c "sender.id !== chrome.runtime.id" src/background/index.ts returns 1 (onMessage sender check)
- grep -c "indexedDB.deleteDatabase('VideoRecorderDB')" src/background/index.ts returns 1
- grep -c "function getVideoBufferFromOffscreen" src/background/index.ts returns 1
- grep -c "REQUEST_BUFFER" src/background/index.ts returns at least 1
- grep -c "offscreenReady" src/background/index.ts returns at least 2 (the Promise variable + the await)
- grep -c "OFFSCREEN_READY" src/background/index.ts returns at least 1 (the case label)
- grep -c "as any" src/background/index.ts returns 0
- grep -c "@ts-ignore" src/background/index.ts returns 0
</acceptance_criteria>
SW side fully wired against the offscreen's port. Sender checks in place. IndexedDB cleanup landed on onInstalled. The SW is now a pure coordinator — it holds no buffer state of its own.
npx tsc --noEmit— exits 0.npx vitest run— exits 0 (8 tests passing across 4 files in tests/offscreen/).- The grep gates listed in Task 2's
acceptance_criteriaall return their expected values. wc -l src/background/index.ts— line count between 380 and 440 (the file shrunk from 536 by ~100-150 lines as the legacy paths went away and ~30-40 lines were added for the port host).
Commit cadence: TWO commits.
- Task 1: ONE commit (
refactor(01-05): delete legacy SW buffer + alarms + IndexedDB + tabCapture paths). - Task 2: ONE commit (
feat(01-05): wire SW-side port host and port-based buffer fetch).
<success_criteria>
src/background/index.tscarries no buffer state, no alarms, no IndexedDB plumbing, notabCapturecalls- SW has
onConnecthandler matching the offscreen's port (Plan 04 counterparty) - SW has
OFFSCREEN_READYhandshake handler resolving a readiness Promise - T-1-04 mitigations in place on BOTH onConnect (sender + port name) and onMessage (sender)
- IndexedDB orphan cleanup runs on onInstalled
tsc --noEmitclean; noas any, no@ts-ignore</success_criteria>