The pass-1 late-init fix captured `managed` before the fallback reset was issued,
so a host `init` landing DURING the reset round-trip (up to ~30s with the XHR
timeout + retry) left `handle_init` firing an unregistered hook (no-op) while
`complete()` re-checked the stale flag -- neither emitting `ready` nor arming a
hook that could still fire. Wedge.
Decide readiness from the LIVE token in `complete()`: token present since capture
-> emit `ready` directly; token arrived during the round-trip -> run the managed
recovery inline; token still absent -> register the one-shot LATE_INIT_HOOK. The
inline and hook paths share one guarded `run_late_init_recovery` (tokened reset ->
emit_ready), so `ready` cannot double-fire. Add two source-order structural tests
(handle_init emits no `ready` directly; completion re-checks bridge_token live).
The `if undoable { mark_document_changed() }` added to the live-sync glue was
redundant: `replace_document_with_undo` already bumps the content revision via
`history_push_past` -> `mark_document_changed`, so an undoable external apply is
dirty by construction. Remove the glue's manual bump (and its now-wrong comment);
the post-apply pair capture + `note_synced` stay put, since that pair already
carries the history bump.
Retarget the editor-core test to lock the real invariant:
`replace_document_with_undo` ALONE leaves the state dirty (revision != saved),
while plain `replace_document` stays clean -- so a future refactor of
`history_push_past` can't silently break external-apply dirtiness.
Two independent paths could leave the VS Code webview waiting forever for a
`ready` it never receives.
Late init: `mount_ck` captured `managed` once after `await_init`'s 2s timeout,
so an `init` arriving later stored the token but never re-ran the managed
bootstrap. Add a one-shot `LATE_INIT_HOOK` that the fallback (unmanaged)
bootstrap registers after its own reset completes; `handle_init` takes and fires
it, re-running a tokened sync-reset whose completion emits `ready`. Registration
is gated on `!managed && is_iframe` and happens only post-fallback-reset so the
two resets cannot interleave.
Stalled reset: `start_bootstrap_reset` used `post_json` (no XHR timeout), so a
hung connection fired neither success nor error. Route it through
`post_json_with_status`, which arms a timeout and delivers status 0 + empty body
on timeout, landing on the existing retry-then-complete-with-warning path.
The undoable external-apply path (`replace_document_with_undo`) leaves
revision == saved_revision, so an AI turn or MCP client write applied
into a live session reported `is_dirty() == false`. The bridge then
emitted `dirty:false` for genuinely unsaved daemon-side edits, and
closing the tab dropped them without a save prompt — the spec requires
MCP edits to mark the tab dirty.
Fix at the wiring level (leave `replace_document*` semantics intact so
opens stay clean): the live-sync glue's apply path now bumps the content
revision via `mark_document_changed()` after a successful undoable apply,
BEFORE capturing the post-apply pair for `note_synced`. Ordering:
apply → mark_document_changed (undoable only) → post_apply pair →
note_synced(post_apply). The gate baseline then equals the current pair
(no spurious echo-push next tick) while `is_dirty()` is true (revision 1
vs saved 0) so the observer emits `dirty:true`. The bootstrap apply
(undoable == false) stays clean.
The glue ordering is wasm-only (compile-gated); a native op-editor-core
test locks the primitives it relies on: replace_document_with_undo +
mark_document_changed is dirty with revision != saved_revision, while
the plain replace_document open path stays clean.
The managed webview emitted `ready` from `handle_init` the moment the
host's `init` landed, but the bootstrap sync-reset was issued only after
`await_init` resolved — independently and still in flight. The host,
seeing `ready`, could send `open-document`; its push landed on the
daemon, then the in-flight reset reset the daemon to its `--file`
content and bumped the version, and the next pull tick pulled that reset
document over the just-opened canvas (silent overwrite).
Serialize `ready` strictly after the reset: `handle_init` no longer
emits it, and canvaskit's managed bootstrap posts it (via the new
`emit_ready`) from the reset-completion callback, then starts the
live-sync ticks. `ready` is a request/response reply, not an edge event,
so a direct post keeps single-point edge discipline intact. A
transport/server reset error retries once then proceeds anyway with a
console warning — a wedged webview that never says `ready` is worse than
one on a best-effort daemon; a peer `"skipped":true` reply counts as
completion. Direct-open (no bridge token) is unchanged.
Task 7 reordered mount_ck so the daemon-dependent
web_credential_sync::start() runs only after the postMessage bridge
init gate. But start() bundled two things: a pure state reset
(*self = default) and a daemon policy fetch. Moving the whole call past
the gate also moved the reset past repaint_coalescer::install, so an
early repaint (e.g. during the 2s await_init in an iframe) could queue a
credential change via credential_changed() that the later reset silently
wiped — the pre-existing invariant guarded by
canvaskit_repaint_persists_local_settings_and_syncs_only_credential_changes.
Split the two phases: add web_credential_sync::reset() (pure state
clear, no daemon request) called BEFORE the first repaint and the rAF
coalescer install, and keep start() (now begin_policy_check only, the
daemon fetch) after the bridge gate. Both invariants hold: the reset
precedes repaint wiring, and no daemon request fires before the gate.
The daemon behavior is unchanged in the normal flow (reset() then
begin_policy_check equals the old start()), and strictly better in the
race edge — a change queued between reset and start is now preserved
instead of wiped.
Test updated to assert the reset (not the now-daemon-facing start)
precedes install; the semantic invariant it protects is unchanged and
is now expressed against the actual code order. start()'s ordering after
the gate stays covered by
canvaskit_mount_queues_an_initial_snapshot_only_when_local_credentials_exist.
The Task 7 token audit missed a daemon-bound network call:
`agent_settings_mcp_server.rs::request_mcp_server_update` POSTs
`/api/mcp/server` via `window.fetch` (Reflect::get) and attached no
`X-OpenPencil-Token`. In managed mode (VS Code webview) the daemon's
auth gate rejects the request (fails closed), silently breaking the
MCP server start/stop toggle in the settings modal.
Fix: build the request URL from `daemon_base()` (absolute, matching
every other daemon call site in the crate) instead of a bare relative
path, and attach the token via a new shared `live_sync::daemon_token_for`
helper — factored out of `attach_daemon_headers` so XHR and non-XHR
call sites can't drift on the leak-guard policy (token only when set
AND the URL targets the daemon).
Corrected audit (grep -rn "XmlHttpRequest|fetch|Request::new"
crates/op-host-web/src, network-issuing sites only): 5 sites total —
the 4 live_sync XHR helpers + web_model_catalog + web_ai_transport +
iconify_web's fetch_text all already tokened via attach_daemon_headers
(iconify_web additionally verified to withhold the token from the
public Iconify CDN target); agent_settings_mcp_server's window.fetch
was the sole untokened site and is now fixed. No other window.fetch /
Request::new call sites exist in the crate.
Verification: cargo check --target wasm32-unknown-unknown -p
op-host-web --no-default-features --features canvaskit (pass, 1
pre-existing unrelated warning) / --features web (pass, clean);
cargo clippy -p op-host-web --all-targets -- -D warnings (pass, clean).
Add the webview-facing postMessage bridge (vscode_bridge.rs): token
bootstrap via Init, OpenDocument probe-conditional push, uncapped
Snapshot flush, SaveCommitted, and UseLocal/AcceptRemote conflict
resolution. All three edge/state events (dirty-changed, sync-conflict,
opened) are emitted only from the tick observer draining SyncGate's
consumable latches; handlers only mutate the gate. Reorder mount_ck so
the bridge listener installs first, an iframe awaits Init (2s fallback),
and the 400ms pull tick starts only after the daemon sync-reset (managed:
token; direct: legacy reset moved out of index.html) completes.
Token audit (X-OpenPencil-Token attached only when url starts with
daemon_base(); public requests never carry it):
live_sync.rs get -> daemon -> attach_daemon_headers
live_sync.rs get_with_status -> daemon -> attach_daemon_headers
live_sync.rs post_json -> daemon -> attach_daemon_headers
live_sync.rs post_json_with_status -> daemon -> attach_daemon_headers
web_model_catalog.rs:21 (/api/ai/models) -> daemon -> attach_daemon_headers
web_ai_transport.rs:93 (/api/ai/stream) -> daemon -> attach_daemon_headers
iconify_web.rs:311 fetch_text (daemon brand-catalog + public Iconify CDN)
-> attach_daemon_headers with url-prefix guard: token only on the
daemon URL, never on the public api.iconify.design requests
All other daemon-talking modules route through the live_sync helpers and
inherit the token. No window.fetch / Request::new sites in the crate.