fix(02): revise plans per checker (B1 + 4 flags) — add tabs permission for D-P2-02

- 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>
This commit is contained in:
2026-05-20 14:25:20 +02:00
parent 0608b22427
commit 9dcfcf0793
8 changed files with 496 additions and 121 deletions

View File

@@ -134,7 +134,7 @@ nothing is validated until SPEC §10 acceptance passes.)
| **DEC-008**: Screenshot via `chrome.tabs.captureVisibleTab` | SPEC §4.4, §5 — captured at export time, not continuously. | — Pending | locked (Phase 1) |
| **DEC-009**: WebM header chunk retained indefinitely | SPEC §4.1, §8 — WebM without its header is not playable. | — Pending | locked (Phase 1) |
| **DEC-010**: Service Worker keepalive via long-lived port | AMENDED by Phase 01: SPEC §8 originally specified `chrome.alarms` at 20 s; Phase 01 swaps to a `chrome.runtime.connect` port between offscreen and SW with 25 s ping cadence and 290 s pre-emptive reconnect. See `.planning/intel/decisions.md` DEC-010 Amendment. | — Pending | locked (Phase 1, post-Amendment) |
| **DEC-011**: Manifest permissions set | SPEC §7 — `tabCapture`, `activeTab`, `downloads`, `scripting`, `storage` + `host_permissions: ["<all_urls>"]`. | — Pending | locked (Phase 1) |
| **DEC-011**: Manifest permissions set | AMENDED 2026-05-20 (Amendment 1) by Plan 02-03: SPEC §7 originally specified `tabCapture`, `activeTab`, `downloads`, `scripting`, `storage` + `host_permissions: ["<all_urls>"]`. Phase 01 retired `tabCapture` (DEC-003 Amendment) and added `desktopCapture`, `offscreen`, `notifications`. Amendment 1 (2026-05-20) ADDS `tabs` to enable `chrome.tabs.get(tabId).url` + `chrome.tabs.query({})` for the Phase 2 D-P2-02 `meta.urls` feature (tab-url-tracker requires URL visibility beyond active-tab semantics). Current locked set: `desktopCapture`, `activeTab`, `tabs`, `downloads`, `scripting`, `storage`, `offscreen`, `notifications` + `host_permissions: ["<all_urls>"]`. Audit T-1-02 ("unused permissions expand attack surface") is acknowledged but overridden — the permission is genuinely USED by the meta.urls feature, so it is not unused. See `.planning/phases/02-stabilize-export-pipeline/02-CONTEXT.md` Revision Log. | — Pending | locked (Phase 1, post-Amendment 1) |
| **DEC-012**: Vite + crxjs + TypeScript build toolchain | README §"Технический стек" — DOC-level only; SPEC does not prescribe. | — Pending | locked (Phase 1) — auto-overridable by future ADR |
## Success Metric (Developer-Facing)

View File

