Executor instructions: Follow this plan step by step. Run every verification command and confirm the expected result before moving to the next step. If anything in the “STOP conditions” section occurs, stop and report — do not improvise. When done, update the status row for this plan in
plans/README.md— unless a reviewer dispatched you and told you they maintain the index.Drift check (run first):
git diff --stat a6293e1..HEAD -- src/shared/settings.ts src/sidepanel.ts src/background.ts tests/settings.test.tsAny drift in the excerpts below is a STOP condition.
a6293e1, 2026-07-17The side panel’s “Custom domains” form accepts any string. Input like
example.com/chat, https://example.com, foo bar, or an empty-after-trim
value flows into chrome.permissions.request({ origins: ["*://<input>/*"] })
and later chrome.scripting.registerContentScripts({ matches: [...] }). An
invalid match pattern makes those APIs throw; the rejections are
unhandled (void-ed async listeners), the UI shows nothing, and — worse — a
bad entry persisted to chrome.storage.sync makes registerCustomDomainScripts
throw on every subsequent startup, silently breaking content-script
registration for ALL custom domains (the whole registration is one call with
one matches array). One malformed entry disables protection on every
custom site.
src/shared/settings.ts:19-21:
export function normalizeHost(host: string): string {
return host.trim().toLowerCase().replace(/^\*\./, "");
}
No validation exists anywhere (grep -n "isValidHost\|validateHost" src/ → nothing).
src/sidepanel.ts:139-151 — the submit handler: normalize → (no check
beyond falsy) → chrome.permissions.request({ origins: ["*://${host}/*"] })
→ persist to customDomains.src/background.ts:200-220 — registerCustomDomainScripts(): maps every
stored domain into a match pattern and registers them all in ONE
registerContentScripts call; one invalid pattern rejects the whole call
(the .catch(() => undefined) on line 202 covers only the unregister).*. wildcard label is
handled by normalizeHost (stripped) before pattern construction.render("Some message")
re-renders with the message in the header status span — e.g.
await render("Permission denied") on line 147. Match it.tests/settings.test.ts — plain unit tests over settings.ts
exports; model new tests on it.| Purpose | Command | Expected on success |
|---|---|---|
| Typecheck | npx tsc -p tsconfig.json --noEmit |
exit 0 |
| Tests | npm test |
all pass |
| One file | npx vitest run tests/settings.test.ts |
all pass |
In scope:
src/shared/settings.ts (add isValidCustomHost)src/sidepanel.ts (reject invalid input with status feedback)src/background.ts (filter invalid stored entries defensively; log instead
of crash)tests/settings.test.ts (new cases)Out of scope:
isSiteEnabled semantics, permission removal
flow.advisor/009-domain-validationfix(settings): validate custom domains before permission and registrationIn src/shared/settings.ts, below normalizeHost:
const HOST_PATTERN = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z][a-z0-9-]{0,61}[a-z0-9]?$/;
/** Accepts a bare registrable hostname (post-normalizeHost): no scheme, path,
* port, spaces, or IP-literal brackets. Rejects single-label hosts ("localhost"). */
export function isValidCustomHost(host: string): boolean {
return host.length > 0 && host.length <= 253 && HOST_PATTERN.test(host);
}
Verify: npx tsc -p tsconfig.json --noEmit → exit 0.
In src/sidepanel.ts, in the #custom-form submit handler, after
normalizeHost:
const host = normalizeHost(input?.value ?? "");
if (!isValidCustomHost(host)) {
await render("Invalid domain — use a bare hostname like example.com");
return;
}
(Import isValidCustomHost alongside the existing normalizeHost import.)
Verify: npx tsc -p tsconfig.json --noEmit → exit 0.
In src/background.ts, registerCustomDomainScripts:
const hosts = settings.customDomains.map(normalizeHost).filter(isValidCustomHost);
and build matches from hosts; return early when empty (preserving the
existing unregister-first behavior).registerContentScripts call so a rejection is logged, not
unhandled: .catch((error: unknown) => { console.warn("[PromptWard] custom domain registration failed", error); })
— matching the repo’s existing console.debug("[PromptWard]", ...) tag style.This guarantees a legacy bad entry already in chrome.storage.sync (written
before this fix) can no longer break registration for valid ones… note the
filter accomplishes that; the catch is belt-and-braces.
Verify: npx tsc -p tsconfig.json --noEmit → exit 0.
In tests/settings.test.ts, add a describe("isValidCustomHost") block:
Accept: "example.com", "ai.example.co.uk", "my-app.example.io",
"chat.example123.com".
Reject: "", "localhost" (single label), "example.com/chat",
"https://example.com", "foo bar.com", "example..com", "-bad.example.com",
"example.com:8080", a 260-char host, "例え.jp" (unicode — see scope note).
Also one integration-shaped case: normalizeHost("*.Example.COM ") feeding
isValidCustomHost → true (the wildcard-strip + validate pipeline used by
the side panel).
Verify: npx vitest run tests/settings.test.ts → all pass, ~12 new assertions.
Verify: npm test → exit 0.
Step 4; pattern tests/settings.test.ts. Side-panel and background wiring
are thin call sites verified by typecheck (no existing harness imports
sidepanel.ts/background.ts; do not build one for this plan).
npx tsc -p tsconfig.json --noEmit exits 0npm test exits 0 with the new validator tests passinggrep -n "isValidCustomHost" src/shared/settings.ts src/sidepanel.ts src/background.ts → present in all threegit status shows only in-scope files + plans/README.mdplans/README.md status row updatedxn-- form today), and per-entry registration IDs so one bad
future pattern can’t affect others (the filter makes this moot for now).