From 466f019978ccf7096a419d76316fadc7d814ba56 Mon Sep 17 00:00:00 2001 From: Kaushik Gnanaskandan Date: Mon, 26 Oct 2026 21:12:50 -0700 Subject: [PATCH 1/6] docs: plan reliable stateful issue handoff fixes --- .../plans/2026-10-26-stateful-handoff.md | 80 +++++++++++++++++++ .../2026-10-26-stateful-handoff-design.md | 78 ++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-26-stateful-handoff.md create mode 100644 docs/superpowers/specs/2026-10-26-stateful-handoff-design.md diff --git a/docs/superpowers/plans/2026-10-26-stateful-handoff.md b/docs/superpowers/plans/2026-10-26-stateful-handoff.md new file mode 100644 index 0000000..5d7fbc5 --- /dev/null +++ b/docs/superpowers/plans/2026-10-26-stateful-handoff.md @@ -0,0 +1,80 @@ +# Stateful handoff implementation plan + +> **For agentic workers:** Use superpowers:executing-plans; implement sequentially +> in the isolated worktree, with independent review before delivery. + +**Goal:** Fix the three observed installation/context/replay gaps. +**Architecture:** Plugin-owned dependency initialization; optional source-aware +adapter capture; explicit dialog context guards in portable target identity. +**Tech stack:** Existing TypeScript/Nx/Vitest, Vite, Vue/React, browser consumers. +**Spec:** `docs/superpowers/specs/2026-10-26-stateful-handoff-design.md`. + +## Global constraints + +Preserve primary checkouts/lockfiles. Schema-first public types. No global Zod +alias or arbitrary state fingerprint. Bounded/redacted capture. Legacy locator +requests cannot bypass new guards. No publish/deploy/credential changes. + +## Review focus + +- Isolated pnpm dependency resolution and overlay=false must both initialize. +- Same-basename components and metadata missing in supported frameworks must + not masquerade as a source match. +- DOM replacement during async source lookup must refuse stale capture. +- Legacy/nested/redacted dialog locators must not act on the wrong record. +- Context keys stay explicit; regular row identity and production builds survive. + +### Task 1 — Mixed-schema Vite initialization + +Files: Vite plugins in transform/react/vue and focused browser consumer regression +under scripts/fixtures; affected plugin specs only where a contract needs it. + +- [ ] Reproduce normal app Zod3 + local-artifact Domscribe4 browser failure with + no optimizer override; assert schema works and runtime/relay connect. +- [ ] Establish clean affected baseline through configured Nx tasks. +- [ ] Fix dependency ownership/pre-optimization in plugins using actual installed + resolution, retaining user settings and disabled-overlay behavior. +- [ ] Verify npm/pnpm and Vite boundaries; production stripping. Commit slice. + +### Task 2 — Source-owning component capture + +Files: core runtime-context schema, runtime adapter/capture/bridge, Vue adapter, +relay/overlay capture consumers and their regression specs. + +Interface: optional adapter `resolveSourceComponent(element, sourceFile)` returns +an instance and proven source file; capture options accept requested source file; +runtime context metadata labels matched/nearest/unavailable ownership. + +- [ ] Add failing tests for owner props through wrappers, exact path vs basename, + metadata unavailable, cycles and existing privacy/capture options. +- [ ] Implement bounded Vue parent resolution and core/runtime metadata. +- [ ] Resolve manifest before selected-instance capture through overlay/relay; + revalidate the instance after the async lookup. Add failing race coverage. +- [ ] Real wrapped Vue/Hoppscotch owner capture; representative React compatibility, + fresh-session source context, redaction and production stripping. Commit slice. + +### Task 3 — Dialog context guards + +Files: core target schema/replay validation, runtime target registry/specs, +consumer fixtures and API documentation. + +Interface: `data-ds-context` is an explicit non-secret locator attribute; +`unsupportedReason` also accepts `context_required`. + +- [ ] Failing wrong-record, legacy, retained-node, nested-dialog, empty/redacted + context and existing-row regression cases. +- [ ] Enforce context coverage when capturing AND resolving portable locators; + preserve raw overflow checks and local inspection behavior. +- [ ] Verify unmodified Hoppscotch refuses portable dialog replay; explicit-key + fixture replays only the intended record and preserves neighbors. Document + main-only API behavior and required context integration. Commit slice. + +### Task 4 — Review and delivery + +- [ ] Run all affected configured checks and uncached broader unit suite, fresh + browser/npm/pnpm validation and original Hoppscotch flows without overrides. +- [ ] Independent review; fix material findings with regression tests. +- [ ] Push focused PR(s), attach them, follow latest-head CI and merge with + standing admin-bypass authority only after required checks pass. +- [ ] Update continuity ledger with commit/commands/results/limitations and next + meaningful experiment; no commercial advantage claims from setup fixes. diff --git a/docs/superpowers/specs/2026-10-26-stateful-handoff-design.md b/docs/superpowers/specs/2026-10-26-stateful-handoff-design.md new file mode 100644 index 0000000..1b2cb5c --- /dev/null +++ b/docs/superpowers/specs/2026-10-26-stateful-handoff-design.md @@ -0,0 +1,78 @@ +# Reliable stateful issue handoff + +The user authorized fixing the three gaps demonstrated by the Hoppscotch +experiment. Product changes start from remote main b25ded2 in a fresh worktree, +not the research branch. Existing primary checkouts and benchmark patches stay +unchanged. Implementation, review, PR creation and merge after latest-head CI +are authorized; publishing and production deployment are separate. + +## Outcomes + +1. Vue/React Vite consumers using Zod3 alongside Domscribe's Zod4 initialize with + their normal plugin configuration. Domscribe's injected runtime/overlay graph + must retain its own schema dependency and a shared runtime singleton, including + isolated pnpm installs. No global Zod alias/dedupe, user optimizer override, + consumer lockfile rewrite, or production instrumentation. +2. A selected Vue control rendered through shared UI wrappers captures the + component belonging to its manifest source, rather than silently treating + generic anchor props as application context. Runtime metadata states the + actual component and whether requested source ownership matched. Missing + framework/source metadata preserves explicitly labeled nearest-component + context, never a false claim of source ownership. Existing serialization, + redaction, includeProps/includeState and block selectors remain effective. +3. A portable control locator in a dialog cannot silently resolve in another + record's otherwise identical dialog. Require an explicit non-secret + `data-ds-context` identity within each dialog scope; unknown context refuses + with `unsupportedReason: context_required`. This is my fail-closed product + choice, consistent with the user's safe-refusal goal. Live DOM inspection + stays available; it does not assert business identity. Context keys changing + on retained nodes invalidate live handles. Legacy locators lacking keys may + not bypass the guard. Existing row/list semantics remain supported. + +## Boundaries and design calls + +- Reuse current schemas, adapter interface, bridge and relay/overlay APIs. +- Vite dependency handling belongs in plugins, discovered through actual + installed package resolution; preserve configured includes/excludes and + overlay-disabled operation. Qualify Vite7/8 and relevant Vite5 behavior. +- Add optional source-aware adapter resolution. Vue walks its component parent + chain by exact normalized source path, bounded with cycle protection. Do not + guess by basename, arbitrary props/state hashes, component names or positions. + Frameworks without reliable owner metadata are explicitly labeled unavailable. +- Source context is obtained for the already selected live instance; revalidate + after asynchronous manifest lookup so a retired selection never captures a + replacement node's context. No tree-wide ancestor data dump. +- Explicit context attributes are bounded/redacted by existing locator privacy + rules. Never automatically put input values or credentials into identity. + Native dialogs, ARIA dialogs/alertdialogs and nested dialogs require coverage. +- Keep DOM resolution distinct from proof of business behavior. Safe refusal on + unmodified Hoppscotch is an acceptable fix; adding keys to a test fixture is + opt-in demonstration, not a claim of automatic business-key discovery. + +## Acceptance + +Meaningful RED→GREEN tests cover mixed-schema initialization, source ownership +through nested/teleported wrappers, owner unavailable/duplicate basename/cycle, +privacy/bounds/selection races, wrong-record dialog, old unguarded locator, +retained node context change, nested/empty/redacted context and ordinary rows. +Run affected configured Nx unit/lint/build/typecheck and an uncached broader +suite before readiness. Fresh local-artifact npm/pnpm browser consumers must +verify connected relay, useful selected-owner props, wrong-context refusal, +correct-context business action, and production stripping. Re-run the original +Hoppscotch capture with optimizer overrides removed; record failures as well as +successes. Independent design/implementation review resolves material findings. +Do not rerun the four agent repair trials until these foundations pass. + +## Builder-check resolutions + +- Each dialog scope uses a key on that dialog or the selected element's ancestor + path inside it, never a sibling or a key borrowed across a nested-dialog + boundary. Portable locators carry ordered per-dialog context keys and compare + the complete current dialog chain one-to-one. Legacy requests without this + evidence refuse, even when today's candidate now has a key. Targeting a + dialog itself follows the same rule. +- Source matching shares an exact normalized absolute-path namespace; supported + file/Vite URL forms and separators may normalize, but relative paths without + a trustworthy root conversion cannot match by suffix/basename. +- Initialization tests cover cold first load and an optimizer restart, not merely + success after a manual reload. Preserve the app's own Zod3 parse behavior. From 02f0f663cf9fbe39205e620e1ab28aecc7161de1 Mon Sep 17 00:00:00 2001 From: Kaushik Gnanaskandan Date: Mon, 26 Oct 2026 21:52:40 -0700 Subject: [PATCH 2/6] fix: preserve source and business identity in stateful UI captures --- .github/workflows/ci.yml | 10 +- docs/agent-targets-and-replay.md | 67 +- .../plans/2026-10-26-stateful-handoff.md | 51 +- .../2026-10-26-stateful-handoff-design.md | 15 + .../domscribe-core/src/lib/errors/index.ts | 2 +- .../src/lib/types/annotation.ts | 12 +- .../domscribe-core/src/lib/types/config.ts | 2 +- .../domscribe-core/src/lib/types/manifest.ts | 2 +- .../domscribe-core/src/lib/types/replay.ts | 7 +- .../domscribe-core/src/lib/types/target.ts | 12 +- .../domscribe-core/src/privacy/patterns.ts | 2 + .../domscribe-core/src/privacy/redaction.ts | 4 + .../src/core/annotation-target.spec.ts | 73 ++ .../src/core/overlay-store.ts | 76 +- .../src/services/relay-service.ts | 26 +- .../src/vite/vite-plugin.spec.ts | 41 + .../domscribe-react/src/vite/vite-plugin.ts | 23 + .../src/mcp/tools/annotation-process.tool.ts | 2 + .../mcp/tools/query-by-source.tool.spec.ts | 23 + .../src/mcp/tools/query-by-source.tool.ts | 10 + packages/domscribe-relay/src/schema.ts | 7 +- .../routes/v1/annotation-process.route.ts | 1 + .../routes/v1/query-by-source.route.spec.ts | 23 + .../server/routes/v1/query-by-source.route.ts | 2 + .../src/adapters/adapter.interface.ts | 9 + .../src/bridge/bridge-dispatch.spec.ts | 1 + .../src/bridge/bridge-dispatch.ts | 11 +- .../src/capture/props-capturer.spec.ts | 3 + .../src/capture/props-capturer.ts | 1 + .../src/capture/redaction-boundaries.spec.ts | 60 ++ .../src/capture/state-capturer.ts | 1 + .../domscribe-runtime/src/capture/types.ts | 3 + .../src/core/context-capturer.ts | 21 +- .../src/core/runtime-manager.ts | 3 +- packages/domscribe-runtime/src/index.ts | 5 +- .../src/targets/dialog-context.spec.ts | 134 ++++ .../src/targets/target-registry.ts | 74 +- .../src/utils/serialization-utils.ts | 3 + .../src/utils/serialization.ts | 10 + .../src/adapter/source-context.spec.ts | 159 ++++ .../domscribe-vue/src/adapter/vue-adapter.ts | 20 + .../src/internals/source-file.spec.ts | 23 + .../src/internals/source-file.ts | 24 + packages/domscribe-vue/src/internals/types.ts | 2 + .../src/vite/vite-plugin.spec.ts | 41 + .../domscribe-vue/src/vite/vite-plugin.ts | 23 + scripts/fixtures/replay-consumer.mjs | 4 +- scripts/verify-instance-replay.mjs | 731 +++++++++++------- 48 files changed, 1478 insertions(+), 381 deletions(-) create mode 100644 packages/domscribe-runtime/src/capture/redaction-boundaries.spec.ts create mode 100644 packages/domscribe-runtime/src/targets/dialog-context.spec.ts create mode 100644 packages/domscribe-vue/src/adapter/source-context.spec.ts create mode 100644 packages/domscribe-vue/src/internals/source-file.spec.ts create mode 100644 packages/domscribe-vue/src/internals/source-file.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4698618..480afbb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -147,7 +147,7 @@ jobs: - id: react-npm-vite7 framework: react package-manager: npm - version-args: '--vite-version=7.3.7' + version-args: '--vite-version=7.3.7 --schema-peer' - id: react-pnpm-vite5 framework: react package-manager: pnpm @@ -159,15 +159,19 @@ jobs: - id: vue-pnpm-vite5 framework: vue package-manager: pnpm - version-args: '--vite-version=5.4.21 --scope=list' + version-args: '--vite-version=5.4.21 --scope=list --schema-peer --adapter-only --raw-runtime' - id: react-pnpm-vite8 framework: react package-manager: pnpm - version-args: '--vite-version=8.3.5 --scope=table' + version-args: '--vite-version=8.3.5 --scope=table --schema-peer --adapter-only' - id: vue-npm-vite8 framework: vue package-manager: npm version-args: '--vite-version=8.0.10 --scope=list' + - id: vue-pnpm-vite8-no-overlay + framework: vue + package-manager: pnpm + version-args: '--vite-version=8.3.5 --schema-peer --adapter-only --no-overlay' - id: next15-npm-webpack framework: next package-manager: npm diff --git a/docs/agent-targets-and-replay.md b/docs/agent-targets-and-replay.md index 2b42163..0e163e6 100644 --- a/docs/agent-targets-and-replay.md +++ b/docs/agent-targets-and-replay.md @@ -12,9 +12,9 @@ Use MCP `domscribe.targets.query` or HTTP `POST /api/v1/targets/query`: { "mode": "list", "entryId": "aB3dEf7h", "limit": 50 } ``` -The response contains `status`, `targets`, `total`, and `browserConnected`. Each target includes `instanceId`, `entryId` when mapped, `selector`, `locator`, `disabled`, and the effective `inputType` for inputs. Resolve an entry ID with the existing source-resolution tools for its source file and position. +The response contains `status`, optional `reason`, `targets`, `total`, and `browserConnected`. Each target includes `instanceId`, `entryId` when mapped, `selector`, `locator`, `disabled`, and the effective `inputType` for inputs. Resolve an entry ID with the existing source-resolution tools for its source file and position. -To inspect an instance, send `{"instanceId":""}`. To locate a capture in a new session, send `{"locator":}`. `domscribe.query.bySource` also accepts an optional `instanceId` or `locator` and returns `selectionStatus`, `targets` and `total` alongside its existing fields. +To inspect an instance, send `{"instanceId":""}`. To locate a capture in a new session, send `{"locator":}`. `domscribe.query.bySource` also accepts an optional `instanceId` or `locator` and returns `selectionStatus`, optional `selectionReason`, `targets` and `total` alongside its existing fields. Only `resolved` identifies one exact target and returns its available runtime context. `listed` is discovery, not a selection. `ambiguous`, `not_found`, `stale`, `wrong_page`, `not_ready`, `unsupported`, `ambiguous_browser`, and `unavailable` are explicit stopping conditions. Reconnect/retry readiness failures or refine ambiguous targets; do not choose the first candidate. Keep one browser session connected to the relay for these queries. After HMR, the manifest can briefly lag newly rendered source IDs; retry source resolution while updates settle instead of substituting an old mapping. @@ -24,6 +24,56 @@ Semantic table rows and list items (`tr`, `li`, `[role="row"]`, and `[role="list PII is checked before segment encoding, including email/card/phone values split across text nodes; sensitive row identity is redacted as a whole and cannot be replayed. Local fingerprints retain complete raw ancestor constraints privately. If more than four identified ancestor scopes are needed, the portable locator is explicitly unsupported rather than silently dropping an outer business identity. The same applies when raw identity exceeds size limits even if redaction would shorten it. +## Dialog business identity + +A reusable dialog can render identical controls for different business records. +A static dialog label, source ID or button text cannot prove which record is +selected. Portable capture/replay therefore requires an explicit, stable, +**non-secret** `data-ds-context` key on the dialog or an ancestor of the selected +control inside that dialog: + +```tsx +
+ +
+``` + +In Vue, use `:data-ds-context="'customer:' + customer.id"`. Use an opaque public +record identifier, never a token, personal information, form value or secret. +Each nested dialog needs its own key; sibling keys and keys outside that dialog +do not establish its identity. The full ordered dialog-context chain must match. +Changing an inner or outer key invalidates old local handles and portable matches. + +Unscoped dialogs are exported with `unsupportedReason: "context_required"`. +Portable resolution returns `status: "unsupported", reason: "context_required"`, +and recording refuses with an actionable error. Legacy locators without a saved +context chain also refuse inside dialogs, even if the current app now supplies a +key. Recapture after integration. Live instance inspection and source mapping +remain available, but a DOM handle alone does not prove business identity across +sessions. For an unmodified application without a key, safe refusal is expected. +Dialog detection covers native `dialog`, `role="dialog"`, `role="alertdialog"` +and `aria-modal="true"`; inline editors without these semantics still require +sufficient ordinary locator identity and are not automatically business-scoped. + +## Source-owning runtime context + +Relay target/source queries and overlay selection resolve manifest metadata before +capturing context, pinning the original instance across that asynchronous lookup. +Vue can follow component ancestry through shared controls and Teleport to the +component whose `__file` exactly matches that source. `componentIdentity` labels +whose props/state were captured and reports `sourceMatch: "matched"`, `"nearest"` +(no requested source), or `"unavailable"` (ownership could not be proved). +An unavailable match preserves nearest-component context without presenting it as +the application owner. Relative/unknown namespaces and duplicate basenames are +not guessed. Other adapters currently use that explicit nearest/unavailable +fallback; source-owner matching here is Vue-specific and depends on dev metadata. + +Capture remains bounded and honors props/state opt-outs and configured redaction. +OAuth `clientSecret`/`client_secret` fields and entire records marked `secret: true` +are redacted, including nested environment values. Redaction cannot identify every +application-specific secret; configure blocked selectors and avoid capturing +private application state in issue evidence. + ## Explicit recording In the instrumented page's JavaScript context: @@ -83,7 +133,7 @@ node scripts/verify-instance-replay.mjs --framework=nuxt --package-manager=npm \ The harness installs local tarballs into a temporary consumer, uses the fixture project's existing Playwright dependency and installed Chromium, persists an annotation, closes the browser context, reorders rows in a fresh context, and replays the same four UI steps through the relay. Assertions cover exact mapped source/props, committed editor state after typing/checking, stale handles after unmount and across sessions, ambiguity refusal, application console/page/network failures, production instrumentation removal (including conditional editors), and correct production UI updates. -CI runs ten representative combinations, with at most two replay jobs simultaneously: +CI runs ten complete replay combinations plus one initialization-only case, with at most two replay jobs simultaneously: | Integration | Consumer versions | Package manager | | --------------------------- | ------------------------- | --------------- | @@ -100,7 +150,7 @@ CI runs ten representative combinations, with at most two replay jobs simultaneo The default remains React18/npm/Vite7. Use `--framework-version` for exact Next15/16 or Nuxt3/4 releases; use `--vite-version` for React/Vue consumers. Mixed or unsupported flags are rejected. Native SSR consumers use their framework integrations and normal hydration, not the Vite SPA integration. Nuxt excludes the runtime from Vite prebundling so its native plugin and consumer queries share one runtime on cold startup. Each run checks actual installed versions, expected framework source, and local runtime resolution. -`--scope=article` keeps the original labeled-card fixture. `--scope=table` and `--scope=list` use sibling business text with no added row label or test ID. They deliberately retain position-keyed action nodes during a customer replacement and verify that old handles/selectors retire, old portable identity does not resolve, the new runtime props belong to the replacement, and only that customer's UI data changes. CI runs Vue/pnpm/Vite5 and Vue/npm/Vite8 as lists, and Next16/pnpm and React/pnpm/Vite8 as tables. The other six cases retain article coverage. +`--scope=article` keeps the original labeled-card fixture. `--scope=table` and `--scope=list` use sibling business text with no added row label or test ID. They deliberately retain position-keyed action nodes during a customer replacement and verify that old handles/selectors retire, old portable identity does not resolve, the new runtime props belong to the replacement, and only that customer's UI data changes. CI runs Vue/pnpm/Vite5 and Vue/npm/Vite8 as lists, and Next16/pnpm and React/pnpm/Vite8 as tables. The other complete replay cases retain article coverage. The additional Vue/pnpm/Vite8 case disables the overlay and checks initialization/runtime capture only. Vite8 consumers use the compatible official React6.1.2/Vue6.0.6 plugins. Older Vite5–7 fixture paths keep their existing plugins. [Vite8](https://vite.dev/blog/announcing-vite8) requires Node20.19+ or22.12+; qualification here uses Node24.22.1. The installed-package checks separately install only the adapter, framework and Vite through an isolated local registry, without adding transitive packages or dependency overrides. They typecheck a normal Vite configuration and check that virtual browser modules serve one shared runtime. Vite8 peer support and these APIs remain unpublished until the next package release; npm0.6.1 still declares Vite5–7 support. @@ -109,3 +159,12 @@ The [real-application acceptance matrix](./real-app-acceptance.md) records separ These are representative synthetic regression cases, not the full version/manager/router matrix or an adoption benchmark. Additional local testing on the Domscribe website (Next16.4.3 / React19.2.4) verified exact installation-tab source/state among six repeated instances, persisted replay after a fresh browser session, safe page-navigation failure, mobile navigation state/replay after reload, and controlled HMR identity changes without resurrecting retired handles. Its production build and UI also worked without instrumentation. This is one maintained app, not evidence for every router, virtualized list, framework version, or agent workflow advantage. Package installs use local tarballs, with explicit pnpm local-artifact overrides so unpublished artifacts cannot be replaced by registry copies. This is not evidence that the new APIs are in the published npm release. Logs and result JSON are retained in the printed temporary directory on success and failure. `--report-dir` copies a compact evidence bundle. Traces and screenshots are retained when available, with best-effort failure screenshots after browser startup. Server startup and builds are bounded, and the harness stops its owned server process group and relay. + +`--schema-peer` adds an independent application Zod3 parser and checks cold +initialization plus a forced optimizer restart without consumer optimizer includes. +`--adapter-only` (pnpm with schema-peer) installs only the documented Domscribe +adapter at the consumer root; local-artifact overrides supply its transitive +packages without hoisting runtime/overlay. `--raw-runtime` additionally preserves +an explicit runtime exclusion. `--no-overlay` checks initialization and direct +runtime capture without a WebSocket overlay; its result is explicitly scoped to +initialization, not full replay or production verification. diff --git a/docs/superpowers/plans/2026-10-26-stateful-handoff.md b/docs/superpowers/plans/2026-10-26-stateful-handoff.md index 5d7fbc5..3ceb759 100644 --- a/docs/superpowers/plans/2026-10-26-stateful-handoff.md +++ b/docs/superpowers/plans/2026-10-26-stateful-handoff.md @@ -29,12 +29,13 @@ requests cannot bypass new guards. No publish/deploy/credential changes. Files: Vite plugins in transform/react/vue and focused browser consumer regression under scripts/fixtures; affected plugin specs only where a contract needs it. -- [ ] Reproduce normal app Zod3 + local-artifact Domscribe4 browser failure with - no optimizer override; assert schema works and runtime/relay connect. -- [ ] Establish clean affected baseline through configured Nx tasks. -- [ ] Fix dependency ownership/pre-optimization in plugins using actual installed - resolution, retaining user settings and disabled-overlay behavior. -- [ ] Verify npm/pnpm and Vite boundaries; production stripping. Commit slice. +- [x] Reproduce normal app Zod3 + local-artifact Domscribe4 browser failure with + no optimizer override; assert schema works and runtime/relay connect. +- [x] Establish clean affected baseline through configured Nx tasks. +- [x] Fix browser-shared schema imports to explicitly select Zod4, per the + reproduced namespace collision; preserve consumer exclusions and dependency ownership while predeclaring + injected adapter dependencies; verify disabled-overlay behavior. +- [x] Verify npm/pnpm and Vite boundaries; production stripping. Commit slice. ### Task 2 — Source-owning component capture @@ -45,13 +46,13 @@ Interface: optional adapter `resolveSourceComponent(element, sourceFile)` return an instance and proven source file; capture options accept requested source file; runtime context metadata labels matched/nearest/unavailable ownership. -- [ ] Add failing tests for owner props through wrappers, exact path vs basename, - metadata unavailable, cycles and existing privacy/capture options. -- [ ] Implement bounded Vue parent resolution and core/runtime metadata. -- [ ] Resolve manifest before selected-instance capture through overlay/relay; - revalidate the instance after the async lookup. Add failing race coverage. -- [ ] Real wrapped Vue/Hoppscotch owner capture; representative React compatibility, - fresh-session source context, redaction and production stripping. Commit slice. +- [x] Add failing tests for owner props through wrappers, exact path vs basename, + metadata unavailable, cycles and existing privacy/capture options. +- [x] Implement bounded Vue parent resolution and core/runtime metadata. +- [x] Resolve manifest before selected-instance capture through overlay/relay; + revalidate the instance after the async lookup. Add failing race coverage. +- [x] Real wrapped Vue/Hoppscotch owner capture; representative React compatibility, + fresh-session source context, redaction and production stripping. Commit slice. ### Task 3 — Dialog context guards @@ -61,20 +62,20 @@ consumer fixtures and API documentation. Interface: `data-ds-context` is an explicit non-secret locator attribute; `unsupportedReason` also accepts `context_required`. -- [ ] Failing wrong-record, legacy, retained-node, nested-dialog, empty/redacted - context and existing-row regression cases. -- [ ] Enforce context coverage when capturing AND resolving portable locators; - preserve raw overflow checks and local inspection behavior. -- [ ] Verify unmodified Hoppscotch refuses portable dialog replay; explicit-key - fixture replays only the intended record and preserves neighbors. Document - main-only API behavior and required context integration. Commit slice. +- [x] Failing wrong-record, legacy, retained-node, nested-dialog, empty/redacted + context and existing-row regression cases. +- [x] Enforce context coverage when capturing AND resolving portable locators; + preserve raw overflow checks and local inspection behavior. +- [x] Verify unmodified Hoppscotch refuses portable dialog replay; explicit-key + fixture replays only the intended record and preserves neighbors. Document + main-only API behavior and required context integration. Commit slice. ### Task 4 — Review and delivery -- [ ] Run all affected configured checks and uncached broader unit suite, fresh - browser/npm/pnpm validation and original Hoppscotch flows without overrides. -- [ ] Independent review; fix material findings with regression tests. +- [x] Run all affected configured checks and uncached broader unit suite, fresh + browser/npm/pnpm validation and original Hoppscotch flows without overrides. +- [x] Independent review; fix material findings with regression tests. - [ ] Push focused PR(s), attach them, follow latest-head CI and merge with - standing admin-bypass authority only after required checks pass. + standing admin-bypass authority only after required checks pass. - [ ] Update continuity ledger with commit/commands/results/limitations and next - meaningful experiment; no commercial advantage claims from setup fixes. + meaningful experiment; no commercial advantage claims from setup fixes. diff --git a/docs/superpowers/specs/2026-10-26-stateful-handoff-design.md b/docs/superpowers/specs/2026-10-26-stateful-handoff-design.md index 1b2cb5c..c1fa5e8 100644 --- a/docs/superpowers/specs/2026-10-26-stateful-handoff-design.md +++ b/docs/superpowers/specs/2026-10-26-stateful-handoff-design.md @@ -76,3 +76,18 @@ Do not rerun the four agent repair trials until these foundations pass. a trustworthy root conversion cannot match by suffix/basename. - Initialization tests cover cold first load and an optimizer restart, not merely success after a manual reload. Preserve the app's own Zod3 parse behavior. + +## Initialization ruling after reproduction + +The fresh isolated-pnpm browser test reproduces an application Zod3 parser +working while shared Domscribe schema evaluation throws `z.partialRecord`. +The minimal schema-boundary fix uses explicit `zod/v4` imports for core schemas +and the browser-safe relay schema. These construct Domscribe's own Classic +schemas; they do not accept user Mini schemas. Keep the declared Zod4 dependency +without changing the application's Zod or adding consumer overrides. A second, +independently reproduced Vite8 problem split RuntimeManager between raw and +optimized adapter imports. React/Vue Vite plugins now predeclare their injected +adapter/runtime/overlay graph through nested package resolution. They preserve +explicit consumer exclusions; Nuxt's raw-runtime configuration is unchanged. +These solve separate schema-major and module-identity failures. Node imports of +Zod4 root/classic remain compatible; prove browser and full-suite behavior. diff --git a/packages/domscribe-core/src/lib/errors/index.ts b/packages/domscribe-core/src/lib/errors/index.ts index b2df720..36554f0 100644 --- a/packages/domscribe-core/src/lib/errors/index.ts +++ b/packages/domscribe-core/src/lib/errors/index.ts @@ -4,7 +4,7 @@ * @module @domscribe/core/errors */ -import { z } from 'zod'; +import { z } from 'zod/v4'; /** * Domscribe error codes following the DS_* convention. diff --git a/packages/domscribe-core/src/lib/types/annotation.ts b/packages/domscribe-core/src/lib/types/annotation.ts index f7c38d4..4f764f7 100644 --- a/packages/domscribe-core/src/lib/types/annotation.ts +++ b/packages/domscribe-core/src/lib/types/annotation.ts @@ -4,7 +4,7 @@ * @module @domscribe/core/types/annotation */ import { ManifestEntryIdSchema, ManifestEntrySchema } from './manifest.js'; -import { z } from 'zod'; +import { z } from 'zod/v4'; import { PATTERNS } from '../constants/index.js'; import { ElementTargetSchema } from './target.js'; import { ReplayTraceSchema } from './replay.js'; @@ -90,7 +90,17 @@ export const ComponentStylesSchema = z.object({ ), }); +/** Identifies whose props/state were captured; source matching is explicit. */ +export const ComponentIdentitySchema = z.object({ + name: z.string().max(256).optional(), + sourceFile: z.string().max(4096).optional(), + requestedSourceFile: z.string().max(4096).optional(), + sourceMatch: z.enum(['matched', 'nearest', 'unavailable']), +}); +export type ComponentIdentity = z.infer; + export const RuntimeContextSchema = z.object({ + componentIdentity: ComponentIdentitySchema.optional(), componentProps: z.unknown().optional().describe('Component props snapshot'), componentState: z.unknown().optional().describe('Component state snapshot'), componentStyles: ComponentStylesSchema.optional().describe( diff --git a/packages/domscribe-core/src/lib/types/config.ts b/packages/domscribe-core/src/lib/types/config.ts index ed2ac63..7af7394 100644 --- a/packages/domscribe-core/src/lib/types/config.ts +++ b/packages/domscribe-core/src/lib/types/config.ts @@ -2,7 +2,7 @@ * Domscribe project configuration schema. * @module @domscribe/core/types/config */ -import { z } from 'zod'; +import { z } from 'zod/v4'; /** * Schema for `domscribe.config.json`. diff --git a/packages/domscribe-core/src/lib/types/manifest.ts b/packages/domscribe-core/src/lib/types/manifest.ts index a4040d8..606ec1c 100644 --- a/packages/domscribe-core/src/lib/types/manifest.ts +++ b/packages/domscribe-core/src/lib/types/manifest.ts @@ -4,7 +4,7 @@ * @module @domscribe/core/types/manifest */ -import { z } from 'zod'; +import { z } from 'zod/v4'; import { PATTERNS } from '../constants/index.js'; export const ManifestEntryIdSchema = z diff --git a/packages/domscribe-core/src/lib/types/replay.ts b/packages/domscribe-core/src/lib/types/replay.ts index 38eab29..773f175 100644 --- a/packages/domscribe-core/src/lib/types/replay.ts +++ b/packages/domscribe-core/src/lib/types/replay.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import { z } from 'zod/v4'; import { PortableLocatorSchema } from './target.js'; const RecordedValueSchema = z @@ -35,7 +35,10 @@ export const ReplayStepSchema = z ) ctx.addIssue({ code: 'custom', - message: 'Portable identity cannot be replayed', + message: + target.unsupportedReason === 'context_required' + ? 'Portable dialog replay requires a stable, non-secret data-ds-context key' + : 'Portable identity cannot be replayed', }); if ( ['password', 'file'].includes( diff --git a/packages/domscribe-core/src/lib/types/target.ts b/packages/domscribe-core/src/lib/types/target.ts index 63b555c..3b59bbe 100644 --- a/packages/domscribe-core/src/lib/types/target.ts +++ b/packages/domscribe-core/src/lib/types/target.ts @@ -1,9 +1,10 @@ -import { z } from 'zod'; +import { z } from 'zod/v4'; import { ManifestEntryIdSchema } from './manifest.js'; const IdentityAttributesSchema = z.partialRecord( z.enum([ 'id', + 'data-ds-context', 'data-testid', 'data-test', 'aria-label', @@ -24,10 +25,16 @@ export const LocatorIdentitySchema = z heading: z.string().max(128).optional(), }) .strict(); +export const TargetReasonSchema = z.enum([ + 'identity_too_long', + 'context_required', +]); export const PortableLocatorSchema = z .object({ version: z.literal(1), - unsupportedReason: z.literal('identity_too_long').optional(), + unsupportedReason: TargetReasonSchema.optional(), + // One business key for each enclosing dialog, innermost first. + contexts: z.array(z.string().min(1).max(256)).max(4).optional(), pathname: z.string().startsWith('/').max(2048), element: LocatorIdentitySchema, // Nearest identified ancestor first; these scopes are conjunctive. @@ -82,6 +89,7 @@ export const TargetStatusSchema = z.enum([ ]); export const TargetResolutionSchema = z.object({ status: TargetStatusSchema, + reason: TargetReasonSchema.optional(), targets: z.array(ElementTargetSchema).max(200), total: z.number().int().nonnegative(), }); diff --git a/packages/domscribe-core/src/privacy/patterns.ts b/packages/domscribe-core/src/privacy/patterns.ts index 41aefaa..e35b14c 100644 --- a/packages/domscribe-core/src/privacy/patterns.ts +++ b/packages/domscribe-core/src/privacy/patterns.ts @@ -33,6 +33,8 @@ export const SENSITIVE_FIELD_NAMES = [ 'passwd', 'pwd', 'secret', + 'clientsecret', + 'client_secret', 'token', 'apikey', 'api_key', diff --git a/packages/domscribe-core/src/privacy/redaction.ts b/packages/domscribe-core/src/privacy/redaction.ts index 064801f..78703e4 100644 --- a/packages/domscribe-core/src/privacy/redaction.ts +++ b/packages/domscribe-core/src/privacy/redaction.ts @@ -151,6 +151,10 @@ export function redactSensitiveFields( return obj; } + // Applications may explicitly label an environment/credential record secret. + // Redact the whole record: its payload need not use a known credential key. + if (isRecord(obj) && obj.secret === true) return '[REDACTED]'; + const sensitiveNames = new Set([ ...SENSITIVE_FIELD_NAMES, ...additionalFields.map((f) => f.toLowerCase()), diff --git a/packages/domscribe-overlay/src/core/annotation-target.spec.ts b/packages/domscribe-overlay/src/core/annotation-target.spec.ts index 7ceb91c..5972f33 100644 --- a/packages/domscribe-overlay/src/core/annotation-target.spec.ts +++ b/packages/domscribe-overlay/src/core/annotation-target.spec.ts @@ -96,6 +96,79 @@ describe('annotation target resolution', () => { expect(create).not.toHaveBeenCalled(); vi.restoreAllMocks(); }); + it('captures source-owner context after manifest lookup and labels its provenance', async () => { + window.__DOMSCRIBE_RELAY_PORT__ = 6412; + document.body.innerHTML = + ''; + const owner = { recordId: 'alpha' }; + const runtime = RuntimeManager.getInstance(); + await runtime.initialize({ + adapter: { + name: 'fixture', + getComponentInstance: () => ({ wrapper: true }), + resolveSourceComponent: (_element, file) => + file === '/app/OAuth2.vue' + ? { component: owner, sourceFile: file } + : null, + captureProps: (component) => component as Record, + captureState: () => null, + getComponentName: (component) => + component === owner ? 'OAuth2' : 'Wrapper', + }, + }); + vi.spyOn(RelayService.getInstance(), 'resolve').mockResolvedValue({ + id: 'aB3dEf7h', + file: '/app/OAuth2.vue', + start: { line: 1, column: 0 }, + }); + const store = OverlayStore.getInstance(); + await store.selectElement(required(document.querySelector('button'))); + expect(store.getState().runtimeContext).toMatchObject({ + componentProps: { recordId: 'alpha' }, + componentIdentity: { + name: 'OAuth2', + sourceMatch: 'matched', + sourceFile: '/app/OAuth2.vue', + }, + }); + vi.restoreAllMocks(); + }); + it('does not capture a replacement after the selected node unmounts during manifest lookup', async () => { + window.__DOMSCRIBE_RELAY_PORT__ = 6412; + document.body.innerHTML = + ''; + const captureProps = vi.fn(() => ({ recordId: 'wrong' })); + const runtime = RuntimeManager.getInstance(); + await runtime.initialize({ + adapter: { + name: 'fixture', + getComponentInstance: () => ({}), + captureProps, + captureState: () => null, + }, + }); + let resolve!: (value: null) => void; + vi.spyOn(RelayService.getInstance(), 'resolve').mockImplementation( + () => + new Promise((done) => { + resolve = done; + }), + ); + const store = OverlayStore.getInstance(); + const selection = store.selectElement( + required(document.querySelector('button')), + ); + document.body.innerHTML = + ''; + resolve(null); + await selection; + expect(store.getState().runtimeContext).toBeNull(); + expect(captureProps).not.toHaveBeenCalled(); + await expect(store.submitAnnotation('Selected Alpha')).rejects.toThrow( + /changed|stale/i, + ); + vi.restoreAllMocks(); + }); }); function required(value: T | null | undefined): T { diff --git a/packages/domscribe-overlay/src/core/overlay-store.ts b/packages/domscribe-overlay/src/core/overlay-store.ts index df3f50a..24c8e6b 100644 --- a/packages/domscribe-overlay/src/core/overlay-store.ts +++ b/packages/domscribe-overlay/src/core/overlay-store.ts @@ -295,41 +295,36 @@ export class OverlayStore { hoveredElement: null, }); - // Capture runtime context asynchronously - try { - const bridge = BridgeDispatch.getInstance(); - const runtimeContext = await bridge.captureContext(element); - - if (runtimeContext && selectionVersion === this.selectionVersion) { - this.setState({ runtimeContext }); - } - } catch (error) { - if (this.state.debug) { - console.warn( - '[domscribe-overlay][store] Failed to capture runtime context:', - error, - ); - } - } - - // Resolve manifest entry if we have an element ID + let sourceFile: string | undefined; if (entryId) { try { - const relayService = RelayService.getInstance(); - const manifestEntry = await relayService.resolve(entryId); - + const manifestEntry = await RelayService.getInstance().resolve(entryId); if (manifestEntry && selectionVersion === this.selectionVersion) { + sourceFile = manifestEntry.file; this.setState({ manifestEntry }); } - } catch (error) { - if (this.state.debug) { - console.warn( - '[domscribe-overlay][store] Failed to resolve manifest entry:', - error, - ); - } + } catch { + /* Capture remains useful when source metadata is unavailable. */ } } + if (selectionVersion !== this.selectionVersion || !selectedTarget) return; + try { + const selection = await BridgeDispatch.getInstance().queryTargets( + { instanceId: selectedTarget.instanceId }, + { sourceFile }, + ); + if ( + selectionVersion === this.selectionVersion && + selection.status === 'resolved' + ) + this.setState({ runtimeContext: selection.context ?? null }); + } catch (error) { + if (this.state.debug) + console.warn( + '[domscribe-overlay][store] Failed to capture runtime context:', + error, + ); + } } /** @@ -502,18 +497,10 @@ export class OverlayStore { const element = this.findAnnotationElement(annotation); if (!element) return null; - // Re-capture runtime context + const bridge = BridgeDispatch.getInstance(); + const target = bridge.describeTarget(element); + if (!target) return null; let runtimeContext: RuntimeContext | undefined; - try { - const bridge = BridgeDispatch.getInstance(); - const ctx = await bridge.captureContext(element); - if (ctx) { - runtimeContext = ctx; - } - } catch { - // Continue without runtime context - } - // Re-resolve manifest entry let manifestSnapshot: ManifestEntry[] | undefined; if (dataDs) { @@ -528,6 +515,17 @@ export class OverlayStore { } } + try { + const selection = await bridge.queryTargets( + { instanceId: target.instanceId }, + { sourceFile: manifestSnapshot?.[0]?.file }, + ); + if (selection.status !== 'resolved') return null; + runtimeContext = selection.context; + } catch { + return null; + } + // Patch the annotation with fresh data const updates: Partial = {}; if (runtimeContext) updates.runtimeContext = runtimeContext; diff --git a/packages/domscribe-overlay/src/services/relay-service.ts b/packages/domscribe-overlay/src/services/relay-service.ts index b145ac1..70f49d6 100644 --- a/packages/domscribe-overlay/src/services/relay-service.ts +++ b/packages/domscribe-overlay/src/services/relay-service.ts @@ -21,6 +21,7 @@ import type { AnnotationContext, AnnotationStatus, AnnotationId, + TargetQueryInput, } from '@domscribe/core'; import { AnnotationStatusEnum, WS_EVENTS } from '@domscribe/core'; import { BridgeDispatch } from '@domscribe/runtime'; @@ -36,6 +37,24 @@ export class RelayService { private store: OverlayStore; private unsubscribers: Array<() => void> = []; + private async querySourceContext(query: TargetQueryInput) { + const bridge = BridgeDispatch.getInstance(); + const selection = bridge.resolveTargets(query); + if (selection.status !== 'resolved') return selection; + const target = selection.targets[0]; + let sourceFile: string | undefined; + try { + if (target.entryId) + sourceFile = (await this.resolve(target.entryId))?.file; + } catch { + /* Source metadata may be unavailable; nearest context is labeled. */ + } + return bridge.queryTargets( + { instanceId: target.instanceId }, + { sourceFile }, + ); + } + private constructor() { this.store = OverlayStore.getInstance(); } @@ -141,9 +160,7 @@ export class RelayService { if (!packet.success) return; let result; try { - result = await BridgeDispatch.getInstance().queryTargets( - packet.data.query, - ); + result = await this.querySourceContext(packet.data.query); } catch { result = { status: 'unavailable', targets: [], total: 0 }; } @@ -174,7 +191,7 @@ export class RelayService { }; } else { try { - const selection = await bridge.queryTargets({ + const selection = await this.querySourceContext({ entryId, instanceId, locator, @@ -190,6 +207,7 @@ export class RelayService { responseData = { requestId, selectionStatus: selection.status, + selectionReason: selection.reason, targets: selection.targets, total: selection.total, success: selection.status === 'resolved' && context != null, diff --git a/packages/domscribe-react/src/vite/vite-plugin.spec.ts b/packages/domscribe-react/src/vite/vite-plugin.spec.ts index 73e3e44..07a8c85 100644 --- a/packages/domscribe-react/src/vite/vite-plugin.spec.ts +++ b/packages/domscribe-react/src/vite/vite-plugin.spec.ts @@ -11,6 +11,7 @@ vi.mock('@domscribe/transform/plugins/vite', () => ({ })); import { domscribe } from './vite-plugin.js'; +import { mergeConfig, type UserConfig } from 'vite'; describe('domscribe (react/vite)', () => { it('should rename the plugin to vite-plugin-domscribe-react', () => { @@ -26,6 +27,46 @@ describe('domscribe (react/vite)', () => { expect(plugin.name).toBeDefined(); }); + describe('injected dependency optimization', () => { + it('preserves application includes and explicit runtime/overlay exclusions', () => { + const plugin = domscribe(); + const user: UserConfig = { + optimizeDeps: { + include: ['application-dependency'], + exclude: ['@domscribe/runtime', '@domscribe/overlay'], + }, + }; + const configure = plugin.config as (config: UserConfig) => UserConfig; + const merged = mergeConfig(user, configure(user)); + expect(merged.optimizeDeps?.include).toContain('application-dependency'); + expect(merged.optimizeDeps?.include).toContain('@domscribe/react'); + expect( + merged.optimizeDeps?.include?.some((id) => + id.endsWith(' > @domscribe/runtime'), + ), + ).toBe(false); + expect( + merged.optimizeDeps?.include?.some((id) => + id.endsWith(' > @domscribe/overlay'), + ), + ).toBe(false); + expect(merged.optimizeDeps?.exclude).toEqual(user.optimizeDeps?.exclude); + }); + it('does not require overlay optimization when overlay is disabled', () => { + const plugin = domscribe({ overlay: false }); + const configure = plugin.config as (config: UserConfig) => UserConfig; + const config = configure({}); + expect(config.optimizeDeps?.include).toContain( + '@domscribe/react > @domscribe/runtime', + ); + expect( + config.optimizeDeps?.include?.some((id) => + id.includes('@domscribe/overlay'), + ), + ).toBe(false); + }); + }); + describe('resolveId', () => { it('should resolve the init module path', () => { const plugin = domscribe(); diff --git a/packages/domscribe-react/src/vite/vite-plugin.ts b/packages/domscribe-react/src/vite/vite-plugin.ts index 75ec339..cf912a5 100644 --- a/packages/domscribe-react/src/vite/vite-plugin.ts +++ b/packages/domscribe-react/src/vite/vite-plugin.ts @@ -53,6 +53,29 @@ export function domscribe(options?: DomscribeReactPluginOptions): Plugin { basePlugin.name = 'vite-plugin-domscribe-react'; + // The virtual initializer is invisible to the HTML dependency scan. Declare + // its graph before first navigation, resolving transitive modules through + // the installed adapter so isolated pnpm consumers need no hoisted imports. + basePlugin.config = (config) => { + const dependencies = [ + '@domscribe/react', + '@domscribe/react > @domscribe/runtime', + ...(options?.overlay === false + ? [] + : ['@domscribe/react > @domscribe/transform > @domscribe/overlay']), + ]; + const excluded = config.optimizeDeps?.exclude ?? []; + return { + optimizeDeps: { + include: dependencies.filter( + (id) => + !excluded.includes(id) && + !excluded.includes(id.split(' > ').at(-1)!), + ), + }, + }; + }; + basePlugin.resolveId = function (id, importer, ...args) { if (id === INIT_MODULE_PATH) { return INIT_MODULE_PATH; diff --git a/packages/domscribe-relay/src/mcp/tools/annotation-process.tool.ts b/packages/domscribe-relay/src/mcp/tools/annotation-process.tool.ts index c5d83ad..072cea5 100644 --- a/packages/domscribe-relay/src/mcp/tools/annotation-process.tool.ts +++ b/packages/domscribe-relay/src/mcp/tools/annotation-process.tool.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { ComponentIdentitySchema } from '@domscribe/core'; import { McpToolDefinition, McpToolOutputSchema, @@ -39,6 +40,7 @@ const ProcessToolSourceLocationSchema = z.object({ /** Schema for runtime context in flattened format */ const ProcessToolRuntimeContextSchema = z.object({ + componentIdentity: ComponentIdentitySchema.optional(), componentProps: z.unknown().optional().describe('Component props snapshot'), componentState: z.unknown().optional().describe('Component state snapshot'), }); diff --git a/packages/domscribe-relay/src/mcp/tools/query-by-source.tool.spec.ts b/packages/domscribe-relay/src/mcp/tools/query-by-source.tool.spec.ts index 94859bc..f93caba 100644 --- a/packages/domscribe-relay/src/mcp/tools/query-by-source.tool.spec.ts +++ b/packages/domscribe-relay/src/mcp/tools/query-by-source.tool.spec.ts @@ -81,6 +81,29 @@ describe('QueryBySourceTool', () => { ); }); + it('explains missing dialog identity instead of suggesting navigation', async () => { + const mockClient = createMockRelayClient({ + queryBySource: vi.fn().mockResolvedValue({ + found: true, + browserConnected: true, + selectionStatus: 'unsupported', + selectionReason: 'context_required', + targets: [], + total: 0, + runtime: { rendered: false }, + }), + }); + const result = await new QueryBySourceTool(mockClient).toolCallback({ + file: '/app/OAuth2.vue', + line: 526, + }); + expect(result.structuredContent).toMatchObject({ + selectionReason: 'context_required', + hint: expect.stringContaining('data-ds-context'), + }); + expect(getResultText(result)).not.toContain('not currently rendered'); + }); + it('emits no captureStyles hint when componentStyles is already present', async () => { // Arrange — simulate the post-RFC 0001 happy path: runtime is configured // with captureStyles, the query returns componentStyles, agent has full diff --git a/packages/domscribe-relay/src/mcp/tools/query-by-source.tool.ts b/packages/domscribe-relay/src/mcp/tools/query-by-source.tool.ts index af37f25..2b07aef 100644 --- a/packages/domscribe-relay/src/mcp/tools/query-by-source.tool.ts +++ b/packages/domscribe-relay/src/mcp/tools/query-by-source.tool.ts @@ -1,9 +1,11 @@ import { z } from 'zod'; import { SourcePositionSchema, + ComponentIdentitySchema, PortableLocatorSchema, ElementTargetSchema, TargetStatusSchema, + TargetReasonSchema, } from '@domscribe/core'; import { McpToolDefinition, @@ -47,6 +49,7 @@ type QueryBySourceToolInput = z.infer; const QueryBySourceToolOutputSchema = McpToolOutputSchema.extend({ selectionStatus: TargetStatusSchema.optional(), + selectionReason: TargetReasonSchema.optional(), targets: z.array(ElementTargetSchema).optional(), total: z.number().int().nonnegative().optional(), found: z.boolean(), @@ -63,6 +66,7 @@ const QueryBySourceToolOutputSchema = McpToolOutputSchema.extend({ runtime: z .object({ rendered: z.boolean(), + componentIdentity: ComponentIdentitySchema.optional(), componentProps: z.unknown().optional(), componentState: z.unknown().optional(), componentStyles: z @@ -123,12 +127,17 @@ export class QueryBySourceTool implements McpToolDefinition< private buildHint(result: { found: boolean; selectionStatus?: string; + selectionReason?: string; browserConnected?: boolean; runtime?: { rendered?: boolean; componentStyles?: unknown; }; }): string | undefined { + if (result.selectionStatus === 'unsupported') + return result.selectionReason === 'context_required' + ? 'Portable dialog replay requires a stable, non-secret data-ds-context key for each enclosing dialog. Recapture after the app supplies the key; do not guess a business entity.' + : 'This portable identity is unsupported. Recapture with shorter, unambiguous identity; do not act on a guessed target.'; if (result.selectionStatus === 'ambiguous') return 'Multiple live instances share this source. Inspect targets and retry with an instanceId; persist its locator for another session.'; if (result.selectionStatus === 'ambiguous_browser') @@ -175,6 +184,7 @@ export class QueryBySourceTool implements McpToolDefinition< const output: QueryBySourceToolOutput = { selectionStatus: result.selectionStatus, + selectionReason: result.selectionReason, targets: result.targets, total: result.total, found: result.found, diff --git a/packages/domscribe-relay/src/schema.ts b/packages/domscribe-relay/src/schema.ts index 357c604..5e31859 100644 --- a/packages/domscribe-relay/src/schema.ts +++ b/packages/domscribe-relay/src/schema.ts @@ -2,6 +2,7 @@ import { PortableLocatorSchema, ElementTargetSchema, TargetStatusSchema, + TargetReasonSchema, TargetResolutionSchema, TargetQuerySchema, } from '@domscribe/core'; @@ -14,7 +15,7 @@ import { * * @module @domscribe/relay/schema */ -import { z } from 'zod'; +import { z } from 'zod/v4'; import { AnnotationContextSchema, AnnotationIdSchema, @@ -25,6 +26,7 @@ import { ManifestEntryIdSchema, ManifestEntrySchema, RuntimeContextSchema, + ComponentIdentitySchema, SelectedElementSchema, SourcePositionSchema, AnnotationStatusSchema, @@ -401,6 +403,7 @@ export const WSContextResponseSchema = z.object({ success: z.boolean(), rendered: z.boolean().optional(), selectionStatus: TargetStatusSchema.optional(), + selectionReason: TargetReasonSchema.optional(), targets: z.array(ElementTargetSchema).max(200).optional(), total: z.number().int().nonnegative().optional(), context: RuntimeContextSchema.optional(), @@ -544,6 +547,7 @@ export type QueryBySourceRequest = z.infer; export const QueryBySourceResponseSchema = z.object({ selectionStatus: TargetStatusSchema.optional(), + selectionReason: TargetReasonSchema.optional(), targets: z.array(ElementTargetSchema).max(200).optional(), total: z.number().int().nonnegative().optional(), found: z.boolean().describe('Whether a manifest entry was found'), @@ -561,6 +565,7 @@ export const QueryBySourceResponseSchema = z.object({ runtime: z .object({ rendered: z.boolean(), + componentIdentity: ComponentIdentitySchema.optional(), componentProps: z.unknown().optional(), componentState: z.unknown().optional(), componentStyles: z diff --git a/packages/domscribe-relay/src/server/routes/v1/annotation-process.route.ts b/packages/domscribe-relay/src/server/routes/v1/annotation-process.route.ts index 172d1b8..4a8f697 100644 --- a/packages/domscribe-relay/src/server/routes/v1/annotation-process.route.ts +++ b/packages/domscribe-relay/src/server/routes/v1/annotation-process.route.ts @@ -102,6 +102,7 @@ export class AnnotationProcessRoute implements RelayRoute { : undefined, runtimeContext: runtimeCtx ? { + componentIdentity: runtimeCtx.componentIdentity, componentProps: runtimeCtx.componentProps, componentState: runtimeCtx.componentState, } diff --git a/packages/domscribe-relay/src/server/routes/v1/query-by-source.route.spec.ts b/packages/domscribe-relay/src/server/routes/v1/query-by-source.route.spec.ts index d45129f..2f9cc71 100644 --- a/packages/domscribe-relay/src/server/routes/v1/query-by-source.route.spec.ts +++ b/packages/domscribe-relay/src/server/routes/v1/query-by-source.route.spec.ts @@ -362,4 +362,27 @@ describe('POST /api/v1/manifest/resolve-by-source', () => { vi.mocked(mockWsServer.getClientCount).mockReturnValue(0); vi.mocked(mockWsServer.requestContext).mockResolvedValue(null); }); + it('preserves a dialog-context refusal through the HTTP response schema', async () => { + vi.mocked(mockWsServer.getClientCount).mockReturnValue(1); + vi.mocked(mockWsServer.requestContext).mockResolvedValue({ + requestId: 'refusal', + success: false, + rendered: false, + selectionStatus: 'unsupported', + selectionReason: 'context_required', + targets: [], + total: 0, + }); + const response = await app.inject({ + method: 'POST', + url: '/api/v1/manifest/resolve-by-source', + payload: { file: 'src/components/Input.tsx', line: 5 }, + }); + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + selectionStatus: 'unsupported', + selectionReason: 'context_required', + runtime: { rendered: false }, + }); + }); }); diff --git a/packages/domscribe-relay/src/server/routes/v1/query-by-source.route.ts b/packages/domscribe-relay/src/server/routes/v1/query-by-source.route.ts index b23bd0b..d9692fb 100644 --- a/packages/domscribe-relay/src/server/routes/v1/query-by-source.route.ts +++ b/packages/domscribe-relay/src/server/routes/v1/query-by-source.route.ts @@ -120,10 +120,12 @@ export class QueryBySourceRoute implements RelayRoute { : await this.wsServer.requestContext(entry.id); if (wsResult) { response.selectionStatus = wsResult.selectionStatus; + response.selectionReason = wsResult.selectionReason; response.targets = wsResult.targets; response.total = wsResult.total; response.runtime = { rendered: wsResult.rendered ?? false, + componentIdentity: wsResult.context?.componentIdentity, componentProps: wsResult.context?.componentProps, componentState: wsResult.context?.componentState, componentStyles: wsResult.context?.componentStyles, diff --git a/packages/domscribe-runtime/src/adapters/adapter.interface.ts b/packages/domscribe-runtime/src/adapters/adapter.interface.ts index 516d710..05bc5a6 100644 --- a/packages/domscribe-runtime/src/adapters/adapter.interface.ts +++ b/packages/domscribe-runtime/src/adapters/adapter.interface.ts @@ -56,6 +56,15 @@ export interface FrameworkAdapter { */ getComponentInstance(element: HTMLElement): Nullable; + /** Resolve an actual ancestor component with matching source metadata. */ + resolveSourceComponent?( + element: HTMLElement, + sourceFile: string, + ): Nullable<{ + component: unknown; + sourceFile: string; + }>; + /** * Capture component props * diff --git a/packages/domscribe-runtime/src/bridge/bridge-dispatch.spec.ts b/packages/domscribe-runtime/src/bridge/bridge-dispatch.spec.ts index d1bc7b9..fde3141 100644 --- a/packages/domscribe-runtime/src/bridge/bridge-dispatch.spec.ts +++ b/packages/domscribe-runtime/src/bridge/bridge-dispatch.spec.ts @@ -209,6 +209,7 @@ describe('BridgeDispatch', () => { expect(result).toBe(context); expect(mockRuntimeManager.captureContextForElement).toHaveBeenCalledWith( element, + {}, ); }); diff --git a/packages/domscribe-runtime/src/bridge/bridge-dispatch.ts b/packages/domscribe-runtime/src/bridge/bridge-dispatch.ts index b057e84..1b8fa16 100644 --- a/packages/domscribe-runtime/src/bridge/bridge-dispatch.ts +++ b/packages/domscribe-runtime/src/bridge/bridge-dispatch.ts @@ -18,6 +18,7 @@ import type { TargetQueryInput, TargetResolution, } from '@domscribe/core'; +import type { CaptureOptions } from '../capture/types.js'; import type { ElementInfo } from '../core/types.js'; import type { IRuntimeTransport } from './transport.interface.js'; import { DirectTransport } from './direct-transport.js'; @@ -88,11 +89,14 @@ export class BridgeDispatch { * Capture runtime context for a DOM element directly. * Only works in the same JS context as RuntimeManager. */ - async captureContext(element: HTMLElement): Promise { + async captureContext( + element: HTMLElement, + options: CaptureOptions = {}, + ): Promise { try { const runtime = RuntimeManager.getInstance(); if (!runtime.isReady()) return null; - return await runtime.captureContextForElement(element); + return await runtime.captureContextForElement(element, options); } catch { return null; } @@ -105,8 +109,9 @@ export class BridgeDispatch { queryTargets( query: TargetQueryInput, + options: CaptureOptions = {}, ): Promise { - return RuntimeManager.getInstance().queryTargets(query); + return RuntimeManager.getInstance().queryTargets(query, options); } resolveTargets(query: TargetQueryInput): TargetResolution { return RuntimeManager.getInstance().resolveTargets(query); diff --git a/packages/domscribe-runtime/src/capture/props-capturer.spec.ts b/packages/domscribe-runtime/src/capture/props-capturer.spec.ts index 48b475d..8bbb453 100644 --- a/packages/domscribe-runtime/src/capture/props-capturer.spec.ts +++ b/packages/domscribe-runtime/src/capture/props-capturer.spec.ts @@ -712,6 +712,7 @@ describe('PropsCapturer', () => { expect(mockSerializeValue).toHaveBeenCalledWith(rawProps, { maxDepth: 8, includeFunctions: false, + redactSecretRecords: true, }); expect(mockRedactPII).toHaveBeenCalledWith(serialized); expect(mockRedactSensitiveFields).toHaveBeenCalledWith(afterPII); @@ -771,6 +772,7 @@ describe('PropsCapturer', () => { expect(mockSerializeValue).toHaveBeenCalledWith(deepProps, { maxDepth: 20, includeFunctions: false, + redactSecretRecords: false, }); }); @@ -853,6 +855,7 @@ describe('PropsCapturer', () => { expect(mockSerializeValue).toHaveBeenCalledWith(rawProps, { maxDepth: 2, includeFunctions: false, + redactSecretRecords: false, }); }); diff --git a/packages/domscribe-runtime/src/capture/props-capturer.ts b/packages/domscribe-runtime/src/capture/props-capturer.ts index fc0b398..b38e58e 100644 --- a/packages/domscribe-runtime/src/capture/props-capturer.ts +++ b/packages/domscribe-runtime/src/capture/props-capturer.ts @@ -48,6 +48,7 @@ export class PropsCapturer { maxProperties: this.options.maxProperties, maxTotalBytes: this.options.maxTotalBytes, includeFunctions: false, + redactSecretRecords: this.options.redactPII !== false, skipKeys: this.options.skipKeys, skipKeyPrefixes: this.options.skipKeyPrefixes, }); diff --git a/packages/domscribe-runtime/src/capture/redaction-boundaries.spec.ts b/packages/domscribe-runtime/src/capture/redaction-boundaries.spec.ts new file mode 100644 index 0000000..2225e79 --- /dev/null +++ b/packages/domscribe-runtime/src/capture/redaction-boundaries.spec.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; +import type { FrameworkAdapter } from '../adapters/adapter.interface.js'; +import { PropsCapturer } from './props-capturer.js'; +import { StateCapturer } from './state-capturer.js'; + +// Real serializer + redaction: truncation must not erase a secret marker after +// a payload has already been retained. Both framework capture paths use these. +describe.each(['props', 'state'] as const)( + '%s marked-record privacy', + (kind) => { + function capture(data: Record, options = {}) { + const adapter: FrameworkAdapter = { + name: 'fixture', + getComponentInstance: () => data, + captureProps: () => data, + captureState: () => data, + }; + return kind === 'props' + ? new PropsCapturer(adapter, options).capture({}) + : new StateCapturer(adapter, options).capture({}); + } + it.each([{ maxProperties: 1 }, { maxTotalBytes: 1 }, {}])( + 'redacts a payload preceding its marker under %j', + (options) => { + const record = { + arbitraryPayload: 'private-marked-fixture', + ...Object.fromEntries( + Array.from({ length: 55 }, (_, i) => [`filler${i}`, i]), + ), + secret: true, + }; + const result = capture({ envs: [record] }, options); + expect(result.success).toBe(true); + expect(JSON.stringify(result.data)).not.toContain( + 'private-marked-fixture', + ); + expect(result.data).toMatchObject({ envs: ['[REDACTED]'] }); + }, + ); + it('handles a marked cyclic record before walking its properties', () => { + const record: Record = { + payload: 'private-cyclic-fixture', + secret: true, + }; + record.self = record; + expect(capture({ envs: [record] }).data).toEqual({ + envs: ['[REDACTED]'], + }); + }); + it('preserves the explicit redaction opt-out', () => { + const result = capture( + { envs: [{ payload: 'local-debug-fixture', secret: true }] }, + { redactPII: false }, + ); + expect(result.data).toMatchObject({ + envs: [{ payload: 'local-debug-fixture', secret: true }], + }); + }); + }, +); diff --git a/packages/domscribe-runtime/src/capture/state-capturer.ts b/packages/domscribe-runtime/src/capture/state-capturer.ts index 11c107b..b16bf81 100644 --- a/packages/domscribe-runtime/src/capture/state-capturer.ts +++ b/packages/domscribe-runtime/src/capture/state-capturer.ts @@ -49,6 +49,7 @@ export class StateCapturer { maxProperties: this.options.maxProperties, maxTotalBytes: this.options.maxTotalBytes, includeFunctions: false, + redactSecretRecords: this.options.redactPII !== false, skipKeys: this.options.skipKeys, skipKeyPrefixes: this.options.skipKeyPrefixes, }); diff --git a/packages/domscribe-runtime/src/capture/types.ts b/packages/domscribe-runtime/src/capture/types.ts index 05545a5..b7f5866 100644 --- a/packages/domscribe-runtime/src/capture/types.ts +++ b/packages/domscribe-runtime/src/capture/types.ts @@ -27,6 +27,9 @@ export interface CaptureResult { * Options for context capture */ export interface CaptureOptions { + /** Manifest source file whose owning component should be captured, if provable. */ + sourceFile?: string; + /** * Include props in capture (Phase 1) */ diff --git a/packages/domscribe-runtime/src/core/context-capturer.ts b/packages/domscribe-runtime/src/core/context-capturer.ts index 8ec9d4d..af02ca0 100644 --- a/packages/domscribe-runtime/src/core/context-capturer.ts +++ b/packages/domscribe-runtime/src/core/context-capturer.ts @@ -149,10 +149,29 @@ export class ContextCapturer { return null; } + const owner = options.sourceFile + ? this.options.adapter.resolveSourceComponent?.( + element, + options.sourceFile, + ) + : null; + const capturedComponent = owner?.component ?? componentInstance; const context = await this.captureForComponent( - componentInstance, + capturedComponent, options, ); + context.componentIdentity = { + name: this.options.adapter + .getComponentName?.(capturedComponent) + ?.slice(0, 256), + sourceFile: owner?.sourceFile.slice(0, 4096), + requestedSourceFile: options.sourceFile?.slice(0, 4096), + sourceMatch: owner + ? 'matched' + : options.sourceFile + ? 'unavailable' + : 'nearest', + }; this.attachStyles(context, element, options); return context; } catch (error) { diff --git a/packages/domscribe-runtime/src/core/runtime-manager.ts b/packages/domscribe-runtime/src/core/runtime-manager.ts index 355074c..0495731 100644 --- a/packages/domscribe-runtime/src/core/runtime-manager.ts +++ b/packages/domscribe-runtime/src/core/runtime-manager.ts @@ -251,12 +251,13 @@ export class RuntimeManager { /** Never capture context until a target has resolved to one exact instance. */ async queryTargets( query: TargetQueryInput, + options: CaptureOptions = {}, ): Promise { const result = this.resolveTargets(query); if (result.status !== 'resolved') return result; const element = this.targets?.getElement(result.targets[0].instanceId); if (!element) return { status: 'stale', targets: [], total: 0 }; - const context = await this.captureContextForElement(element); + const context = await this.captureContextForElement(element, options); if (!this.targets?.getElement(result.targets[0].instanceId)) return { status: 'stale', targets: [], total: 0 }; return { ...result, context: context ?? undefined }; diff --git a/packages/domscribe-runtime/src/index.ts b/packages/domscribe-runtime/src/index.ts index c3c141d..2e10b5f 100644 --- a/packages/domscribe-runtime/src/index.ts +++ b/packages/domscribe-runtime/src/index.ts @@ -19,7 +19,10 @@ export type { IRuntimeTransport } from './bridge/transport.interface.js'; // Configuration types export type { DomscribeRuntimeOptions } from './core/types.js'; -export type { SerializationConstraints } from './capture/types.js'; +export type { + CaptureOptions, + SerializationConstraints, +} from './capture/types.js'; // Style capture (RFC 0001) export { diff --git a/packages/domscribe-runtime/src/targets/dialog-context.spec.ts b/packages/domscribe-runtime/src/targets/dialog-context.spec.ts new file mode 100644 index 0000000..e196274 --- /dev/null +++ b/packages/domscribe-runtime/src/targets/dialog-context.spec.ts @@ -0,0 +1,134 @@ +/** @vitest-environment jsdom */ +import { afterEach, describe, expect, it } from 'vitest'; +import { TargetRegistry } from './target-registry.js'; + +let registry: TargetRegistry; +afterEach(() => { + registry?.dispose(); + document.body.replaceChildren(); +}); +function setup(html: string) { + document.body.innerHTML = html; + registry = new TargetRegistry({ + document, + blocked: () => false, + redact: true, + }); + return document.querySelector('button')!; +} +describe('portable identity across reused dialogs', () => { + it.each(['role="dialog"', 'role="alertdialog"', 'aria-modal="true"'])( + 'refuses unscoped %s even with an unchanged label', + (attributes) => { + const button = setup( + `
`, + ); + const target = registry.describe(button)!; + expect(target.locator.unsupportedReason).toBe('context_required'); + expect(registry.query({ locator: target.locator })).toMatchObject({ + status: 'unsupported', + reason: 'context_required', + total: 0, + }); + expect(registry.query({ instanceId: target.instanceId }).status).toBe( + 'resolved', + ); + }, + ); + it('binds the business context and invalidates a retained DOM handle when it changes', () => { + const button = setup( + '', + ); + const alpha = registry.describe(button)!; + expect(registry.query({ locator: alpha.locator }).status).toBe('resolved'); + button.parentElement!.setAttribute('data-ds-context', 'collection:beta'); + expect(registry.query({ locator: alpha.locator }).status).toBe('not_found'); + expect(registry.query({ instanceId: alpha.instanceId }).status).toBe( + 'stale', + ); + }); + it('refuses a legacy locator even when the new dialog supplies a context key', () => { + const button = setup( + '', + ); + const legacy = registry.describe(button)!.locator; + delete (legacy as { contexts?: string[] }).contexts; + legacy.ancestors = []; + expect(registry.query({ locator: legacy })).toMatchObject({ + status: 'unsupported', + reason: 'context_required', + }); + }); + it('does not borrow keys from siblings or an outer dialog', () => { + const button = setup( + '
', + ); + expect(registry.describe(button)!.locator.unsupportedReason).toBe( + 'context_required', + ); + }); + it('supports a context on the selected ancestor path inside each nested dialog', () => { + const button = setup( + '
', + ); + const target = registry.describe(button)!; + expect(target.locator).toMatchObject({ contexts: ['inner', 'outer'] }); + expect(target.locator.unsupportedReason).toBeUndefined(); + expect(registry.query({ locator: target.locator }).status).toBe('resolved'); + }); + it('requires context when selecting the dialog itself', () => { + setup(''); + expect( + registry.describe(document.querySelector('dialog')!)!.locator + .unsupportedReason, + ).toBe('context_required'); + }); + it('redacts private context and refuses exported replay', () => { + const button = setup( + '', + ); + const target = registry.describe(button)!; + expect(JSON.stringify(target)).not.toContain('person@example.com'); + expect(registry.query({ locator: target.locator }).status).toBe( + 'not_found', + ); + }); + it.each(['', ' '])('refuses an empty dialog context %j', (key) => { + const button = setup( + ``, + ); + expect(registry.describe(button)!.locator.unsupportedReason).toBe( + 'context_required', + ); + }); + it('bounds context keys before export and refuses overflow', () => { + const button = setup( + ``, + ); + const target = registry.describe(button)!; + expect(target.locator.contexts![0].length).toBeLessThanOrEqual(256); + expect(target.locator.unsupportedReason).toBe('identity_too_long'); + expect(registry.query({ locator: target.locator }).status).toBe( + 'unsupported', + ); + }); + it('refuses context-chain overflow and a changed outer business key', () => { + const button = setup( + '
', + ); + const target = registry.describe(button)!; + document.querySelector('dialog')!.setAttribute('data-ds-context', 'other'); + expect(registry.query({ locator: target.locator }).status).toBe( + 'not_found', + ); + const wraps = Array.from( + { length: 5 }, + (_, i) => `
`, + ).join(''); + document.body.innerHTML = + wraps + '' + '
'.repeat(5); + const overflow = registry.describe(document.querySelector('button')!)!; + expect(overflow.locator.contexts).toHaveLength(4); + expect(overflow.locator.unsupportedReason).toBe('identity_too_long'); + }); +}); diff --git a/packages/domscribe-runtime/src/targets/target-registry.ts b/packages/domscribe-runtime/src/targets/target-registry.ts index bd175ed..8b6cd91 100644 --- a/packages/domscribe-runtime/src/targets/target-registry.ts +++ b/packages/domscribe-runtime/src/targets/target-registry.ts @@ -11,6 +11,7 @@ import { const IDENTITY_ATTRIBUTES = [ 'id', + 'data-ds-context', 'data-testid', 'data-test', 'aria-label', @@ -128,18 +129,45 @@ export class TargetRegistry { } else if (query.locator) { const locator = query.locator; if (locator.unsupportedReason) - return { status: 'unsupported', targets: [], total: 0 }; + return { + status: 'unsupported', + reason: locator.unsupportedReason, + targets: [], + total: 0, + }; if (locator.pathname !== this.options.document.location.pathname) { return { status: 'wrong_page', targets: [], total: 0 }; } if (JSON.stringify(locator).includes('[REDACTED')) { return { status: 'not_found', targets: [], total: 0 }; } - elements = Array.from( + const candidates = Array.from( this.options.document.getElementsByTagName(locator.element.tagName), ) .filter((element): element is HTMLElement => this.eligible(element)) .filter((element) => this.matches(element, locator)); + // Old locators carry no proof of dialog business identity. Even a newly + // keyed candidate cannot retroactively provide that missing evidence. + if ( + !locator.contexts && + candidates.some( + (element) => this.dialogContexts(element).contexts.length, + ) + ) + return { + status: 'unsupported', + reason: 'context_required', + targets: [], + total: 0, + }; + elements = candidates.filter((element) => { + const current = this.dialogContexts(element); + return ( + !current.missing && + JSON.stringify(current.contexts) === + JSON.stringify(locator.contexts ?? []) + ); + }); } else { elements = Array.from( this.options.document.querySelectorAll('[data-ds]'), @@ -301,6 +329,33 @@ export class TargetRegistry { : normalize(chunks.join('')); } + private dialogContexts(element: HTMLElement): { + contexts: string[]; + missing: boolean; + } { + const contexts: string[] = []; + let missing = false; + let key: string | null = null; + let current: HTMLElement | null = element; + while (current) { + // Only keys on this ancestor path count, never siblings or a different + // dialog. Reset at each boundary so an outer key cannot cover an inner one. + const value = current.getAttribute('data-ds-context'); + if (value?.trim()) key = value; + if ( + current.matches( + 'dialog, [role="dialog"], [role="alertdialog"], [aria-modal="true"]', + ) + ) { + if (!key) missing = true; + contexts.push(key ?? '[CONTEXT_REQUIRED]'); + key = null; + } + current = current.parentElement; + } + return { contexts, missing }; + } + private locator( element: HTMLElement, redactSegments = false, @@ -317,8 +372,11 @@ export class TargetRegistry { ancestors.push(identity); ancestor = ancestor.parentElement; } + const dialog = this.dialogContexts(element); const locator: PortableLocator = { version: 1, + contexts: dialog.contexts.length ? dialog.contexts : undefined, + unsupportedReason: dialog.missing ? 'context_required' : undefined, pathname: this.options.document.location.pathname, element: this.identity(element), ancestors, @@ -328,9 +386,10 @@ export class TargetRegistry { private boundLocator(locator: PortableLocator): PortableLocator { let unsupported = - !!locator.unsupportedReason || locator.pathname.length > 2048 || - locator.ancestors.length > 4; + locator.ancestors.length > 4 || + (locator.contexts?.length ?? 0) > 4 || + !!locator.contexts?.some((key: string) => key.length > 256); const bound = (identity: LocatorIdentity): LocatorIdentity => { if ( identity.tagName.length > 64 || @@ -357,12 +416,17 @@ export class TargetRegistry { const result = { ...locator, pathname: locator.pathname.slice(0, 2048), + contexts: locator.contexts + ?.slice(0, 4) + .map((key: string) => key.slice(0, 256)), element: bound(locator.element), ancestors: locator.ancestors.slice(0, 4).map(bound), }; return { ...result, - unsupportedReason: unsupported ? 'identity_too_long' : undefined, + unsupportedReason: unsupported + ? 'identity_too_long' + : locator.unsupportedReason, }; } diff --git a/packages/domscribe-runtime/src/utils/serialization-utils.ts b/packages/domscribe-runtime/src/utils/serialization-utils.ts index 86cd269..358e03c 100644 --- a/packages/domscribe-runtime/src/utils/serialization-utils.ts +++ b/packages/domscribe-runtime/src/utils/serialization-utils.ts @@ -13,6 +13,9 @@ import type { SerializationConstraints } from '../capture/types.js'; * are set by the capture layer, not by end users. */ export interface SerializationOptions extends SerializationConstraints { + /** Redact records explicitly marked secret before truncation can hide their marker. */ + redactSecretRecords?: boolean; + /** * Whether to include function references * @default false diff --git a/packages/domscribe-runtime/src/utils/serialization.ts b/packages/domscribe-runtime/src/utils/serialization.ts index a254f22..b03c238 100644 --- a/packages/domscribe-runtime/src/utils/serialization.ts +++ b/packages/domscribe-runtime/src/utils/serialization.ts @@ -107,6 +107,7 @@ export function serializeValue( maxProperties = 50, maxTotalBytes = 262144, includeFunctions = false, + redactSecretRecords = false, replacer, skipKeys, skipKeyPrefixes, @@ -191,6 +192,15 @@ export function serializeValue( // Handle objects and arrays if (type === 'object' && val !== null && typeof val === 'object') { + // Inspect the own data marker before enumerating properties or applying + // budgets within this record. Never invoke an arbitrary marker getter. + if ( + redactSecretRecords && + Object.getOwnPropertyDescriptor(val, 'secret')?.value === true + ) { + trackBytes('[REDACTED]'); + return '[REDACTED]'; + } // Check for circular reference if (seen.has(val)) { return SENTINEL_REF.CIRCULAR; diff --git a/packages/domscribe-vue/src/adapter/source-context.spec.ts b/packages/domscribe-vue/src/adapter/source-context.spec.ts new file mode 100644 index 0000000..c2c0f66 --- /dev/null +++ b/packages/domscribe-vue/src/adapter/source-context.spec.ts @@ -0,0 +1,159 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { createApp, defineComponent, h, Teleport, type App } from 'vue'; +import { RuntimeManager } from '@domscribe/runtime'; +import type { VueComponentInstance } from '../internals/types.js'; +import { VueAdapter } from './vue-adapter.js'; + +let app: App; +let host: HTMLElement; +let runtime: RuntimeManager; + +beforeEach(async () => { + host = document.createElement('div'); + document.body.append(host); + const Anchor = Object.assign( + defineComponent({ + name: 'SharedAnchor', + props: { appearance: String }, + setup: () => () => h('button', 'Generate Token'), + }), + { __file: '/vendor/Anchor.vue' }, + ); + const Button = Object.assign( + defineComponent({ + name: 'SharedButton', + setup: () => () => h(Anchor, { appearance: 'quiet' }), + }), + { __file: '/vendor/Button.vue' }, + ); + const Owner = Object.assign( + defineComponent({ + name: 'CollectionOAuth', + props: { recordId: String, clientSecret: String, envs: Array }, + setup: () => () => h(Teleport, { to: document.body }, h(Button)), + }), + { __file: '/app/components/OAuth2.vue' }, + ); + app = createApp(Owner, { + recordId: 'alpha', + clientSecret: 'private-fixture-value', + envs: [ + { + key: 'TOKEN', + secret: true, + currentValue: 'private-environment-value', + initialValue: 'private-environment-initial', + }, + { key: 'PUBLIC', secret: false, currentValue: 'public-setting' }, + ], + }); + app.mount(host); + window.__DOMSCRIBE_RELAY_PORT__ = 4500; + runtime = RuntimeManager.getInstance(); + await runtime.initialize({ adapter: new VueAdapter() }); +}); + +afterEach(() => { + app?.unmount(); + host?.remove(); + RuntimeManager.resetInstance(); + delete window.__DOMSCRIBE_RELAY_PORT__; +}); + +describe('source-owning context through shared Vue controls', () => { + it('captures the manifest owner across wrappers and Teleport with redaction', async () => { + const options = { + includeProps: true, + sourceFile: '/app/components/OAuth2.vue', + }; + const context = await runtime.captureContextForElement( + document.querySelector('button')!, + options, + ); + + expect(context?.componentProps).toMatchObject({ + recordId: 'alpha', + clientSecret: '[REDACTED]', + }); + expect(context).toMatchObject({ + componentIdentity: { + name: 'CollectionOAuth', + sourceFile: '/app/components/OAuth2.vue', + sourceMatch: 'matched', + }, + }); + expect(JSON.stringify(context)).not.toContain('private-fixture-value'); + expect(JSON.stringify(context)).not.toContain('private-environment-'); + expect(context?.componentProps).toMatchObject({ + envs: ['[REDACTED]', { key: 'PUBLIC', currentValue: 'public-setting' }], + }); + }); + + it('labels unavailable ownership rather than guessing by a duplicate basename', async () => { + const options = { + includeProps: true, + sourceFile: '/different/components/OAuth2.vue', + }; + const context = await runtime.captureContextForElement( + document.querySelector('button')!, + options, + ); + + expect(context?.componentProps).toMatchObject({ appearance: 'quiet' }); + expect(context).toMatchObject({ + componentIdentity: { + name: 'SharedAnchor', + requestedSourceFile: '/different/components/OAuth2.vue', + sourceMatch: 'unavailable', + }, + }); + }); + + it('honors props/state opt-outs after selecting the source owner', async () => { + const options = { + includeProps: false, + includeState: false, + sourceFile: '/app/components/OAuth2.vue', + }; + const context = await runtime.captureContextForElement( + document.querySelector('button')!, + options, + ); + + expect(context?.componentProps).toBeUndefined(); + expect(context?.componentState).toBeUndefined(); + expect(context).toMatchObject({ + componentIdentity: { name: 'CollectionOAuth', sourceMatch: 'matched' }, + }); + }); + it('bounds cyclic ancestry and preserves exact functional-owner metadata', () => { + const element = document.querySelector('button')!; + const adapter = new VueAdapter(); + const component = adapter.getComponentInstance( + element, + ) as VueComponentInstance; + const parent = component.parent; + component.parent = component; + expect( + adapter.resolveSourceComponent(element, '/missing/OAuth2.vue'), + ).toBeNull(); + component.parent = parent; + expect( + new VueAdapter({ maxTreeDepth: 1 }).resolveSourceComponent( + element, + '/app/components/OAuth2.vue', + ), + ).toBeNull(); + const originalType = component.type; + component.type = Object.assign( + function FunctionalOwner() { + return null; + }, + { __file: '/app/Functional.tsx' }, + ); + expect( + adapter.resolveSourceComponent(element, '/app/Functional.tsx')?.component, + ).toBe(component); + component.type = originalType; + }); +}); diff --git a/packages/domscribe-vue/src/adapter/vue-adapter.ts b/packages/domscribe-vue/src/adapter/vue-adapter.ts index f51bf09..5fbe64b 100644 --- a/packages/domscribe-vue/src/adapter/vue-adapter.ts +++ b/packages/domscribe-vue/src/adapter/vue-adapter.ts @@ -22,6 +22,7 @@ import { checkVNodeAccess, findNearestUserComponent, } from '../internals/component-resolver.js'; +import { normalizeSourceFile } from '../internals/source-file.js'; import { extractProps } from '../internals/props-extractor.js'; import { extractState } from '../internals/state-extractor.js'; import { isVueComponentInstance, isRecord } from '../internals/type-guards.js'; @@ -150,6 +151,25 @@ export class VueAdapter implements VueFrameworkAdapter { * @param component - The component instance * @returns Serialized props object or null */ + resolveSourceComponent(element: HTMLElement, sourceFile: string) { + const requested = normalizeSourceFile(sourceFile); + if (!requested || !this.state.initialized) return null; + let current = resolveComponentFromElement(element).instance; + const visited = new Set(); + for (let depth = 0; current && depth < this.options.maxTreeDepth; depth++) { + if (visited.has(current)) break; + visited.add(current); + const file = + typeof current.type === 'object' || typeof current.type === 'function' + ? current.type.__file + : undefined; + if (file && normalizeSourceFile(file) === requested) + return { component: current, sourceFile: requested }; + current = current.parent ?? undefined; + } + return null; + } + captureProps(component: unknown): Nullable> { if (!this.state.initialized || !isVueComponentInstance(component)) { return null; diff --git a/packages/domscribe-vue/src/internals/source-file.spec.ts b/packages/domscribe-vue/src/internals/source-file.spec.ts new file mode 100644 index 0000000..a623d34 --- /dev/null +++ b/packages/domscribe-vue/src/internals/source-file.spec.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { normalizeSourceFile } from './source-file.js'; +describe('source file namespaces', () => { + it.each([ + ['/app/components/OAuth2.vue', '/app/components/OAuth2.vue'], + ['file:///app/my%20components/OAuth2.vue', '/app/my components/OAuth2.vue'], + ['/ @fs/foo.vue', '/ @fs/foo.vue'], + ['/@fs//app/OAuth2.vue', '/app/OAuth2.vue'], + ['C:\\app\\OAuth2.vue', 'C:/app/OAuth2.vue'], + ['file:///C:/app/OAuth2.vue', 'C:/app/OAuth2.vue'], + ])('normalizes %s without losing source identity', (input, expected) => { + expect(normalizeSourceFile(input)).toBe(expected); + }); + it.each([ + 'OAuth2.vue', + 'src/OAuth2.vue', + 'https://example.com/OAuth2.vue', + 'file://other/app/OAuth2.vue', + '/app/../other/OAuth2.vue', + ])('refuses an unproved namespace %s', (input) => { + expect(normalizeSourceFile(input)).toBeNull(); + }); +}); diff --git a/packages/domscribe-vue/src/internals/source-file.ts b/packages/domscribe-vue/src/internals/source-file.ts new file mode 100644 index 0000000..2eefac1 --- /dev/null +++ b/packages/domscribe-vue/src/internals/source-file.ts @@ -0,0 +1,24 @@ +/** Compare only absolute filesystem namespaces, never basenames or suffixes. */ +export function normalizeSourceFile(value: string): string | null { + let path = value.replace(/\\/g, '/'); + if (path.startsWith('file://')) { + try { + const url = new URL(path); + if (url.hostname && url.hostname !== 'localhost') return null; + path = decodeURIComponent(url.pathname); + } catch { + return null; + } + } else { + if (path.startsWith('/@fs/')) { + path = path.slice(5); + if (!/^[A-Za-z]:\//.test(path)) path = '/' + path.replace(/^\/+/, ''); + } + } + if (/^\/[A-Za-z]:\//.test(path)) path = path.slice(1); + if (!path.startsWith('/') && !/^[A-Za-z]:\//.test(path)) return null; + // Do not collapse ambiguous dot segments supplied by unknown module namespaces. + if (path.split('/').some((part) => part === '.' || part === '..')) + return null; + return path; +} diff --git a/packages/domscribe-vue/src/internals/types.ts b/packages/domscribe-vue/src/internals/types.ts index df51f42..d48393b 100644 --- a/packages/domscribe-vue/src/internals/types.ts +++ b/packages/domscribe-vue/src/internals/types.ts @@ -35,6 +35,7 @@ export interface VueVNode { * @internal */ export interface VueComponentType { + __file?: string; name?: string; __name?: string; displayName?: string; @@ -64,6 +65,7 @@ export interface VueComponentType { * @internal */ export type VueFunctionalComponent = ((...args: unknown[]) => unknown) & { + __file?: string; name?: string; displayName?: string; }; diff --git a/packages/domscribe-vue/src/vite/vite-plugin.spec.ts b/packages/domscribe-vue/src/vite/vite-plugin.spec.ts index c252400..25cbad1 100644 --- a/packages/domscribe-vue/src/vite/vite-plugin.spec.ts +++ b/packages/domscribe-vue/src/vite/vite-plugin.spec.ts @@ -12,6 +12,7 @@ vi.mock('@domscribe/transform/plugins/vite', () => ({ })); import { domscribe } from './vite-plugin.js'; +import { mergeConfig, type UserConfig } from 'vite'; describe('domscribe (vue/vite)', () => { it('should rename the plugin to vite-plugin-domscribe-vue', () => { @@ -27,6 +28,46 @@ describe('domscribe (vue/vite)', () => { expect(plugin.name).toBeDefined(); }); + describe('injected dependency optimization', () => { + it('preserves application includes and explicit runtime/overlay exclusions', () => { + const plugin = domscribe(); + const user: UserConfig = { + optimizeDeps: { + include: ['application-dependency'], + exclude: ['@domscribe/runtime', '@domscribe/overlay'], + }, + }; + const configure = plugin.config as (config: UserConfig) => UserConfig; + const merged = mergeConfig(user, configure(user)); + expect(merged.optimizeDeps?.include).toContain('application-dependency'); + expect(merged.optimizeDeps?.include).toContain('@domscribe/vue'); + expect( + merged.optimizeDeps?.include?.some((id) => + id.endsWith(' > @domscribe/runtime'), + ), + ).toBe(false); + expect( + merged.optimizeDeps?.include?.some((id) => + id.endsWith(' > @domscribe/overlay'), + ), + ).toBe(false); + expect(merged.optimizeDeps?.exclude).toEqual(user.optimizeDeps?.exclude); + }); + it('does not require overlay optimization when overlay is disabled', () => { + const plugin = domscribe({ overlay: false }); + const configure = plugin.config as (config: UserConfig) => UserConfig; + const config = configure({}); + expect(config.optimizeDeps?.include).toContain( + '@domscribe/vue > @domscribe/runtime', + ); + expect( + config.optimizeDeps?.include?.some((id) => + id.includes('@domscribe/overlay'), + ), + ).toBe(false); + }); + }); + describe('resolveId', () => { it('should resolve the init module path', () => { const plugin = domscribe(); diff --git a/packages/domscribe-vue/src/vite/vite-plugin.ts b/packages/domscribe-vue/src/vite/vite-plugin.ts index c2e6e8a..edac2db 100644 --- a/packages/domscribe-vue/src/vite/vite-plugin.ts +++ b/packages/domscribe-vue/src/vite/vite-plugin.ts @@ -52,6 +52,29 @@ export function domscribe(options?: DomscribeVuePluginOptions): Plugin { basePlugin.name = 'vite-plugin-domscribe-vue'; + // The virtual initializer is invisible to the HTML dependency scan. Declare + // its graph before first navigation, resolving transitive modules through + // the installed adapter so isolated pnpm consumers need no hoisted imports. + basePlugin.config = (config) => { + const dependencies = [ + '@domscribe/vue', + '@domscribe/vue > @domscribe/runtime', + ...(options?.overlay === false + ? [] + : ['@domscribe/vue > @domscribe/transform > @domscribe/overlay']), + ]; + const excluded = config.optimizeDeps?.exclude ?? []; + return { + optimizeDeps: { + include: dependencies.filter( + (id) => + !excluded.includes(id) && + !excluded.includes(id.split(' > ').at(-1)!), + ), + }, + }; + }; + basePlugin.resolveId = function (id, importer, ...args) { if (id === INIT_MODULE_PATH) { return INIT_MODULE_PATH; diff --git a/scripts/fixtures/replay-consumer.mjs b/scripts/fixtures/replay-consumer.mjs index 7a44982..d1b768e 100644 --- a/scripts/fixtures/replay-consumer.mjs +++ b/scripts/fixtures/replay-consumer.mjs @@ -60,7 +60,7 @@ const city = ref(props.customer.city); const confirmed = ref(false);