@@ -25,11 +25,11 @@ must_haves:
truths:
- "Three RED test files exist and fail under the current HEAD (cc042a5) with concrete failure messages, NOT timeouts or missing imports."
- "blob-url-download.test.ts pins downloadArchive() calling chrome.downloads.download with url starting with 'blob:' (not 'data:application/zip;base64,')."
- "meta-json-urls-schema.test.ts pins SessionMetadata.urls as string[] and createArchive emitting meta.json with urls (not url)."
- "strict-meta-json-validation.test.ts pins ISO-8601 Z-suffix timestamp, non-empty urls[], semver extensionVersion, non-negative totalEvents, exactly 8 fields."
- "meta-json-urls-schema.test.ts pins SessionMetadata.urls as string[], createArchive emitting meta.json with urls (not url), AND the F2-pinned empty-tracker contract (urls: [] for whole-desktop-no-tab sessions; NO sentinel fallback)."
- "strict-meta-json-validation.test.ts pins ISO-8601 Z-suffix timestamp, urls is an Array of filter-matching URLs (empty PERMITTED per F2 resolution), semver extensionVersion, non-negative totalEvents, exactly 8 fields."
- "All 3 files use Vitest describe+it patterns matching existing tests/background/*.test.ts conventions (vi.fn() chrome.* stubs; jsdom or node env per existing patterns)."
- "Tier-1 FORBIDDEN_HOOK_STRINGS unit-test gate remains GREEN (12 entries unchanged — this plan adds no test-hook symbols)."
- "153/153 baseline vitest count rises to 153 + N (where N = number of new it() blocks; expected ~10-14)."
- "153/153 baseline vitest count rises to 153 + N (where N = number of new it() blocks; expected ~16 after F2 added Test 5 to meta-json-urls-schema.test.ts)."
artifacts:
- path: "tests/background/blob-url-download.test.ts"
provides: "RED pin: downloadArchive calls chrome.downloads.download({ url: blob:..., filename })"
@@ -229,8 +229,14 @@ Existing test conventions (read these for stub patterns):
'chrome-extension://abc/popup.html'; assert `meta.urls` equals
`['https://example.com', 'chrome-extension://abc/popup.html']` (per CONTEXT.md
"URL filter" specifics block).
- All 4 tests MUST fail under current HEAD: Test 1 via tsc compile failure (urls missing on type);
Test 2 via `meta.url` present + `meta.urls` undefined; Test 3+4 via the tab-tracking module
- Test 5 ("empty-tracker → meta.urls === []", per F2 plan-checker iteration 1):
mock the tab-tracking source to return an EMPTY array (whole-desktop-no-tab session);
invoke createArchive; assert `meta.urls` is EXACTLY `[]` (empty Array; not undefined; not
`[extension-origin-URL]`; not `null`). This pins the F2 resolution: NO sentinel fallback.
The empty array is the canonical representation of a whole-desktop recording with no browser
tabs open at SAVE time.
- All 5 tests MUST fail under current HEAD: Test 1 via tsc compile failure (urls missing on type);
Test 2 via `meta.url` present + `meta.urls` undefined; Test 3+4+5 via the tab-tracking module
not existing yet (these will throw import errors — acceptable as the RED signal).
</behavior>
<action>
@@ -256,10 +262,10 @@ Existing test conventions (read these for stub patterns):
<automated>npx vitest run tests/background/meta-json-urls-schema.test.ts 2>&1 | grep -E "(FAIL|RED|failed|TS\d+)" | head -10</automated>
</verify>
<done>
File exists, 4 it() blocks under a single describe. Test 1 fails via tsc compile error on missing
`urls` field. Tests 2-4 fail with assertion errors OR import errors (latter for the missing
File exists, 5 it() blocks under a single describe. Test 1 fails via tsc compile error on missing
`urls` field. Tests 2-5 fail with assertion errors OR import errors (latter for the missing
tab-url-tracker module). NONE pass under current HEAD.
Commit message: `test(02-01): RED — pin meta.json urls[] schema + dedup/filter semantics (D-P2-02)`.
Commit message: `test(02-01): RED — pin meta.json urls[] schema + dedup/filter + empty-tracker semantics (D-P2-02 + F2)`.
</done>
</task>
@@ -270,14 +276,15 @@ Existing test conventions (read these for stub patterns):
- Test 1 ("meta.json has exactly 8 fields"): Object.keys(meta).length === 8.
- Test 2 ("timestamp is ISO-8601 with Z suffix"):
`/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/.test(meta.timestamp)`.
- Test 3 ("urls is a non-empty string[] of valid URLs"):
`Array.isArray(meta.urls) && meta.urls.length >= 1 && meta.urls.every(u => /^(https?|chrome-extension):\/\//.test(u))`.
Note: CONTEXT.md `<specifics>` allows empty urls[] for "purely whole-desktop recording without
browser-tab interaction" — but D-P2-02 also says "the operator's primary tab at SAVE time should
always be in the array if it has a valid URL", and CONTEXT.md `<decisions>` D-P2-03 mandates
"urls array non-empty" as a schema validation rule. Resolve in favor of the strict-validation
rule from D-P2-03 (non-empty required). Document the resolution inline in the test as a
block comment citing both CONTEXT.md sources.
- Test 3 ("urls is an Array of valid URLs; empty IS PERMITTED for whole-desktop sessions"):
`Array.isArray(meta.urls) && meta.urls.every(u => /^(https?|chrome-extension):\/\//.test(u))`.
Per F2 (plan-checker iteration 1 resolution): the empty-tracker case is MEANINGFUL (whole-desktop
recording with no browser tabs open). The strict-validation rule from D-P2-03 ("urls array
non-empty") is RELAXED to PERMIT empty arrays here; CONTEXT.md `<specifics>` permissive clause
wins over the original D-P2-03 strict clause. Operator empirical workflow (Plan 02-04 Task 4
Step 2) verifies the non-empty case via the multi-tab path. Document this resolution inline
in the test as a block comment citing both CONTEXT.md sources AND the F2 plan-checker
iteration 1 decision.
- Test 4 ("extensionVersion matches semver"): `/^\d+\.\d+\.\d+$/.test(meta.extensionVersion)`.
- Test 5 ("totalEvents is non-negative integer"):
`Number.isInteger(meta.totalEvents) && meta.totalEvents >= 0`.
@@ -347,9 +354,9 @@ Existing test conventions (read these for stub patterns):
<verification>
- `npx vitest run tests/background/blob-url-download.test.ts` → 3 RED.
- `npx vitest run tests/background/meta-json-urls-schema.test.ts` → 4 RED.
- `npx vitest run tests/build/strict-meta-json-validation.test.ts` → 8 RED.
- `npm test` → previously-153 GREEN + N RED (15 ± 1 RED from this plan). Acceptable window —
- `npx vitest run tests/background/meta-json-urls-schema.test.ts` → 5 RED (includes F2 empty-tracker → urls:[] pin).
- `npx vitest run tests/build/strict-meta-json-validation.test.ts` → 8 RED (Test 3 RELAXED to permit empty urls[]).
- `npm test` → previously-153 GREEN + N RED (16 ± 1 RED from this plan). Acceptable window —
Plans 02-02 + 02-03 close all RED before phase closure.
- `grep -c '\.skip' tests/background/blob-url-download.test.ts tests/background/meta-json-urls-schema.test.ts tests/build/strict-meta-json-validation.test.ts` → 0.
- Tier-1 grep gate: `npx vitest run tests/background/no-test-hooks-in-prod-bundle.test.ts` GREEN

View File

@@ -290,7 +290,7 @@ Existing blobToBase64 / base64ToBlob (src/shared/binary.ts:42-85) — REUSE AS-I
(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 an EmptyVideoBufferError-equivalent "blob-url-mint-failed" or log + return (planner-decision: log warn, attempt fallback via legacy data: URL ONLY for archives <1 MBotherwise throw and surface to operator). Resolved inline: NO FALLBACK. The blob: URL is the new contract; failure must surface via a typed error so the operator gets a clear failure mode, not a silent corrupt archive. Throw `new Error('blob-url-mint-failed: offscreen unresponsive or empty url')`.
(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):
@@ -433,7 +433,7 @@ Existing blobToBase64 / base64ToBlob (src/shared/binary.ts:42-85) — REUSE AS-I
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</automated>
<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.

View File

@@ -5,6 +5,9 @@ type: auto
wave: 2
depends_on: [01]
files_modified:
- manifest.json
- tests/i18n/manifest-i18n.test.ts
- .planning/PROJECT.md
- src/background/tab-url-tracker.ts
- src/shared/types.ts
- src/background/index.ts
@@ -28,7 +31,7 @@ must_haves:
- ".planning/REQUIREMENTS.md REQ-meta-json-schema text amended to reflect the new 8-field shape, with the breaking-change cutover documented inline."
- "chrome.tabs.onActivated and chrome.tabs.onUpdated listeners registered in src/background/index.ts maintain the tracker's internal Set; pruning happens alongside the video segment ring buffer lifecycle (no separate timer)."
- "Always-on charter preserved (Plan 01-09 Amendment 3): SAVE creates a zip with the current tracker state; tracker continues accumulating after SAVE."
- "manifest.json permissions: the existing `tabs` permission gap (Plan 01-13 SUMMARY Known Limitations item 3) is NOT closed by this plan — the tracker uses chrome.tabs.onActivated + chrome.tabs.onUpdated which work on `activeTab` permission alone for the active-tab URL events. Cross-window/inactive-tab URL events require `tabs` permission and are deferred to Phase 4 hardening (planner-decision documented inline)."
- "DEC-011 amended (Amendment 1, 2026-05-20): `tabs` permission ADDED to manifest.json by this plan. chrome.tabs.get(tabId).url is now reliably defined for all tabs in any window (not just activeTab). The tab-url-tracker enumerates chrome.tabs.query({}) at SAVE time as a defensive fallback to capture tabs the operator OPENED but never activated during the 30s window. tests/i18n/manifest-i18n.test.ts pins the new 8-entry permission set as a regression guard."
artifacts:
- path: "src/background/tab-url-tracker.ts"
provides: "getTabUrlsSeen(): string[] + initTabUrlTracker(): void + clearTabUrlsSeen(): void"
@@ -43,6 +46,15 @@ must_haves:
- path: ".planning/REQUIREMENTS.md"
provides: "REQ-meta-json-schema amended for 8-field shape + breaking-change cutover note"
contains: "schemaVersion|urls.*string\\[\\]"
- path: "manifest.json"
provides: "permissions array extended to include `tabs` (DEC-011 Amendment 1)"
contains: "\"tabs\""
- path: "tests/i18n/manifest-i18n.test.ts"
provides: "regression-pin describe block for the 8-entry permission set including `tabs`"
contains: "DEC-011 Amendment 1|EXPECTED_PERMISSIONS"
- path: ".planning/PROJECT.md"
provides: "DEC-011 row rewritten with Amendment 1 (2026-05-20) citing Phase 2 D-P2-02 as justification"
contains: "Amendment 1"
key_links:
- from: "src/background/index.ts:initialize"
to: "src/background/tab-url-tracker.ts:initTabUrlTracker"
@@ -189,6 +201,65 @@ REQ-meta-json-schema (REQUIREMENTS.md:106-119) — verbatim text to amend:
<tasks>
<task type="auto" tdd="false">
<name>Task 0: DEC-011 Amendment 1 — add `tabs` to manifest + sync PROJECT.md + i18n permission-pin test</name>
<files>manifest.json, .planning/PROJECT.md, tests/i18n/manifest-i18n.test.ts</files>
<action>
Per plan-checker iteration 1 BLOCKER B1 resolution (user-approved Option A: honor D-P2-02
"all tabs visible" verbatim by adding `tabs` permission to manifest.json).
1. **manifest.json:** extend the `permissions` array to include `"tabs"`. The locked
post-amendment set has exactly 8 entries (alphabetic order is NOT required; the
i18n test pins membership via set-equality, not order):
```json
"permissions": [
"desktopCapture",
"activeTab",
"tabs",
"downloads",
"scripting",
"storage",
"offscreen",
"notifications"
]
```
NOTE: this commit may have already been applied during the revision pass — verify before
editing. If `grep -c '"tabs"' manifest.json` returns a value matching the post-amendment
state, treat this step as a no-op verification and proceed.
2. **.planning/PROJECT.md DEC-011 row:** rewrite the row to embed Amendment 1
(2026-05-20) referencing Phase 2 D-P2-02 as justification. The amended row must:
(a) Cite SPEC §7 as the original source and DEC-003 Amendment as Phase 01 delta.
(b) State Amendment 1 ADDS `tabs` for D-P2-02 meta.urls feature.
(c) List the current locked set verbatim (8 permissions + host_permissions).
(d) Acknowledge audit T-1-02 ("unused permissions expand attack surface") and
explicitly override it: the permission is USED by the meta.urls feature, so
it is not unused.
(e) Set status to `locked (Phase 1, post-Amendment 1)`.
3. **tests/i18n/manifest-i18n.test.ts:** add a new describe block
`"Plan 02-03 DEC-011 Amendment 1: manifest.json permissions include \"tabs\" (D-P2-02)"`
containing TWO assertions:
(a) `manifest.permissions.includes('tabs') === true`
(b) Set-equality between `manifest.permissions` (sorted) and the locked 8-entry
EXPECTED_PERMISSIONS constant (sorted). Both lengths must match.
Pre-existing test blocks (`Plan 01-12: ...`) MUST remain GREEN unchanged.
The amendment closes plan-checker iteration 1 BLOCKER B1 and unblocks Plan 02-04 A27's
strict-mode (both-URLs-required) assertion — see Plan 02-04 Task 3 amendment in the same
iteration 1 pass.
</action>
<verify>
<automated>grep -c '"tabs"' manifest.json ; grep -c 'Amendment 1' .planning/PROJECT.md ; npx vitest run tests/i18n/manifest-i18n.test.ts 2>&1 | tail -10</automated>
</verify>
<done>
manifest.json permissions includes `"tabs"` exactly once. PROJECT.md DEC-011 row carries
Amendment 1 prose (≥1 grep hit for "Amendment 1"). tests/i18n/manifest-i18n.test.ts new
describe block GREEN (2/2 assertions pass). Atomic commit shared with the rest of the
iteration 1 revision pass — see commit conventions in the orchestrator brief.
</done>
</task>
<task type="auto" tdd="true">
<name>Task 1: Create src/background/tab-url-tracker.ts module</name>
<files>src/background/tab-url-tracker.ts</files>
@@ -239,19 +310,31 @@ REQ-meta-json-schema (REQUIREMENTS.md:106-119) — verbatim text to amend:
and in-tab navigation events. No explicit "snapshot active tab at SAVE time" is needed (the
tracker has already captured it via onActivated).
NOTE on `tabs` permission gap: chrome.tabs.onActivated provides tabId in the event payload;
chrome.tabs.get(tabId) returns the tab WITHOUT the `.url` field unless `tabs` permission is
granted (Plan 01-13 SUMMARY Known Limitations item 3). For the active tab specifically,
`activeTab` permission grants temporary URL access. The tracker accepts the URL when present
and skips silently when absent (logged at warn level for diagnostics). This means inactive-tab
URL changes are NOT captured — acceptable per planner-decision deferred to Phase 4 hardening
(CONTEXT.md `<deferred>` "tabs permission gap"). Document this limitation inline.
NOTE on `tabs` permission (post DEC-011 Amendment 1, 2026-05-20): the new task in this plan
AMENDS manifest.json to include the `tabs` permission. With `tabs` granted, chrome.tabs.get(tabId)
reliably populates the `.url` field for any tab in any window — closing the limitation noted in
Plan 01-13 SUMMARY Known Limitations item 3. The tracker therefore captures URL transitions for
BOTH active and inactive tabs (the `chrome.tabs.onUpdated` listener already fires for any tab's
URL change regardless of active state). A diagnostic logger.warn fires only if `.url` is still
missing after the permission is granted (shouldn't happen — defensive only).
Per D-P2-03's "urls array non-empty" rule from Plan 02-01 Task 3: if getTabUrlsSeen returns
an empty array at SAVE time, createArchive (Task 3 below) must fall back to a sentinel value
(e.g., the extension-origin URL from `new URL(chrome.runtime.getURL('')).origin` — same as the
current single-url path). This guarantees the strict 8-field schema validation never fails on
empty urls[].
SAVE-time defensive enumeration (per F2 + DEC-011 Amendment 1 capability): in addition to the
event-driven Set maintenance, the tracker exposes a `snapshotOpenTabs(): Promise<void>` helper
that invokes `chrome.tabs.query({})` and folds every returned tab.url (that passes the filter)
into the internal Set + firstSeenOrder. createArchive (Task 2 below) calls this helper BEFORE
reading `getTabUrlsSeen()` so any tab the operator OPENED during the 30s window but never
activated (so the onActivated listener never fired for it) is still captured. This is purely
additive — duplicates dedup; order preserves first-seen-first; no behavior change for
already-observed tabs.
Per F2 (plan-checker iteration 1): the empty-tracker case is NO LONGER a sentinel-URL fallback.
If after `snapshotOpenTabs()` the tracker is STILL empty (e.g., whole-desktop recording with NO
browser tabs open at SAVE time — operator captured a non-Chrome window via desktopCapture only),
that is a MEANINGFUL state and is represented faithfully as `urls: []` (empty array). The strict-
validation rule in `tests/build/strict-meta-json-validation.test.ts` Test 3 was relaxed in Plan
02-01 iteration 1 to PERMIT empty urls[] for this case; Plan 02-01's RED tests pin the
empty-tracker → `meta.urls === []` contract explicitly. NO fake extension-origin URL is
inserted.
</action>
<verify>
<automated>npx tsc --noEmit 2>&1 | head -20</automated>
@@ -269,16 +352,27 @@ REQ-meta-json-schema (REQUIREMENTS.md:106-119) — verbatim text to amend:
<behavior>
- src/shared/types.ts SessionMetadata: REPLACE `url: string` with `urls: string[]`; ADD `schemaVersion: string` as the first field. Total 8 fields.
- src/background/index.ts:
(a) import { initTabUrlTracker, getTabUrlsSeen } from './tab-url-tracker';
(a) import { initTabUrlTracker, snapshotOpenTabs, getTabUrlsSeen } from './tab-url-tracker';
(b) call initTabUrlTracker() inside the existing `initialize()` function (line 957) under defensive try/catch matching the chrome.notifications.onClicked pattern.
(c) createArchive (line 673-684) updated:
(c) createArchive (line 673-684) updated. Per F2 (plan-checker iteration 1) the empty-tracker case is NOT a sentinel-URL fallback; it is `urls: []`:
```typescript
const manifest = chrome.runtime.getManifest();
const tabUrls = getTabUrlsSeen();
// D-P2-03 non-empty guarantee: if the tracker has no URLs (e.g., SW just spawned,
// operator hasn't activated a real tab yet), fall back to the extension-origin URL
// so the strict-validation schema (REQ-meta-json-schema) never fails on empty array.
const urls = tabUrls.length > 0 ? tabUrls : [new URL(chrome.runtime.getURL('')).origin];
// DEC-011 Amendment 1: with `tabs` permission granted, snapshot every currently-open
// tab's URL at SAVE time as a defensive fallback. This captures tabs the operator
// OPENED during the 30s window but never activated (so the onActivated listener
// never fired for them). Dedup + order preservation is handled by the tracker.
try {
await snapshotOpenTabs();
} catch (err) {
logger.warn('snapshotOpenTabs failed at SAVE time (continuing with tracker state):', err);
}
const urls = getTabUrlsSeen();
if (urls.length === 0) {
// Meaningful state: whole-desktop recording with NO browser tabs open. Per F2
// resolution, this is represented faithfully as urls:[] — NO fake extension-origin
// URL inserted. tests/background/meta-json-urls-schema.test.ts pins this contract.
logger.warn('createArchive: tabUrlsSeen is empty after snapshotOpenTabs — emitting meta.urls=[] (whole-desktop session with no browser tabs)');
}
const metadata: SessionMetadata = {
schemaVersion: '2',
timestamp: new Date().toISOString(),
@@ -308,7 +402,7 @@ REQ-meta-json-schema (REQUIREMENTS.md:106-119) — verbatim text to amend:
decision to make schemaVersion the 8th field.
2. Edit `src/background/index.ts`:
- Add import: `import { initTabUrlTracker, getTabUrlsSeen } from './tab-url-tracker';` near
- Add import: `import { initTabUrlTracker, snapshotOpenTabs, getTabUrlsSeen } from './tab-url-tracker';` near
the existing import block (line 1-11).
- In `initialize()` (line 957), add a defensive try/catch block calling initTabUrlTracker()
right after the existing chrome.notifications.onClicked listener registration (search for
@@ -436,25 +530,28 @@ REQ-meta-json-schema (REQUIREMENTS.md:106-119) — verbatim text to amend:
- `grep -c "schemaVersion" .planning/REQUIREMENTS.md` ≥ 2 (in REQ block + acceptance bullet).
- `grep -c "url: string" src/shared/types.ts` → 0 (the legacy field is gone).
- Always-on charter check: `grep -n "clearTabUrlsSeen" src/background/index.ts` → 0 hits (createArchive does NOT clear; tracker keeps accumulating after SAVE).
- DEC-011 Amendment 1 verification: `grep -c '"tabs"' manifest.json` ≥ 1; `npx vitest run tests/i18n/manifest-i18n.test.ts` GREEN (pre-existing 10 tests + 2 new permission-set tests = 12/12 GREEN); `grep -c 'Amendment 1' .planning/PROJECT.md` ≥ 1.
</verification>
<success_criteria>
1. tests/background/meta-json-urls-schema.test.ts 4/4 GREEN.
2. tests/build/strict-meta-json-validation.test.ts 8/8 GREEN.
1. tests/background/meta-json-urls-schema.test.ts 4/4 GREEN (including the F2-pinned empty-tracker → `meta.urls === []` contract).
2. tests/build/strict-meta-json-validation.test.ts 8/8 GREEN (Test 3 relaxed to PERMIT empty urls[] per F2 resolution).
3. UAT harness 24/24 GREEN preserved (or A13 amended in lockstep if its meta.json assertions depend on the old `url` field — verify before commit).
4. .planning/REQUIREMENTS.md REQ-meta-json-schema reflects the 8-field shape with breaking-change cutover documented.
5. Always-on charter preserved: tab-url-tracker continues accumulating after SAVE; no createArchive-time clearTabUrlsSeen call.
6. Tier-1 FORBIDDEN_HOOK_STRINGS = 12 (unchanged).
7. tabs permission gap NOT closed by this plan — explicit deferral comment in tab-url-tracker.ts citing CONTEXT.md `<deferred>`.
7. DEC-011 Amendment 1 LANDED: manifest.json permissions includes `"tabs"`; PROJECT.md DEC-011 row carries Amendment 1 prose; tests/i18n/manifest-i18n.test.ts permission-set describe block GREEN. The `tabs` permission gap noted in Plan 01-13 SUMMARY Known Limitations item 3 is CLOSED by this plan, not deferred.
8. createArchive empty-tracker fallback REMOVED (F2): emits `urls: []` + logger.warn for whole-desktop-no-tab sessions; NO fake extension-origin URL inserted.
</success_criteria>
<output>
After completion, create `.planning/phases/02-stabilize-export-pipeline/02-03-SUMMARY.md`
documenting:
- New module src/background/tab-url-tracker.ts (LOC + public API summary)
- New module src/background/tab-url-tracker.ts (LOC + public API summary) — 4 exports incl. snapshotOpenTabs (DEC-011 Amendment 1)
- SessionMetadata field-count delta (7 → 8) + ordering rationale
- 8th field `schemaVersion` decision: planner-suggested in Plan 02-01 Task 3, ratified here
- Filter rules verbatim from CONTEXT.md `<specifics>`
- Tabs permission gap deferral citation (CONTEXT.md `<deferred>`)
- Forward link: Plan 02-04 may amend UAT harness A13 if its meta.json assertions assumed the old `url` field
- DEC-011 Amendment 1 (2026-05-20): `tabs` permission ADDED; tests/i18n/manifest-i18n.test.ts pins the 8-entry set; PROJECT.md DEC-011 row rewritten with Amendment 1 prose
- F2 resolution: empty-tracker case emits `urls: []` (no fake extension-origin fallback)
- Forward link: Plan 02-04 A27 strict-mode (both URLs required) is unblocked by Amendment 1
</output>

View File

@@ -22,9 +22,13 @@ tags:
- a25
- a26
- a27
- a28
- a27-strict-mode
- dec-011-amendment-1
- blob-url-empirical
- latency-5s
- meta-urls-shape
- archive-layout-strict
- operator-checkpoint
- phase-2-closure
- approach-b
@@ -34,29 +38,30 @@ must_haves:
- "A24 verifies the SAVE_ARCHIVE → chrome.downloads.create call site receives a `blob:` URL prefix (NOT `data:application/zip;base64,`)."
- "A25 verifies the SAVE_ARCHIVE → zip-on-disk latency (<5000ms from chrome.runtime.sendMessage({type:'SAVE_ARCHIVE'}) dispatch to file appearing in downloadsDir) per REQ-archive-export-latency."
- "A26 verifies the produced meta.json has the 8-field D-P2-02/D-P2-03 shape: schemaVersion='2', urls is non-empty string[], no `url` key present."
- "A27 verifies tab-URL tracking by activating 2 distinct tabs during the recording window, then SAVE, then inspecting meta.urls — expects both URLs to be present (deduplicated, first-seen-ordered)."
- "A27 STRICT mode (post DEC-011 Amendment 1, 2026-05-20): harness opens TWO tabs in sequence via chrome.tabs.create + activates each via chrome.tabs.update; after SAVE_ARCHIVE, meta.urls is EXACTLY `[url1, url2]` (order-flexible; ≥2 length REQUIRED; NO `[object Object]`, NO extension-origin sentinel, NO chrome:// URLs). Test FAILS on length < 2 OR on any missing URL string."
- "Tier-1 FORBIDDEN_HOOK_STRINGS inventory extended IF new hook surfaces required for A24+; lockstep across tests/background/no-test-hooks-in-prod-bundle.test.ts AND tests/uat/harness.test.ts A0 mirror."
- "Final operator empirical checkpoint validates: (a) saving a real ~6MB archive completes successfully (was: data:URL Network error pre-Plan-02-02); (b) the produced meta.json has the new 8-field shape (operator opens the zip with archive-manager tool); (c) the saved zip opens cleanly in the OS file manager."
- "Pre-checkpoint bundle gates per saved memory `feedback-pre-checkpoint-bundle-gates.md` are run BEFORE operator checkpoint: SW CSP grep (no `new Function`/`eval`), SW Node-globals grep (no `Buffer.from`), DOM-globals grep, SW-bundle-import gate, manifest validation."
- "Phase 2 closes with 4/4 plans landed; 24/24 UAT baseline preserved or extended to 28/28 (A24+A25+A26+A27 inclusive); vitest baseline preserved or extended; operator empirical ack documented."
- "A28 verifies REQ-archive-layout: the downloaded zip contains EXACTLY 5 entries — `video/last_30sec.webm`, `rrweb/session.json`, `logs/events.json`, `screenshot.png`, `meta.json` (no extras, no missing). Uses JSZip parsing in the harness driver against the host-side zip blob. Cross-references REQ-archive-layout + REQ-popup-ui (popup-triggered SAVE) + REQ-screenshot-on-export."
- "Phase 2 closes with 4/4 plans landed; 24/24 UAT baseline preserved or extended to 29/29 (A24+A25+A26+A27+A28 inclusive); vitest baseline preserved or extended; operator empirical ack documented."
artifacts:
- path: "tests/uat/extension-page-harness.ts"
provides: "assertA24 (blob: URL prefix) + assertA25 (5s latency) + assertA26 (meta.urls shape) + assertA27 (multi-tab URL dedup)"
contains: "assertA24|assertA25|assertA26|assertA27"
provides: "assertA24 (blob: URL prefix) + assertA25 (5s latency) + assertA26 (meta.urls shape) + assertA27 (multi-tab URL dedup, STRICT) + assertA28 (REQ-archive-layout strict zip-layout)"
contains: "assertA24|assertA25|assertA26|assertA27|assertA28"
- path: "tests/uat/lib/harness-page-driver.ts"
provides: "driveA24, driveA25, driveA26, driveA27 page.evaluate wrappers"
contains: "driveA24|driveA25|driveA26|driveA27"
provides: "driveA24, driveA25, driveA26, driveA27, driveA28 page.evaluate wrappers"
contains: "driveA24|driveA25|driveA26|driveA27|driveA28"
- path: "tests/uat/harness.test.ts"
provides: "Orchestrator runs A24+A25+A26+A27 after A23; FORBIDDEN_HOOK_STRINGS lockstep if new hook symbols"
contains: "driveA24|driveA25|driveA26|driveA27"
provides: "Orchestrator runs A24+A25+A26+A27+A28 after A23; FORBIDDEN_HOOK_STRINGS lockstep if new hook symbols"
contains: "driveA24|driveA25|driveA26|driveA27|driveA28"
- path: "tests/background/no-test-hooks-in-prod-bundle.test.ts"
provides: "Lockstep FORBIDDEN_HOOK_STRINGS update if new hook symbols introduced"
contains: "FORBIDDEN_HOOK_STRINGS"
key_links:
- from: "tests/uat/harness.test.ts"
to: "tests/uat/lib/harness-page-driver.ts:driveA24..A27"
to: "tests/uat/lib/harness-page-driver.ts:driveA24..A28"
via: "import + sequential orchestrator dispatch after driveA23"
pattern: "driveA24|driveA25|driveA26|driveA27"
pattern: "driveA24|driveA25|driveA26|driveA27|driveA28"
- from: "tests/uat/extension-page-harness.ts:assertA25"
to: "performance.now() bookends around SAVE_ARCHIVE dispatch + host-side downloadsDir poll"
via: "page-side measures dispatch→ack; host-side measures dispatch→file-on-disk; assert total < 5000ms"
@@ -68,9 +73,11 @@ must_haves:
---
<objective>
Wave 3 of Phase 2: extend the UAT harness with A24+A25+A26+A27 assertions covering the D-P2-01 +
D-P2-02 + D-P2-03 contracts end-to-end through a real Chrome instance. Run pre-checkpoint bundle
gates per saved memory before surfacing the final operator empirical checkpoint. Close Phase 2.
Wave 3 of Phase 2: extend the UAT harness with A24+A25+A26+A27+A28 assertions covering the
D-P2-01 + D-P2-02 + D-P2-03 contracts AND REQ-archive-layout end-to-end through a real Chrome
instance. A27 runs in STRICT mode (post DEC-011 Amendment 1) — REQUIRES both multi-tab URLs in
meta.urls; FAILS on length<2. A28 strict-pins the 5-entry zip layout (no extras). Run pre-checkpoint
bundle gates per saved memory before surfacing the final operator empirical checkpoint. Close Phase 2.
Purpose: empirical verification that Plans 02-02 (Blob URL pipeline) and 02-03 (meta.urls schema)
work in a real Chrome instance, not just in unit-test isolation. The harness extension is the
@@ -78,7 +85,9 @@ closure gate for Phase 2 — analogous to Plan 01-13's 15/15 harness PASS that c
functional contract.
Output:
- 4 new harness assertions (A24+A25+A26+A27) wired through page-harness + driver + orchestrator.
- 5 new harness assertions (A24+A25+A26+A27+A28) wired through page-harness + driver + orchestrator.
- A27 in STRICT mode (post DEC-011 Amendment 1) — both URLs REQUIRED; FAILS on length<2.
- A28 pins REQ-archive-layout: exactly 5 zip entries (`video/last_30sec.webm`, `rrweb/session.json`, `logs/events.json`, `screenshot.png`, `meta.json`).
- Tier-1 FORBIDDEN_HOOK_STRINGS lockstep IF new hook symbols required.
- Pre-checkpoint bundle gate validation completed.
- Operator empirical checkpoint documenting Plan 02-02 + 02-03 + the 5s-latency contract on a
@@ -403,7 +412,7 @@ tests/uat/lib/harness-page-driver.ts:driveA5 (line 215) for the merged-checks pa
</task>
<task type="auto" tdd="true">
<name>Task 3: assertA26 + A27 — meta.urls shape + multi-tab dedup empirical (D-P2-02)</name>
<name>Task 3: assertA26 + A27 (strict) + A28 — meta.urls shape + multi-tab strict + REQ-archive-layout (D-P2-02/03 + REQ-archive-layout)</name>
<files>tests/uat/extension-page-harness.ts, tests/uat/lib/harness-page-driver.ts, tests/uat/harness.test.ts</files>
<behavior>
- assertA26 page-side: chain off A25's produced zip (read host-side filename via merged checks).
@@ -413,32 +422,34 @@ tests/uat/lib/harness-page-driver.ts:driveA5 (line 215) for the merged-checks pa
(c) Array.isArray(meta.urls) && meta.urls.length >= 1
(d) meta.url === undefined (the legacy single-URL field is gone)
(e) meta.urls.every(u => /^(https?|chrome-extension):\/\//.test(u))
- assertA27 page-side: requires multi-tab activation BEFORE save. Sequence:
- assertA27 STRICT mode (post DEC-011 Amendment 1) — REQUIRES both multi-tab URLs in meta.urls. Sequence:
(1) setupFreshRecording
(2) Open tab A (e.g., chrome.tabs.create({ url: 'https://example.com' })) and wait for it to load
(3) Activate tab A
(4) Open tab B (e.g., chrome.tabs.create({ url: 'https://www.iana.org' })) and wait
(5) Activate tab B
(2) Open tab A: `chrome.tabs.create({ url: 'https://example.com/', active: false })`; wait 1500ms for navigation
(3) Activate tab A: `chrome.tabs.update(tabA.id, { active: true })`; wait 500ms for onActivated to fire
(4) Open tab B: `chrome.tabs.create({ url: 'https://www.iana.org/', active: false })`; wait 1500ms for navigation
(5) Activate tab B: `chrome.tabs.update(tabB.id, { active: true })`; wait 500ms for onActivated to fire
(6) Wait 11s for one segment to land
(7) Dispatch SAVE_ARCHIVE
(8) Read meta.urls from the produced zip
(9) Assert both example.com AND iana.org appear in meta.urls
(10) Cleanup: close both tabs
- PLANNER-NOTE on tabs permission limitation: A27 depends on chrome.tabs URL access. Per Plan
01-13 SUMMARY Known Limitations item 3, `tabs` permission is NOT declared and `chrome.tabs.query`
may return tabs without `.url`. RESOLUTION: A27 uses chrome.tabs.create + chrome.tabs.update
to drive tab activation directly, bypassing chrome.tabs.query. The tracker's chrome.tabs.onActivated
listener fires regardless of permission — it's the post-event chrome.tabs.get(tabId) inside the
tracker that may return undefined .url. If A27 reveals the gap empirically, surface a diagnostic
in the test result and defer the closure to Phase 4 hardening (CONTEXT.md `<deferred>` tabs
permission gap item). If A27 PASSES on the active-tab path, the limitation is non-blocking
for Phase 2.
- driveA26 + driveA27: standard page.evaluate wrappers (mirror driveA23/A24).
- Orchestrator update: tests/uat/harness.test.ts adds A24, A25, A26, A27 to the assertion
sequence AFTER A23. Total target: 24 (Phase 1 baseline) + 4 (Phase 2) = 28/28 GREEN.
- FORBIDDEN_HOOK_STRINGS lockstep: PLANNER-DECISION — A24/A25/A26/A27 use chrome.* monkey-patch
(A24's downloads spy) + JSZip + chrome.tabs.create/update which are production APIs. No new
test-hook surface required. Tier-1 inventory STAYS at 12. Plan-checker verifies via final grep.
(7) Dispatch SAVE_ARCHIVE; await ack with success===true
(8) Host-side: load latest zip; parse meta.json; read meta.urls
(9) Strict assertions (all REQUIRED to pass):
(a) `meta.urls.length >= 2` (FAIL on length < 2)
(b) `meta.urls` contains BOTH 'https://example.com/' AND 'https://www.iana.org/' (order-flexible; use Set membership)
(c) `meta.urls.every(u => typeof u === 'string' && u.length > 0)` — no `[object Object]`, no nulls, no empty strings
(d) `meta.urls.every(u => !u.startsWith('chrome-extension://'))` — no extension-origin sentinel fallback (F2)
(e) `meta.urls.every(u => !u.startsWith('chrome://'))` — no chrome-internal URLs
(10) Cleanup: try/catch close both tabs via chrome.tabs.remove (silent-ignore on already-closed)
DEC-011 Amendment 1 guarantee: with `tabs` permission granted, chrome.tabs.get(tabId).url
and chrome.tabs.query({}) reliably populate the URL field. snapshotOpenTabs at SAVE time
(Plan 02-03 Task 2) provides a defensive belt + suspenders. A27 has UNAMBIGUOUS contract:
both URLs MUST appear; the test is the binding empirical gate for D-P2-02.
- assertA28 (REQ-archive-layout strict zip-layout): chain off A25's produced zip (reuse merged-checks pattern). Use JSZip.loadAsync to enumerate zip entries; assert:
(a) Zip contains EXACTLY 5 entries (no more, no fewer)
(b) The 5 paths are EXACTLY `video/last_30sec.webm`, `rrweb/session.json`, `logs/events.json`, `screenshot.png`, `meta.json` (set-equality; order-flexible)
(c) NO extra entries (no `__MACOSX/`, no `.DS_Store`, no temp files)
Cross-references REQ-archive-layout + REQ-popup-ui (popup-triggered SAVE flows through the same createArchive path) + REQ-screenshot-on-export (screenshot.png entry verified present).
- driveA26 + driveA27 + driveA28: standard page.evaluate wrappers + host-side zip-lookup (mirror driveA5's merged-checks pattern). driveA27 additionally inspects meta.urls host-side via JSZip; driveA28 inspects the zip directory listing.
- Orchestrator update: tests/uat/harness.test.ts adds A24, A25, A26, A27, A28 to the assertion sequence AFTER A23. Total target: 24 (Phase 1 baseline) + 5 (Phase 2) = 29/29 GREEN.
- FORBIDDEN_HOOK_STRINGS lockstep: A24/A25/A26/A27/A28 use chrome.* monkey-patch (A24's downloads spy) + JSZip + chrome.tabs.create/update which are production APIs. No new test-hook surface required. Tier-1 inventory STAYS at 12. Plan-checker verifies via final grep.
</behavior>
<action>
Add `assertA26` and `assertA27` to tests/uat/extension-page-harness.ts:
@@ -494,20 +505,35 @@ tests/uat/lib/harness-page-driver.ts:driveA5 (line 215) for the merged-checks pa
}
/**
* A27 — D-P2-02 empirical: multi-tab URL tracking. Activates two distinct
* URLs during the recording window; meta.urls should contain both.
* A27 — STRICT mode (post DEC-011 Amendment 1): multi-tab URL tracking.
* Opens TWO tabs sequentially, activates each, then dispatches SAVE.
* Host-side driver asserts meta.urls contains BOTH URLs (length >= 2 REQUIRED;
* FAILS on length < 2; no extension-origin sentinel; no chrome-internal URLs).
*/
async function assertA27(): Promise<AssertionResult> {
async function assertA27(): Promise<AssertionResult & { tabAUrl: string; tabBUrl: string }> {
const checks: CheckRecord[] = [];
const diagnostics: string[] = [];
await setupFreshRecording();
// Open + activate 2 distinct tabs.
const tabA = await chrome.tabs.create({ url: 'https://example.com', active: true });
const TAB_A_URL = 'https://example.com/';
const TAB_B_URL = 'https://www.iana.org/';
// Open + activate tab A.
const tabA = await chrome.tabs.create({ url: TAB_A_URL, active: false });
await new Promise(r => setTimeout(r, 1500));
const tabB = await chrome.tabs.create({ url: 'https://www.iana.org', active: true });
if (tabA.id !== undefined) {
await chrome.tabs.update(tabA.id, { active: true });
await new Promise(r => setTimeout(r, 500));
}
// Open + activate tab B.
const tabB = await chrome.tabs.create({ url: TAB_B_URL, active: false });
await new Promise(r => setTimeout(r, 1500));
if (tabB.id !== undefined) {
await chrome.tabs.update(tabB.id, { active: true });
await new Promise(r => setTimeout(r, 500));
}
// Settle for one segment.
await new Promise(r => setTimeout(r, 11_000));
@@ -517,16 +543,177 @@ tests/uat/lib/harness-page-driver.ts:driveA5 (line 215) for the merged-checks pa
chrome.runtime.sendMessage({ type: 'SAVE_ARCHIVE' }, resolve);
});
checks.push({
name: 'A27.1: SAVE_ARCHIVE ack received',
name: 'A27.1: SAVE_ARCHIVE ack received with success=true',
expected: true, actual: ack.success, passed: ack.success === true,
});
// Cleanup tabs.
if (tabA.id) await chrome.tabs.remove(tabA.id);
if (tabB.id) await chrome.tabs.remove(tabB.id);
// Cleanup tabs (silent-ignore on already-closed).
try { if (tabA.id !== undefined) await chrome.tabs.remove(tabA.id); } catch {}
try { if (tabB.id !== undefined) await chrome.tabs.remove(tabB.id); } catch {}
diagnostics.push(`opened tabA=${tabA.id} tabB=${tabB.id}; meta.urls inspection deferred to host-side`);
return { name: 'A27 — multi-tab URL dedup (D-P2-02)', checks, diagnostics };
diagnostics.push(`opened tabA.id=${tabA.id} url=${TAB_A_URL}; tabB.id=${tabB.id} url=${TAB_B_URL}`);
return {
name: 'A27 — D-P2-02 STRICT multi-tab urls[] (both URLs REQUIRED)',
checks, diagnostics,
tabAUrl: TAB_A_URL,
tabBUrl: TAB_B_URL,
};
}
/**
* A28 — REQ-archive-layout strict zip-layout: zip contains EXACTLY the 5
* canonical entries and no extras. Chains off A25's produced zip via the
* host-side driver's merged-checks pattern (no new SAVE dispatch).
*
* NOTE: page-side assertA28 is a STUB returning the assertion name; all
* real work happens host-side in driveA28 (zip directory enumeration).
*/
async function assertA28(): Promise<AssertionResult> {
return {
name: 'A28 — REQ-archive-layout strict zip-layout (5 entries)',
checks: [],
diagnostics: ['assertA28 page-side stub; host-side driver inspects zip directory'],
};
}
```
Host-side driveA27 (merged-checks pattern with strict meta.urls assertions):
```typescript
export async function driveA27(page: Page, downloadsDir: string): Promise<AssertionRecord> {
const preExisting = new Set(readdirSync(downloadsDir).filter(isZipFilename));
const pageResult = await page.evaluate(async () => {
const harness = (window as any).__mokoshHarness;
return await harness.assertA27();
}) as AssertionRecord & { tabAUrl: string; tabBUrl: string };
// Locate the new zip produced by A27.
let zipPath: string | null = null;
const pollStart = Date.now();
while (Date.now() - pollStart < 8000) {
const candidates = readdirSync(downloadsDir).filter(
(name) => isZipFilename(name) && !preExisting.has(name),
);
if (candidates.length > 0) {
zipPath = `${downloadsDir}/${candidates[candidates.length - 1]}`;
break;
}
await new Promise(r => setTimeout(r, 100));
}
const mergedChecks: CheckRecord[] = pageResult.checks.slice();
const mergedDiagnostics = pageResult.diagnostics.slice();
if (zipPath === null) {
mergedChecks.push({
name: 'A27.2: zip emitted to downloadsDir',
expected: 'zip file present', actual: 'no zip found within 8s', passed: false,
});
} else {
const zipBytes = readFileSync(zipPath);
const zip = await JSZip.loadAsync(zipBytes);
const metaFile = zip.file('meta.json');
const metaText = metaFile !== null ? await metaFile.async('string') : '{}';
let meta: { urls?: unknown } = {};
try { meta = JSON.parse(metaText); } catch {}
const urlsRaw = (meta as { urls?: unknown }).urls;
const urls: string[] = Array.isArray(urlsRaw) ? urlsRaw.filter((u): u is string => typeof u === 'string') : [];
mergedChecks.push({
name: 'A27.2: meta.urls is an Array',
expected: true, actual: Array.isArray(urlsRaw), passed: Array.isArray(urlsRaw),
});
mergedChecks.push({
name: 'A27.3: meta.urls.length >= 2 (STRICT — both URLs REQUIRED post DEC-011 Amendment 1)',
expected: '>=2', actual: urls.length, passed: urls.length >= 2,
});
mergedChecks.push({
name: `A27.4: meta.urls contains ${pageResult.tabAUrl}`,
expected: true, actual: urls.includes(pageResult.tabAUrl), passed: urls.includes(pageResult.tabAUrl),
});
mergedChecks.push({
name: `A27.5: meta.urls contains ${pageResult.tabBUrl}`,
expected: true, actual: urls.includes(pageResult.tabBUrl), passed: urls.includes(pageResult.tabBUrl),
});
mergedChecks.push({
name: 'A27.6: every meta.urls[i] is a non-empty string (no [object Object], no nulls)',
expected: true,
actual: urls.every(u => typeof u === 'string' && u.length > 0),
passed: urls.length > 0 && urls.every(u => typeof u === 'string' && u.length > 0),
});
mergedChecks.push({
name: 'A27.7: no extension-origin sentinel URLs (F2 — empty-tracker fallback removed)',
expected: true,
actual: urls.every(u => !u.startsWith('chrome-extension://')),
passed: urls.every(u => !u.startsWith('chrome-extension://')),
});
mergedChecks.push({
name: 'A27.8: no chrome-internal URLs in meta.urls (filter rules)',
expected: true,
actual: urls.every(u => !u.startsWith('chrome://') && !u.startsWith('about:')),
passed: urls.every(u => !u.startsWith('chrome://') && !u.startsWith('about:')),
});
mergedDiagnostics.push(`zipPath=${zipPath}; meta.urls=${JSON.stringify(urls)}`);
}
return {
passed: mergedChecks.every(c => c.passed),
name: pageResult.name,
checks: mergedChecks,
diagnostics: mergedDiagnostics,
error: pageResult.error,
};
}
export async function driveA28(page: Page, downloadsDir: string): Promise<AssertionRecord> {
// Reuse A25's most-recent zip (A28 does NOT trigger a new SAVE — chains off A25).
const zipFiles = readdirSync(downloadsDir).filter(isZipFilename).sort();
const checks: CheckRecord[] = [];
const diagnostics: string[] = [];
if (zipFiles.length === 0) {
checks.push({
name: 'A28.1: at least one zip present in downloadsDir',
expected: '>=1', actual: 0, passed: false,
});
return { passed: false, name: 'A28 — REQ-archive-layout strict (5 entries)', checks, diagnostics };
}
const latest = `${downloadsDir}/${zipFiles[zipFiles.length - 1]}`;
const zipBytes = readFileSync(latest);
const zip = await JSZip.loadAsync(zipBytes);
const EXPECTED_PATHS: ReadonlyArray<string> = [
'video/last_30sec.webm',
'rrweb/session.json',
'logs/events.json',
'screenshot.png',
'meta.json',
];
const actualPaths = Object.keys(zip.files).filter(p => !zip.files[p].dir).sort();
const expectedSorted = [...EXPECTED_PATHS].sort();
checks.push({
name: 'A28.1: zip has EXACTLY 5 entries (REQ-archive-layout)',
expected: 5, actual: actualPaths.length, passed: actualPaths.length === 5,
});
checks.push({
name: 'A28.2: zip entries set-equal to the canonical 5 paths',
expected: expectedSorted.join(','),
actual: actualPaths.join(','),
passed: JSON.stringify(actualPaths) === JSON.stringify(expectedSorted),
});
checks.push({
name: 'A28.3: no extras (no __MACOSX/, no .DS_Store, no temp files)',
expected: 'no extras',
actual: actualPaths.filter(p => !EXPECTED_PATHS.includes(p)).join(',') || 'none',
passed: actualPaths.every(p => EXPECTED_PATHS.includes(p)),
});
diagnostics.push(`zipPath=${latest}; entries=${actualPaths.join(',')}`);
return {
passed: checks.every(c => c.passed),
name: 'A28 — REQ-archive-layout strict (5 entries; D-P2-02/03 + REQ-popup-ui + REQ-screenshot-on-export)',
checks, diagnostics,
};
}
```
@@ -544,25 +731,29 @@ tests/uat/lib/harness-page-driver.ts:driveA5 (line 215) for the merged-checks pa
drivers.push({ name: 'A25', fn: () => driveA25(page, handles.downloadsDir) });
drivers.push({ name: 'A26', fn: () => driveA26(page, handles.downloadsDir) });
drivers.push({ name: 'A27', fn: () => driveA27(page, handles.downloadsDir) });
drivers.push({ name: 'A28', fn: () => driveA28(page, handles.downloadsDir) });
```
Final target: 28/28 GREEN.
Final target: 29/29 GREEN.
FORBIDDEN_HOOK_STRINGS verification: grep the new test files for any new symbols that might
leak into dist/. Expected: none (all symbols are local to extension-page-harness.ts which is
NOT built into dist/ — it's loaded via the test-harness page, not the production manifest).
Tier-1 inventory stays at 12.
Per D-P2-02 + D-P2-03 + REQ-archive-export-latency: A24+A25+A26+A27 collectively validate the
full Phase 2 contract empirically. After A27, Phase 2 functional contract is HARNESS-CLOSED
(analogous to Plan 01-13's role for Phase 1).
Per D-P2-02 + D-P2-03 + REQ-archive-export-latency + REQ-archive-layout: A24+A25+A26+A27+A28
collectively validate the full Phase 2 contract empirically. A27 in STRICT mode is the binding
empirical gate for D-P2-02 (post DEC-011 Amendment 1). A28 pins the canonical 5-entry zip layout.
After A28, Phase 2 functional contract is HARNESS-CLOSED (analogous to Plan 01-13's role for Phase 1).
</action>
<verify>
<automated>HEADLESS=1 npm run test:uat 2>&1 | grep -E "(A26|A27|FAIL|PASS|28/28)" | tail -30</automated>
<automated>HEADLESS=1 npm run test:uat 2>&1 | grep -E "(A26|A27|A28|FAIL|PASS|29/29)" | tail -30</automated>
</verify>
<done>
A26 + A27 page-side + drivers wired. Orchestrator runs 28 assertions sequentially. ALL 28 GREEN.
A26 + A27 (strict) + A28 page-side + drivers wired. Orchestrator runs 29 assertions sequentially.
ALL 29 GREEN. A27 length>=2 strict check passes (both example.com + iana.org appear in meta.urls
per DEC-011 Amendment 1). A28 zip-layout 5-entry strict check passes (REQ-archive-layout).
FORBIDDEN_HOOK_STRINGS unchanged at 12. Atomic commit:
`feat(02-04): harness A26+A27 — empirical meta.json 8-field + multi-tab urls[] verification (D-P2-02/03)`.
`feat(02-04): harness A26+A27(strict)+A28 — empirical meta.json 8-field + multi-tab urls[] STRICT + REQ-archive-layout (D-P2-02/03 + DEC-011 Amendment 1)`.
</done>
</task>
@@ -609,12 +800,13 @@ tests/uat/lib/harness-page-driver.ts:driveA5 (line 215) for the merged-checks pa
└── meta.json
```
Step 2.6 — Open meta.json. Verify:
Step 2.6 — Open meta.json. Verify (STRICT, post DEC-011 Amendment 1):
(a) `schemaVersion: "2"` present
(b) `urls` field is an ARRAY (not a string)
(c) `urls` contains at least `https://example.com/` AND `https://www.iana.org/`
(c) `urls` contains BOTH `https://example.com/` AND `https://www.iana.org/` (length >= 2 REQUIRED)
(d) NO `url` field present (just `urls`)
(e) Exactly 8 keys total
(f) NO `chrome-extension://...` URLs in `urls` (F2 — empty-tracker fallback removed)
Step 2.7 — Open video/last_30sec.webm in a browser (drag into a Chrome tab).
Expected: ~30 seconds of video plays end-to-end.
@@ -652,7 +844,7 @@ tests/uat/lib/harness-page-driver.ts:driveA5 (line 215) for the merged-checks pa
Phase 2 implementation complete:
- Plan 02-02: Offscreen-minted Blob URL pipeline (D-P2-01, closes P0-6).
- Plan 02-03: meta.json urls[] + schemaVersion + tab-url-tracker (D-P2-02, closes P1 #10).
- Plan 02-04 Tasks 1-3: UAT harness A24+A25+A26+A27 GREEN (28/28).
- Plan 02-04 Tasks 1-3: UAT harness A24+A25+A26+A27(STRICT)+A28 GREEN (29/29). A27 strict-mode unblocked by DEC-011 Amendment 1; A28 pins REQ-archive-layout 5-entry zip.
Acceptance baselines preserved: vitest GREEN, UAT GREEN, Tier-1 FORBIDDEN_HOOK_STRINGS = 12,
production bundle hook-free.
</what-built>
@@ -681,7 +873,7 @@ tests/uat/lib/harness-page-driver.ts:driveA5 (line 215) for the merged-checks pa
| T-02-04-02 | Repudiation | A25 latency measurement skewed by harness startup overhead | accept | Measurement bookends bracket ONLY the SAVE_ARCHIVE dispatch → ack, NOT the broader test orchestration. setupFreshRecording + segment-settle happen BEFORE the t0 mark. |
| T-02-04-03 | Information Disclosure | A27 creates real tabs with example.com / iana.org which appear in real Downloads zips | accept | Per "log is internal" charter (CONTEXT.md re-phasing context); test tabs are public sites with no PII; downloadsDir is a per-run mkdtempSync, cleaned up by test runner. |
| T-02-04-04 | Denial of Service | A27 leaks tabs if cleanup fails (chrome.tabs.remove throws on already-closed tabs) | mitigate | A27's cleanup is wrapped in try/catch (silent-ignore — closing an already-closed tab is benign). |
| T-02-04-05 | Elevation of Privilege | New harness assertions reveal previously-uncovered chrome.* APIs to the test surface | accept | Approach B (Plan 01-13 SUMMARY): the harness page is a privileged extension-internal page that already has full chrome.* access. A24+A27 use chrome.downloads + chrome.tabs which are part of the existing extension capability set per manifest.json. No new manifest permissions in this plan. |
| T-02-04-05 | Elevation of Privilege | New harness assertions reveal previously-uncovered chrome.* APIs to the test surface | accept | Approach B (Plan 01-13 SUMMARY): the harness page is a privileged extension-internal page that already has full chrome.* access. A24+A27 use chrome.downloads + chrome.tabs which are part of the extension capability set per manifest.json. NOTE: Plan 02-03 (preceding wave) added `tabs` permission via DEC-011 Amendment 1; A27 in STRICT mode consumes that capability. The amendment is the LOCKED scope addition; no further manifest deltas in this plan. |
</threat_model>
<verification>
@@ -696,26 +888,30 @@ tests/uat/lib/harness-page-driver.ts:driveA5 (line 215) for the merged-checks pa
- `tests/build/fonts-present.test.ts` → GREEN.
- `tests/i18n/manifest-i18n.test.ts` + `tests/i18n/locale-parity.test.ts` → GREEN.
- `npm test` (full suite) → GREEN.
- `HEADLESS=1 npm run test:uat` → 28/28 GREEN.
- `HEADLESS=1 npm run test:uat` → 29/29 GREEN (A0-A23 + A24+A25+A26+A27 strict + A28).
- Operator empirical Task 4 Step 2 → "approved" or surfaces deviations to drive Plan 02-05 follow-up.
</verification>
<success_criteria>
1. UAT harness 28/28 GREEN (A0-A23 Phase 1 baseline + A24+A25+A26+A27 Phase 2 extension).
2. Vitest full suite GREEN (Phase 1 baseline + Plan 02-01 RED tests now all GREEN post-Plans 02-02 + 02-03).
3. Tier-1 FORBIDDEN_HOOK_STRINGS = 12 (unchanged — A24+A27 use chrome.* monkey-patch + production APIs).
4. Pre-checkpoint bundle gates PASS per `feedback-pre-checkpoint-bundle-gates.md`.
5. Operator empirical UAT cycle 1 ack "approved" OR documented deviations.
6. Phase 2 closure marker flippable: REQUIREMENTS.md + STATE.md + ROADMAP.md (this last is orchestrator's territory post-checkpoint).
1. UAT harness 29/29 GREEN (A0-A23 Phase 1 baseline + A24+A25+A26+A27 strict + A28 Phase 2 extension).
2. A27 STRICT mode: meta.urls contains both example.com AND iana.org (length >= 2 REQUIRED); no extension-origin sentinels (F2).
3. A28 STRICT mode: zip contains exactly the 5 canonical entries; no extras (REQ-archive-layout).
4. Vitest full suite GREEN (Phase 1 baseline + Plan 02-01 RED tests now all GREEN post-Plans 02-02 + 02-03 + Plan 02-03 Task 0 manifest i18n test additions).
5. Tier-1 FORBIDDEN_HOOK_STRINGS = 12 (unchanged — A24+A27+A28 use chrome.* monkey-patch + production APIs).
6. Pre-checkpoint bundle gates PASS per `feedback-pre-checkpoint-bundle-gates.md`.
7. Operator empirical UAT cycle 1 ack "approved" OR documented deviations.
8. Phase 2 closure marker flippable: REQUIREMENTS.md + STATE.md + ROADMAP.md (this last is orchestrator's territory post-checkpoint).
</success_criteria>
<output>
After completion, create `.planning/phases/02-stabilize-export-pipeline/02-04-SUMMARY.md`
documenting:
- 4 new harness assertions (A24+A25+A26+A27) with their check-counts and rationale.
- 5 new harness assertions (A24+A25+A26+A27 strict + A28) with their check-counts and rationale.
- A27 strict-mode rationale (DEC-011 Amendment 1 unblocks both-URLs-required contract).
- A28 REQ-archive-layout strict zip-layout pin (5 entries; cross-references REQ-popup-ui + REQ-screenshot-on-export).
- Tier-1 FORBIDDEN_HOOK_STRINGS unchanged at 12 (architectural rationale: chrome.* spy / production APIs vs new test-hook symbols).
- Pre-checkpoint bundle gate run record (each gate result inline).
- Operator empirical ack quote (verbatim) OR list of deviations + follow-up plan-pointer.
- Phase 2 closure summary: 4/4 plans landed; vitest + UAT GREEN; P0-6 + P1 #10 closed; meta.json 8-field schema shipped.
- Phase 2 closure summary: 4/4 plans landed; vitest + UAT GREEN; P0-6 + P1 #10 closed; meta.json 8-field schema shipped; DEC-011 Amendment 1 landed.
- Forward link: Phase 3 (SPEC §10 smoke + DOM/event-log verification) inherits the harness as its closure template (mirrors Plan 01-13's role for Phase 2).
</output>

View File

@@ -136,7 +136,42 @@ None — discussion stayed within phase scope.
</deferred>
## Revision Log
### 2026-05-20 — DEC-011 Amendment 1 + plan-checker iteration 1
- **DEC-011 Amendment 1 (Phase 2 scope addition):** `tabs` permission ADDED to manifest.json
per user direction during plan-checker iteration 1. Justification: Phase 2's D-P2-02
meta.urls feature REQUIRES tab URL visibility beyond active-tab semantics. Audit T-1-02
("declaring unused permissions expands attack surface") is acknowledged but overridden for
this Phase 2 feature; the meta.urls feature is now genuinely USED, so the permission is not
unused. tests/i18n/manifest-i18n.test.ts pins the new 8-entry permission set as a
regression guard. PROJECT.md DEC-011 row rewritten with Amendment 1 prose.
- **Plan 02-04 A27 strict-mode:** harness MUST observe BOTH tab URLs in meta.urls after a
multi-tab session. meta.urls.length >= 2 REQUIRED; test FAILS on length < 2. No
extension-origin sentinels permitted. Empty-tracker case (no browser interaction during
recording) still produces urls:[] per F2 resolution — but A27 explicitly EXERCISES the
multi-tab path so its meta.urls is NEVER empty.
- **createArchive empty-tracker fallback removed (F2):** tracker.getTabUrlsSeen() returning
empty array is meaningful (whole-desktop-no-tab session) and meta.urls: [] is the canonical
representation. tests/background/meta-json-urls-schema.test.ts adds Test 5 pinning this
contract; tests/build/strict-meta-json-validation.test.ts Test 3 relaxed to PERMIT empty
urls[] (still validates URL format on non-empty arrays). createArchive calls a new
snapshotOpenTabs() helper (chrome.tabs.query({}) defensive enumeration via DEC-011
Amendment 1) BEFORE reading getTabUrlsSeen() so any tab the operator opened but never
activated is still captured. Empty array IS the result when no tabs are open at SAVE time.
- **Plan 02-04 A28 added (F1):** REQ-archive-layout strict zip-layout pin. Harness driver
enumerates zip entries and asserts EXACTLY 5 paths (`video/last_30sec.webm`,
`rrweb/session.json`, `logs/events.json`, `screenshot.png`, `meta.json`). Cross-references
REQ-archive-layout + REQ-popup-ui + REQ-screenshot-on-export. UAT target: 28→29 GREEN.
- **Plan 02-02 Task 3 F3 resolved:** OR-deliberation in createArchive prose struck. The
resolved-only contract is: throw typed `blob-url-mint-failed` error on empty/timeout
response from offscreen; NO data: URL fallback for any archive size.
- **Plan 02-02 Task 3 F4 resolved:** verify block extended with full-suite `npm test` after
the focused-test runs so unrelated regressions surface during execution.
---
*Phase: 2-stabilize-export-pipeline*
*Context gathered: 2026-05-20*
*Revised: 2026-05-20 (plan-checker iteration 1)*

View File

@@ -7,6 +7,7 @@
"permissions": [
"desktopCapture",
"activeTab",
"tabs",
"downloads",
"scripting",
"storage",

View File

@@ -12,10 +12,17 @@
// after Wave-3 Task 1 landed the manifest + messages.json files. This
// file now pins the post-D-07 state as a regression guard.
//
// 2026-05-20 Plan 02-03 amendment (DEC-011 Amendment 1): manifest.json
// permissions array extended to include `"tabs"` per Phase 2 D-P2-02
// (meta.urls multi-tab visibility). The permission-set describe block
// below pins the post-amendment 8-entry set as a regression guard.
//
// References:
// - RESEARCH §10 + §11 (Chrome i18n schema; __MSG_* placeholder rules)
// - brand-decisions-v1.md D-07 override (`Mokosh — Session Capture`)
// - brand-decisions-v1.md D-08 tagline (`Thirty seconds ago, always at hand.`)
// - .planning/PROJECT.md DEC-011 Amendment 1 (`tabs` permission, 2026-05-20)
// - .planning/phases/02-stabilize-export-pipeline/02-CONTEXT.md D-P2-02
import { describe, expect, it } from 'vitest';
import { existsSync, readFileSync } from 'node:fs';
@@ -89,3 +96,35 @@ describe('Plan 01-12: _locales/ru/messages.json (primary operator locale)', () =
expect(ru.extDesc?.message).toBe('Тридцать секунд назад, всегда под рукой.');
});
});
describe('Plan 02-03 DEC-011 Amendment 1: manifest.json permissions include "tabs" (D-P2-02)', () => {
// Locked set per DEC-011 Amendment 1 (2026-05-20). `tabs` added to enable
// chrome.tabs.get(tabId).url + chrome.tabs.query for the tab-url-tracker
// (D-P2-02 meta.urls feature). Any future permission delta requires a
// formal DEC-011 amendment update — DO NOT edit this list without one.
const EXPECTED_PERMISSIONS: ReadonlyArray<string> = [
'desktopCapture',
'activeTab',
'tabs',
'downloads',
'scripting',
'storage',
'offscreen',
'notifications',
];
it('manifest.json:permissions includes "tabs" (DEC-011 Amendment 1)', () => {
const manifest = JSON.parse(readFileSync(MANIFEST_PATH, 'utf8'));
expect(Array.isArray(manifest.permissions)).toBe(true);
expect(manifest.permissions).toContain('tabs');
});
it('manifest.json:permissions has exactly the 8 entries in DEC-011 Amendment 1', () => {
const manifest = JSON.parse(readFileSync(MANIFEST_PATH, 'utf8'));
expect(Array.isArray(manifest.permissions)).toBe(true);
const actual = [...manifest.permissions].sort();
const expected = [...EXPECTED_PERMISSIONS].sort();
expect(actual).toEqual(expected);
expect(manifest.permissions.length).toBe(EXPECTED_PERMISSIONS.length);
});
});