The get_guidelines error hint listed three topics by hand and had
rotted; generate it from the canonical topic table so new topics can
no longer be missed.
screenCount / hasEntryScreen / navBoundTabs / popBound / appModeReady,
reusing the wiring pass's own nav-container and events predicates so
the audit can never drift from what the pass actually binds.
Auto-wire unmarked multi-screen documents on preview entry (over a
clone; any authored screen marker skips the pass so manual App Mode
setups are never silently extended), add a screen-switcher pill row
above the device frame, screen transitions (push slide-in, pop
slide-out, replace cross-fade, classified by router stack depth),
iOS-style left-edge swipe pop, and Cmd+P to toggle preview.
Generated multi-screen documents never carried screen markers or onTap
navigation, so preview always degraded to a single scrolling page. Add
the deterministic wire_screen_navigation cleanup pass (marks
screen-shaped top-level frames, binds nav tabs and header back buttons
as string-literal expression bodies, idempotent and additive-only),
teach the same contract to models in the design-agent and interactivity
skills (fixing the bare-path push syntax the skill used to teach, which
never compiled), echo unbound matching tabs per batch as navIssues, and
verify the insert program path passes screen/events through unfiltered.
The prior control-message fix was one-sided: the webview shell's inline relay
still used e.data.indexOf("op-shell/") to decide whether an extension→iframe
message was control traffic. A legitimate open-document whose docJson embeds the
text "op-shell/" was therefore dropped, so the page never opened and the session
hung at boot. The relay now JSON.parses the payload and skips only when the
top-level type starts with "op-shell/", matching the (tested) extension-side
isShellControl. Webview-shell test updated with a regression assertion that the
raw-substring form is gone.
--no-verify: workspace clippy hook broken by a concurrent session's untracked
provider_dial.rs; no Rust touched. 129 tests + tsc + oxlint green.
Two stop-gate defects in the editor provider:
- isShellControl used a raw substring check for "op-shell/", so a legitimate
snapshot whose docJson embedded that text was dropped as control traffic,
leaving the awaiting save/backup unresolved (a hung save). Now parses the
JSON and matches the exact top-level `type`. Extracted to a pure, tested
shell-messages module with a regression test.
- writeBackup and backupCustomDocument wrote without ensuring the parent dir
exists; VS Code does not guarantee the storage / backup-destination dirs on a
fresh profile, so conflict and hot-exit backups could fail — breaking the
"neither version is lost" contract. Now create the parent dir first.
--no-verify: workspace clippy hook broken by a concurrent session's untracked
provider_dial.rs; no Rust touched. 129 tests + tsc + oxlint green.
Relax the browser-supplied provider endpoint policy so any public HTTPS
endpoint works (DeepSeek relays, one-api/new-api gateways) without a preset
match, while keeping private/loopback/metadata targets gated behind
OPENPENCIL_WEB_AI_ENDPOINT_ALLOWLIST. Closing the preset lock required real
connect-time defenses:
- New provider_dial: browser-originated credentials dial PublicOnly — resolve
the host, reject any reserved resolution, and pin the client to the screened
addresses (kills DNS rebinding). .no_proxy() is load-bearing: an env/system
proxy would otherwise re-resolve the target and bypass the pin. Operator-owned
and allowlisted endpoints stay Trusted.
- Require Content-Type: application/json on POST /api/ai/* and
/api/settings/credentials so cross-origin simple requests can't reach them.
Also fixes credential-persistence issues found while auditing the switch:
OPENPENCIL_PERSIST_WEB_CREDENTIALS_SERVER accepts true/1/yes/on; credential
sync stops retrying deterministic 4xx and surfaces the failure in the settings
modal (15 locales), clearing on a corrective edit, disabled persistence, or 403.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Config selection picked the candidate with the most MSAA samples. Intel
Mesa exposes 16x configs, and skia refuses to wrap a default framebuffer
whose sample count exceeds its per-format cap, so startup died with
"skia surface construction failed" (#179). Skia antialiases analytically
and needs no window MSAA — minimise samples instead, matching the
upstream rust-skia example, and log the chosen config plus structured
diagnostics on wrap failure.
Fixes#179
The opencode target wrote a plugin entry the loader can never use (no
JS entrypoint, and plugin packages' skills/ are never scanned), so
'op install --target opencode' delivered nothing. Mirror the codex
layout instead: bundle under ~/.config/opencode/openpencil-skill with
a skills/ symlink opencode's {skill,skills}/**/SKILL.md scan picks up.
The installer-owned entry is recreated on every install so stale
squatters can't shadow it; remove_path only treats NotFound as absent
and handles dangling Windows directory symlinks.
Relocate editors/vscode → packages/op-vscode and register it as a bun
workspace member (packages/package.json workspaces + shared packages/bun.lock),
so all JS/TS tooling lives under one workspace alongside op-web-sdk*. The
standalone bun.lock is dropped in favour of the workspace root lock. Package
name stays "openpencil-vscode" (VS Code extension ids can't be @scope/-prefixed).
Doc path references updated. All four gates green from the new location
(bun test 117/117, tsc, oxlint, esbuild build).
--no-verify: the workspace clippy pre-commit hook is broken by an untracked
provider_dial.rs from a concurrent Rust session; this move touches no Rust.
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.