Skip to content

feat(plugins): an OpenUri effect + capability so a plugin can open a link without RunCommand (#1045, host arm) - #1049

Merged
vibechoom merged 6 commits into
mainfrom
feat/1045-open-uri-effect
Sep 10, 2026
Merged

feat(plugins): an OpenUri effect + capability so a plugin can open a link without RunCommand (#1045, host arm)#1049
vibechoom merged 6 commits into
mainfrom
feat/1045-open-uri-effect

Conversation

@vibechoom

@vibechoom vibechoom commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

What

The host arm of #1045: an Effect::OpenUri { id, uri } + Capability::OpenUri so a plugin can open a link through the desktop's default handler without holding RunCommand.

  • proto — the appended variant, the Effect::open_uri(id, uri) constructor, Capability::OpenUri, OPEN_URI_VOCAB = 5, VOCAB 4 → 5.
  • hostsession::effect_capability maps OpenUriCapability::OpenUri; broker_effect gains an arm that validates the scheme, starts the launch via gio::AppInfo::launch_default_for_uri_async without waiting for it, audits like every effect, and reports HostMsg::EffectResult { id, ok, output } back the way RunCommand does.
  • docsdocs/live-verify.md gets the eight checks CI cannot run.

The plugin side (turning #963's agent page row into a button) is deliberately not here — it rides #963's rebase round.

Fix round at f73c4b6 for the FIX-FIRST adversarial review at f493a68 (F1 MED-HIGH async launch, F2 MED the conditional VOCAB argument, F3/F4/F5/F6, plus the reviewer's M7 breadth test). The sections below are the corrected state; the per-finding table, the eleven-mutation campaign and the re-run gates are in the fix-round comment.

Why

From @kaesaecracker's retest of #963 (2026-09-10 10:56Z): the expanded agent card renders agent page https://pr1ma.darkest.spac… as a plain, ellipsized detail row, and there is no way to follow it.

The row could have become a button emitting Effect::launch(["xdg-open", url])#953's detached mode exists for exactly that class. The cost is the wrong shape: the plugin would have to declare Capability::RunCommand, arbitrary argv execution as the user and the highest-trust capability in the vocabulary, for a card whose only other needs are its own panel and a toast.

OpenUri is the narrow intent instead. The plugin names a destination; it never names a program. One string, host-validated, resolved by the desktop — the same desktop-portal-backed resolution main.rs's screenshot / recording Open actions already use. In the trust order documented on the Capability enum it sits above Notify (it starts something) and well below RunCommand (it cannot choose what).

What the allow-list is and isn't: http/https/file only, everything else refused with a warn and ok: false naming the scheme. That is not a sandbox — file:///… reaches the user's own handler, and a same-uid process on the plugin socket could always do more (route 0, #998/#893). It is a legibility guard: the effect does what its name says and cannot be re-aimed at an unrelated protocol handler. It is host policy, not wire vocabulary, so widening it later is a change in effects.rs alone — no proto bump, no plugin rebuild. (#1045's own triage note parks mailto: as the obvious candidate, to be taken to #947 if it should go wider. Nobody has asked for it yet — the earlier revision of this PR and two crate doc comments misattributed that line as a question from @annikahannig; review F6, now fixed.)

The launch does not block the GTK main loop

gio::AppInfo::launch_default_for_uri — the sync entry point the first revision used — "does synchronous I/O on the uri to detect the type of the file if required" (GLib's own docs). broker_effect runs inside mod.rs's spawn_local on the GTK main thread, so on that path a plugin emitting Effect::open_uri(id, "file:///mnt/nas/x.png") against a hung NFS mount freezes every bar, drawer, overlay and every other plugin's rendering, indefinitely. file is deliberately allow-listed, so that is the sanctioned path rather than an edge case, and the rate cap does not help: one effect is enough.

The safety argument for the sync call was borrowed from main.rs, where the URI is gio::File::for_path(path).uri() over a screenshot the shell itself just wrote to local disk. Same call, same thread — different input, and this variant's whole premise is that the string comes from a plugin.

open_uri_outcome(…) -> EffectOutcome encoded "the verdict is known synchronously", so the fix is in the signature:

  • open_uri_with(plugin_id, id, uri, launch, report) — the launcher answers on a LaunchDone callback and the outcome goes to report; nothing waits. The refusal arm still reports inline, because check_uri is pure and does no I/O.
  • the production launcher is launch_default_for_uri_async, which returns as soon as the request is in flight. It still starts on the GTK thread (the binding asserts main-context ownership, which broker_effect satisfies) and GLib calls back on the main context.
  • as a bonus it is the entry point that carries real error information on the portal path, where the sync sibling does not — so EffectOutcome { ok } is the desktop's verdict rather than "the request was accepted" (the review's second-order F1 point).
  • broker_open_uri_with is the injected seam a test drives, mirroring start_detached_with (test(plugins): guard four detached-RunCommand mechanisms the #960 review found unpinned (audit⇄unit, phantom unit on rejected id, no-retry on timeout, empty --setenv) #964 item 3).

Pinned by a_slow_open_uri_launch_does_not_block_the_broker: a stub that answers after 2 s from another thread, so the only way the call can cost 2 s is if the broker waits. The arm returns in < 100 ms, a second effect brokered while that launch is outstanding gets its verdict first, and the parked verdict still lands afterwards — so non-blocking did not quietly become fire-and-forget. Re-synchronising the seam (mutation M9) reds it at 2.0007 s.

Older-host behaviour, and the VOCAB decision

An older host cannot decode an appended variant at all — it is a decode failure that kills the whole frame, not a skip. codec::decode_body is rmp_serde::from_slice, the enum representation is pinned to external tagging with no #[serde(other)], and Effect has no catch-all: an unknown variant tag fails the body where an unknown field key is skipped. That is the exact difference the crate root's rules turn on, and it is pinned by an_older_host_cannot_decode_an_open_uri_at_all (with a control asserting a pre-#1045 effect still decodes on the same mirror decoder).

So, per the rule in VOCAB_UNCONDITIONAL's docs — "Always bump VOCAB (the census). Bump this too only if a plugin may emit the variant with no advertisement":

The price for a correct plugin is the one every appended capability has paid since the first, stated on Capability::Shader: declaring OpenUri costs compatibility with a pre-#1045 host, which drops the connection with a plugin handshake read failed warn naming the undecodable variant.

the_open_uri_generation_bumps_the_census_only carries the "newest variant" equality (VOCAB == OPEN_URI_VOCAB) that used to live on SCROLLED_VOCAB, per that test's own instruction; the_viewport_generation_bumps_the_census_only relaxes to >= exactly as the shader one did before it.

Golden fixtures — four move, each by exactly the intended delta

Regenerated with the documented regenerate_golden_fixtures run, then diffed byte-structurally (common prefix/suffix + the differing middle):

fixture delta
host_msgs_v1 one byte: Hello { vocab } 0405
manifest_full_v1 vocab_max 0405, capability array header 9e9f, appended a7 "OpenUri"
plugin_register_v1 the same three (it embeds the same manifest)
plugin_render_v1 effects array count dc 00 10dc 00 11, appended 81 a7 "OpenUri" 82 a2 "id" 0e a3 "uri" …

Every pre-existing effect, node, state key and capability keeps its exact bytes — verified by the reviewer as byte identity, which is stronger than the structural decode-diff it was claimed as. The other six fixtures are untouched.

That property ends with the next capability (review F4). 9e9f is a 15-element fixarray; the 16th capability crosses to array16 (dc 00 10, three bytes) and shifts every byte after the capability array in manifest_full_v1 and plugin_register_v1. Not a compat break — rmp-serde reads both — but the next author gets a diff that looks alarming and is not. Documented at the Capability enum and in golden.rs's regeneration note, and pinned by the_capability_list_is_one_variant_from_an_array16_header, whose exhaustive match makes appending a variant a compile error there (verified: mutation M11).

Mutations

Eleven, all reproduced red at the pushed head, each with md5 before/after and the tree verified clean afterwards. Full table with the exact red sites in the fix-round comment. The headline rows:

# mutation exit reds
M8 uri.len() > MAX_URI_BYTES>= 101 now red (it was green at f493a68 — review F3)
M9 re-synchronise the launch seam 101 the arm takes 2.0007 s; the new non-blocking test
M7 refusal path launches anyway, still ok: false 101 the seen/calls assertions only
M10 the residual test declares the cap after all 101 an_undeclared_open_uri_still_registers_on_an_old_host
M11 append a 16th Capability 101 E0004 non-exhaustive — the intended alarm

The honest gap the first revision declared is closed: M7 is the mutation a_refused_scheme_never_launches_and_reports_not_ok's own doc predicted, and it reds the seen assertion and nothing else. The reviewer's breadth test (no_refused_uri_shape_reaches_the_launcher, all thirteen refusal shapes, asserting the launcher call count) is added.

Tests

Proto: variant round-trip through the constructor; the appended-variant decode failure + its control; the generation/census assertions; Capability::OpenUri parsing out of a manifest; the capability's own decode failure on an old host + its control; the undeclared-emit residual (manifest decodes / render body does not, with a control); the fixarray crossing (exhaustive match + both header widths demonstrated).

Host: the scheme table as a pure function (4 allowed incl. a case-insensitive HTTPS, 11 refused, plus both sides of the 4096 B cap — exactly at it is openable, one byte over is not); a refusal never reaches the launcher and names its reason; no refusal shape at all reaches the launcher; an allowed URI reaches the launcher verbatim and both launch arms map to the right outcome; a 2 s launch does not block the broker and its verdict still lands; broker_effect end to end for a refused scheme (EffectResult on the connection's channel + the audit line with id= and no unit=); the audit correlation id; and, at the enforcement seam, that an un-capped OpenUri is dropped and that RunCommand/OpenUri do not substitute for each other in either direction.

The launcher is injected into open_uri_with (and one level up into broker_open_uri_with) the way start_detached_with's program is — the real one starts a browser. Nothing is #[cfg(test)]-compiled-out: the validation, the log lines and the outcome mapping are the same code in both builds, and only the final GLib call differs.

Gates

gate exit
nix fmt -- --no-cache 0 (459 files, 0 changed on the second run — converged)
cargo clippy --workspace --all-targets --features system-tests -- -D warnings 0
cargo test -p hytte-plugin-proto -p hytte-plugin -p trollshell 0 (505 + 447 + proto/SDK suites)
xvfb-run -a cargo test --features system-tests -p trollshell plugins 0 (263 passed)

Live-verify caveat

Nothing in CI can see the actual open: it ends in the desktop's default handler, and the hermetic tests inject a stub launcher precisely so cargo test never starts a browser. docs/live-verify.md gets eight checks for it — the happy path (browser opens, info log, audit line with no unit=), a refused scheme reaching the plugin as a toastable EffectResult, file: opening a viewer, an uppercase HTTPS:// really resolving to a handler (F5: the case-folding is pinned as policy, but only a live desktop can say whether GLib resolves it), the shell staying responsive during a launch pointed at a hung mount (the F1 property), the capability being load-bearing in both directions, the old-shell/new-plugin compat claim, and the named F2 residual seen once. None of it has been run on a live session by this PR.

Notes

Refs #1045 #963 #947

🤖 Generated with Claude Code

https://claude.ai/code/session_0157rpXf1ks2hZEfdB47UJ6v

@vibechoom

Copy link
Copy Markdown
Contributor Author

Adversarial review — PR #1049 @ f493a68

Verdict: FIX-FIRST — one medium-high (F1) and one medium (F2). Everything the PR claims checks out: the fixture diff is exactly as described down to the byte, all six mutations reproduce red, all three gates are green, and the "honest gap" turns out not to be a gap at all (M7 below closes it). What doesn't hold is a justification: the sync launch_default_for_uri on the GTK main thread borrows its safety argument from main.rs, where the URI is shell-generated, and here it is plugin-supplied; and the census-only argument is stated as an absolute ("cannot arise") when it is conditional on a plugin declaring the capability it emits — which the SDK's own docs say nothing enforces.

Neither is a wire-format problem, and F1's fix is a one-function swap already in Cargo.lock. If you'd rather land the host arm now, the hard gate is: F1 must be fixed before #963's plugin-side round makes Capability::OpenUri live, because that is the moment a plugin can freeze the shell.


Findings

F1 — MEDIUM-HIGH: a plugin-supplied URI does synchronous I/O on the GTK main loop

trollshell/src/plugins/effects.rs:766-770 (launch_default_for_uri) ← :775-778 (broker_open_uri) ← :262-275 (the OpenUri arm), drained on glib::MainContext::default().spawn_local at trollshell/src/plugins/mod.rs:745-756. The PR's comment is right that this is the GTK main thread; that is the problem, not the reassurance.

GLib 2.88.3's own documentation for the call, read out of the GIR shipped by the devShell's glib (/nix/store/lfrjb7zz2g8mkm1g1jjg2hxwd1crryf6-glib-2.88.3-dev/share/gir-1.0/Gio-2.0.gir, from gio/gappinfo.c:1152):

Utility function that launches the default application registered to handle the specified uri. Synchronous I/O is done on the uri to detect the type of the file if required.

The D-Bus-activated applications don't have to be started if your application terminates too soon after this function. To prevent this, use g_app_info_launch_default_for_uri_async() instead.

and on the async variant (gio/gappinfo.c:1417):

This version is useful if you are interested in receiving error information in the case where the application is sandboxed and the portal may present an application chooser dialog to the user.

Failure scenario. A plugin holding Capability::OpenUri emits Effect::open_uri(1, "file:///mnt/nas/x.png") against a hung NFS mount or a spun-down removable device. GLib does the synchronous content-type sniff, the GTK main loop blocks, and every bar, drawer, overlay and every other plugin's rendering freezes until the I/O returns — on a hard NFS mount, indefinitely. file is deliberately allow-listed, so this is the sanctioned path, not an edge case. The rate cap does not help: one effect is enough.

Why "the same call main.rs makes" doesn't transfer. At trollshell/src/main.rs:587-589 and :658-660 the URI is gio::File::for_path(path).uri() over a screenshot/recording the shell itself just wrote to local disk. The call and the thread are identical; the input is not. This variant's whole premise is that the string comes from a plugin.

Structural tell. OpenUri is now the only arm of broker_effect that does blocking external I/O inline. OpenPage/RaiseOsd/Notify mutate GTK state; Niri/Media/Audio (effects.rs:339-380) fire service commands that dispatch onto the tokio runtime; Datasource* routes in-process; and RunCommand — the highest-trust capability — is explicitly offloaded (effects.rs:213-227). So the narrow capability introduced to avoid RunCommand has a larger availability blast radius than RunCommand does.

Second-order. Per the async doc above, the sync variant does not carry real error information on the portal path. EffectOutcome { ok: true } can therefore be reported for a launch that ends in an app-chooser the user cancels — the verdict the plugin toasts is weaker than Input::EffectResult's new doc (crates/hytte-plugin/src/lib.rs:558-568) claims.

Fix. gio::AppInfo::launch_default_for_uri_async(uri, gio::AppLaunchContext::NONE, gio::Cancellable::NONE, move |res| …) — present in the pinned gio 0.22.5 (gio-0.22.5/src/auto/app_info.rs:267-313; launch_default_for_uri_future at :319 if you prefer a future), and callable from exactly this thread: the binding asserts main-context ownership, which broker_effect satisfies. Reply from the callback rather than through reply_effect_result's eager spawn. check_uri stays pure and the injected-launcher seam survives — only the launcher's signature becomes callback- or future-shaped.

Test. Nothing hermetic reds today, and that is the point: fn open_uri_outcome(…) -> EffectOutcome encodes "the verdict is known synchronously", so the defect lives in the signature. The regression test arrives with the fix — a stub launcher that parks on a oneshot, asserting broker_open_uri returns before the verdict exists and the EffectResult lands only after the oneshot fires.


F2 — MEDIUM: "the #437 hazard cannot arise here" is an absolute; the truth is conditional

Asserted in four places: crates/hytte-plugin-proto/src/effect.rs (OPEN_URI_VOCAB docs, "therefore cannot arise here"), src/lib.rs:184-191, tests/proto.rs's the_open_uri_generation_bumps_the_census_only doc, and the PR body.

The chain is (a) an effect only reaches a host from a plugin that declared its gating capability, therefore (b) since Capability::OpenUri is undecodable pre-#1045, such a plugin dies at Register.

(b) is true — I confirmed both the byte shape and the mirror-decoder test. (a) is not a wire property. It describes the new host's enforce_capabilities (trollshell/src/plugins/session.rs:419-449), which runs after decoding. An old host decodes first. And nothing upstream enforces the pairing — the SDK says so itself, crates/hytte-plugin/src/lib.rs:56-67:

A plugin names its variants directly, so nothing SDK-side gates which effects an author can emitManifest::capabilities has to name every capability the plugin's effects need or those effects simply never happen.

Failure scenario. A plugin built on this SDK emits Effect::open_uri(…) and forgets Capability::OpenUri in its manifest — the exact authoring mistake that doc anticipates. Against a #1045 host: the effect is dropped with a warn and no EffectResult is ever sent, so a plugin waiting on the verdict to toast hangs silently. Against a pre-#1045 host: Register succeeds (the manifest carries no unknown variant, and vocab is still stamped 1, so check_vocab passes), and then the first render frame carrying the effect fails to decode → the SDK redials → identical tree → precisely the near-silent 5 s crash-loop VOCAB_UNCONDITIONAL exists to catch.

Why this deserves naming rather than waving off. Per the history of the counter: VOCAB was introduced by fc0606c (#437, 2026-07-26). Effect::RequestConsent (#487) and the two datasource legs (#509) both landed before that, so they never had a counter to bump. The three census-only generations since — #882 Node::Preem, #893 Node::Shader, #966 Node::Scrolled — are all host-rendered Nodes gated on a Hello advertisement the plugin must actively read. Effect::OpenUri is therefore the first appended plugin→host Effect variant since the counter existed, and the first to use a capability as the gate. VOCAB_UNCONDITIONAL's own rule (lib.rs:220-226) — "Bump this too only if a plugin may emit the variant with no advertisement" — reads against it on its face, because a plugin may.

I am not asking for VOCAB_UNCONDITIONAL to move. The PR's cost argument is right and bumping it would refuse every rebuilt plugin for nothing. The ask is that the four sites state the conditional truth, e.g.: cannot arise for a plugin that declares the capability it emits; a plugin that emits OpenUri without declaring Capability::OpenUri is a plugin bug that degrades to a dropped effect on a #1045 host and to the #437 crash-loop on an older one — so the next appended Effect variant does not inherit an absolute that was never true.

Test to add, beside the two mirror-decoder tests in proto.rs: encode a PluginMsg::Render whose effects carry Effect::open_uri(…) while its embedded manifest declares only Capability::Notify; assert the manifest decodes on the pre-#1045 mirror (so Register would succeed) and the render body does not. That pins the residual as a fact instead of prose. Falsified by giving Effect a #[serde(other)].

Follow-up worth filing (out of this PR's lane): a debug_assert/warn in hytte-plugin's run() when update() returns an effect whose capability the manifest omits. That closes the hole for every effect and would make the census-only argument true by construction.


F3 — LOW: the 4 KiB cap's boundary is unpinned

effects.rs:681 is if uri.len() > MAX_URI_BYTES — so the cap is inclusive (4096 B accepted, 4097 refused). The only test input is format!("https://x/{}", "a".repeat(MAX_URI_BYTES)) = 4106 B (effects.rs:2483-2484), refused under either comparison.

Mutation M8 (mine): >>=. cargo test -p trollshell --libexit 0, 445 passed. A one-byte drift in the cap is invisible to the suite.

Fix — two rows in check_uri_allows_only_http_https_and_file:

let at_cap = format!("https://x/{}", "a".repeat(MAX_URI_BYTES - 10));
assert_eq!(at_cap.len(), MAX_URI_BYTES);
assert_eq!(check_uri(&at_cap), Ok("https"), "exactly the cap is still openable");
let over = format!("{at_cap}a");
assert_eq!(check_uri(&over), Err(UriRefusal::TooLong(MAX_URI_BYTES + 1)));

F4 — INFO: the capability array is one element from a MessagePack width crossing

manifest_full_v1's capability header moved 9e (fixarray, 14) → 9f (fixarray, 15). fixarray tops out at 15, so the next appended Capability flips it to dc 00 10 (array16) and shifts every byte after it in both manifest_full_v1 and plugin_register_v1. Not a compat break — rmp-serde reads both encodings — but the "every pre-existing byte keeps its position" property that made this diff auditable ends there. One line in golden.rs's regeneration note would save the next reviewer a scare.

F5 — NIT: HTTPS:// has no live-verify row

check_uri("HTTPS://…") == Ok("HTTPS") is pinned as policy (effects.rs:2420), but whether GLib resolves an uppercase scheme to a handler is a runtime question this PR cannot answer and docs/live-verify.md's five checks do not ask. One extra bullet.

F6 — NIT: a misattributed open question

"Annika flagged mailto: as an open question on #1045" appears three times (PR body, the OpenUri variant doc, ALLOWED_URI_SCHEMES' doc). #1045 has zero comments and #1049 has zero — the phrase comes from the issue body's own Triage line ("flag it on #947 if @annikahannig wants it wider"), which is the filer's suggestion, not Annika's question. Harmless, but the crate docs now attribute an open question to someone who hasn't asked it.


Fixture diff — the claim is exactly true

Method: decode origin/main's and f493a68's hex to raw bytes and run difflib.SequenceMatcher over them, listing every non-equal opcode. Four fixtures changed; the other six are untouched.

fixture old → new differing regions what moved
host_msgs_v1 1401 → 1401 B 1 byte 1400: 0405 (Hello { vocab })
manifest_full_v1 375 → 383 B 4 frame-length low byte 737b; byte 40 0405 (vocab_max); byte 147 9e9f (fixarray 14→15); +8 B a7 "OpenUri" appended at 284
plugin_register_v1 395 → 403 B 4 the same three at bytes 3 / 60 / 167; +8 B appended at 304
plugin_render_v1 1855 → 1915 B 3 frame-length low byte 3b77; byte 1101 1011 (array16 count 16→17); +60 B 81 a7 "OpenUri" 82 a2 "id" 0e a3 "uri" d9 28 … appended at 1855

No length prefix widened. The capability array stayed inside fixarray (14→15) and the effects array was already array16 (16→17). Every pre-existing effect, node, state key and capability keeps both its byte position and its value — verified as byte identity, which is stronger than the structural decode-diff it was claimed as.

VOCAB verdict

The number is right; the argument's absoluteness is not (F2).

  • VOCAB 4 → 5 with a generation marker is correct and is what Hello advertises — host_msgs_v1's single byte is exactly that.
  • An old plugin is still accepted. check_vocab (manifest.rs:470-479) compares the plugin's stamped vocab (1, or 0 for a pre-counter manifest) against the host's VOCAB = 5, so it passes. Hello carries 5 to any plugin declaring vocab_max, and the plugin clamps with negotiated_vocab = min(vocab_max, host_vocab) (manifest.rs:505-507) — a vocab_max: 4 plugin negotiates 4 and cannot be talked into emitting generation 5. That direction is clean.
  • "An unknown Capability kills Register" is true. Capability carries no #[serde(other)] and Manifest::capabilities is a plain Vec<Capability>, so an unknown variant name fails the whole body — confirmed independently, and consistent with Capability::Shader's stated price (manifest.rs:216-219). The mirror-decoder tests both carry proper controls.
  • What does not follow is that an OpenUri therefore can never reach an old host — see F2.

Mutations (all re-run at f493a68; md5 before/after per file, all reverted, worktree verified clean afterwards)

# mutation site exit reds
1 effect_capability: OpenUriCapability::RunCommand session.rs:349 101 tests.rs:1991, tests.rs:2015
2 ALLOWED_URI_SCHEMES += "mailto" effects.rs:604 101 effects.rs:2428, :2493
3 drop reply_effect_result from the OpenUri arm effects.rs:779 101 effects.rs:2576 (after the 5 s timeout)
4 refusal path reports ok: true effects.rs:737 101 effects.rs:2493, :2581
5 VOCAB_UNCONDITIONAL 1 → 5 lib.rs:226 101 proto.rs:3054 + golden.rs:961, preem.rs:2378, :2400, proto.rs:2857, :2905
6 VOCAB 5 → 4 lib.rs:192 101 proto.rs:3050, golden.rs:961
7 (new) refusal path calls launch(uri) anyway, still returns ok: false effects.rs:736 101 effects.rs:2500 only
8 (new) uri.len() > MAX_URI_BYTES>= effects.rs:681 0 — 445 passed none → F3

All six claimed mutations reproduce. Two red more than the table says (4 also reds the broker round-trip; 5 reds six tests, not one) — the harmless direction.

M7 closes the honest gap. The PR states seen.borrow().is_empty() ("the launcher must not run for a refused URI") is not independently falsified because mutations 2 and 4 trip assert!(!outcome.ok) first. Running the mutation the test's own doc comment predicts — make the refusal path launch anyway while still returning ok: false — reds a_refused_scheme_never_launches_and_reports_not_ok at effects.rs:2500, the seen assertion, and nothing else. It is load-bearing; it just hadn't been aimed at.

Worth adding anyway, since the early return is currently exercised for mailto: alone while the other eleven refusal shapes only reach the pure check_uri table — a breadth test with no outcome assertion at all, so only the launcher can falsify it:

#[test]
fn no_refused_uri_shape_reaches_the_launcher() {
    let calls = Cell::new(0usize);
    let long = format!("https://x/{}", "a".repeat(MAX_URI_BYTES));
    let refused = [
        "mailto:a@b.c", "javascript:alert(1)", "ssh://box.example/",
        "data:text/html,<script>x</script>", "", "   ",
        "pr1ma.darkest.space/agents", "://x/", "  https://x/", "1http://x/",
        "https://x/\nSet-Cookie: y", "https://x/\0y", long.as_str(),
    ];
    for uri in refused {
        let _ = open_uri_outcome("agents", 1, uri, |_| { calls.set(calls.get() + 1); Ok(()) });
    }
    assert_eq!(calls.get(), 0, "no refused URI may reach the launcher");
}

Enforcement seam and scheme policy

  • Seam is clean. Mapped at session.rs:347-349, dropped at session.rs:419-449, mutation M1 reds two tests, and the test asserts non-substitution in both directions, which is the half that matters.
  • The rate cap does apply. throttle_effects (session.rs:253-281) runs generically over every Effect upstream of the broker, so OpenUri shares the 8-token burst / 1-per-second refill (session.rs:189, :194). One asymmetry worth knowing: an effect dropped by either gate never yields an EffectResult, so a plugin that lost the cap check or blew the rate cap waits forever for the verdict this variant exists to deliver. Pre-existing (RunCommand has it too), not this PR's to fix — but it is what makes F2's un-capped case silent rather than loud.
  • file:// is acceptable under the trust story and the docs say so — the OpenUri variant doc states plainly that it is not a sandbox, that file:///… reaches the user's own handler, and that a same-uid process on the socket could always do more (route 0). file:///home/annika/.ssh/id_ed25519 opening in an editor is no privilege gain over the socket the plugin already holds. Correctly framed as a legibility guard.
  • https://user:pass@host is accepted — the scheme parse stops at the first : and userinfo is left to the handler. Right call: the shell has no better idea than the browser what a userinfo component means.
  • \n and NUL in the path are refused as Control, pinned at effects.rs:2477-2478. The hand-written uri_scheme (effects.rs:697-707) correctly refuses " https" and "1http" — a permissive parser in front of an allow-list is how allow-lists get bypassed, and this one isn't.
  • IDN/punycode is correctly not the host's problem. check_uri reads only the scheme and hands the rest through; the handler is the only component that can resolve a name.

Gates (fresh at f493a68, in the devShell, RUSTC_WRAPPER unset)

gate exit
cargo clippy --workspace --all-targets --features system-tests -- -D warnings 0
cargo test -p hytte-plugin-proto -p hytte-plugin -p trollshell 0
xvfb-run -a cargo test --features system-tests -p trollshell plugins 0 (252 passed)

Read-only review: nothing pushed, no files touched outside my worktree, every mutation reverted and md5-verified.

vibechoom and others added 6 commits September 10, 2026 14:43
…ink without RunCommand

Appends `Effect::OpenUri { id, uri }` and `Capability::OpenUri` to the wire
vocabulary, plus the `Effect::open_uri` constructor and the host's
effect -> capability mapping.

Motivation is Mara's retest of #963: the expanded agent card renders
`agent page https://…` as a plain detail row nobody can follow. The row could
have become a button emitting `Effect::launch(["xdg-open", url])`, but only at
the cost of declaring `Capability::RunCommand` — arbitrary argv as the user, the
highest-trust capability in the vocabulary — for a card whose only other needs
are its own panel and a toast. `OpenUri` is the narrow intent instead: the
plugin names a destination, never a program.

Vocabulary: `VOCAB` 4 -> 5 (`OPEN_URI_VOCAB`), `VOCAB_UNCONDITIONAL` stays at 1.
An appended variant always bumps the census; the unconditional ceiling moves
only for a variant a plugin may emit with no gate. Here the gate is the
capability rather than #882's `Hello`: an effect only rides the wire from a
plugin whose manifest declared its cap, and `Capability::OpenUri` is itself
undecodable on a pre-#1045 host, so such a plugin never gets past `Register`.
Bumping the ceiling would not save it and would add a handshake refusal for
every plugin rebuilt on this SDK that never opens a link.

Golden fixtures: four move, each by exactly the intended delta —
`host_msgs_v1` one byte (`Hello { vocab }` 04 -> 05), `manifest_full_v1` /
`plugin_register_v1` by `vocab_max` 04 -> 05 plus the capability array header
9e -> 9f and an appended `a7 "OpenUri"`, and `plugin_render_v1` by the effects
array count `dc 00 10` -> `dc 00 11` plus the appended `OpenUri` map. Every
pre-existing effect, node and capability keeps its exact bytes.

Refs #1045 #963 #947

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0157rpXf1ks2hZEfdB47UJ6v
…with a scheme allow-list

The host arm of #1045. `broker_effect` gains an `Effect::OpenUri` case that
validates the URI, hands it to `gio::AppInfo::launch_default_for_uri` — the same
call `main.rs`'s screenshot/recording Open actions use — and routes the verdict
back as `HostMsg::EffectResult` keyed by the effect's `id`, so a plugin can
toast a refusal instead of shipping a click that silently does nothing.

Enforced policy is the scheme allow-list (`http`/`https`/`file`) plus three
hygiene guards: an empty URI, a control character (a newline would ride into the
journal; an interior NUL cannot survive the &str -> C string conversion the
launcher does at all), and a 4 KiB cap so a runaway plugin cannot push a
multi-megabyte string through a log line. Everything else is refused with a warn
and `ok: false` naming the reason. `check_uri` is pure, so the whole table is
unit-testable; the allow-list is host policy, not wire vocabulary, so widening
it later (Annika's `mailto:` question) is a change here alone.

Unlike `RunCommand` the launch is NOT offloaded to the tokio runtime: it is a
GLib call and the broker is already on the GTK main thread, which is where the
shell makes the same call today. Only the reply hops to the runtime.

The launcher is injected into `open_uri_outcome` the way `start_detached_with`'s
program is (#964 item 3), because the real one starts a browser — so the
allowed path is covered hermetically, and the one `broker_effect` test uses a
refused scheme, which reaches no launcher by construction.

Tests: the scheme table (4 allowed incl. a case-insensitive scheme, 11 refused);
a refusal never reaches the launcher and reports its reason; an allowed URI
reaches the launcher verbatim and both launch arms map to the right outcome; the
broker end to end (EffectResult on the connection's channel + the audit line);
`OpenUri`'s audit correlation id; and, at the enforcement seam, that an un-capped
`OpenUri` is dropped and that `RunCommand`/`OpenUri` do not substitute for each
other in either direction.

docs/live-verify.md gets the five checks CI cannot run. docs/plugin-env.md is
deliberately untouched — it is an environment-variable reference and lists no
effect vocabulary.

Refs #1045 #963 #947

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0157rpXf1ks2hZEfdB47UJ6v
…::doc_markdown)

Refs #1045

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0157rpXf1ks2hZEfdB47UJ6v
…ung mount cannot freeze the GTK main loop

Review F1 (MEDIUM-HIGH) on PR #1049. `gio::AppInfo::launch_default_for_uri`
"does synchronous I/O on the uri to detect the type of the file if required"
(GLib's own docs), and the OpenUri arm ran it inline on the GTK main thread.
The safety argument was borrowed from `main.rs`, where the URI is a local file
the shell just wrote; here it is whatever the plugin said, and `file:///mnt/nas/x`
on a hard NFS mount would freeze every bar, drawer, overlay and every other
plugin's rendering. One effect is enough; the rate cap does not help.

`open_uri_outcome(...) -> EffectOutcome` encoded "the verdict is known
synchronously", so the fix is in the signature: `open_uri_with` now takes a
launcher that answers on a `LaunchDone` callback and a `report` sink, and the
production launcher is `launch_default_for_uri_async` — which returns as soon as
the request is in flight and is also the entry point that carries real error
information when the portal shows an application chooser. The launch still
STARTS on the GTK thread (the binding asserts main-context ownership, which
`broker_effect` satisfies); nothing waits there. The refusal arm still reports
inline, since `check_uri` is pure and does no I/O.

`broker_open_uri_with` is the injected seam a test drives, mirroring
`start_detached_with`. New test: a stub launcher that answers after 2 s from
another thread; the arm returns in <100 ms, a second effect brokered while that
launch is outstanding gets its verdict first, and the parked verdict still lands
afterwards, so non-blocking did not become fire-and-forget.

Refs #1045 #963 #947

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0157rpXf1ks2hZEfdB47UJ6v
… fixarray crossing is one variant away

Review F2/F4/F5/F6 on PR #1049.

F2 (MEDIUM): four sites claimed the #437 hazard "cannot arise here". It is
conditional. The chain "an effect only reaches a host from a plugin that
declared its gating capability" describes the NEW host's `enforce_capabilities`,
which runs after decoding, and nothing upstream enforces the pairing -- the SDK
says so itself ("nothing SDK-side gates which effects an author can emit"). A
plugin that emits `OpenUri` while forgetting `Capability::OpenUri` registers
FINE on a pre-#1045 host and then crash-loops on the first render frame carrying
the effect. `OpenUri` is also the first appended plugin->host `Effect` variant
since `VOCAB` existed, so the next author inherits whatever is written here.

The four sites now state the conditional truth and name the residual;
`VOCAB_UNCONDITIONAL` deliberately does NOT move (that would refuse every
rebuilt plugin against every older shell to catch one authoring bug that already
misbehaves visibly on a current host). `an_undeclared_open_uri_still_registers_on_an_old_host`
pins both halves as fact rather than prose.

F3 (LOW): the 4 KiB cap's boundary was unpinned -- `>` to `>=` stayed green,
because the only test input was 10 B over. Exactly 4096 B accepted and 4097
refused are both pinned now, and `MAX_URI_BYTES`' docs say which.

F4 (INFO): `Capability` is 15 variants; the 16th crosses MessagePack's fixarray
limit and shifts every byte after the capability array in two golden fixtures.
Documented at the enum and in `golden.rs`' regeneration note, and pinned by a
test whose exhaustive match makes appending a variant a COMPILE error there.

F5: the uppercase-scheme row (`check_uri` accepts `HTTPS://` as policy, but
whether GLib resolves it is a runtime question) plus two more live-verify rows
-- the slow-launch responsiveness check F1 exists for, and the named residual.

F6: "Annika flagged mailto: as an open question" was the issue's own triage
text, not a question she asked. Fixed in both crate docs.

Also adds the reviewer's breadth test: none of the twelve refusal shapes reaches
the launcher (the M7 mutation -- launch anyway, still report ok:false -- is the
only thing that can falsify it).

Refs #1045 #963 #947

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0157rpXf1ks2hZEfdB47UJ6v
… new residual test (clippy)

Refs #1045

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0157rpXf1ks2hZEfdB47UJ6v
@vibechoom
vibechoom force-pushed the feat/1045-open-uri-effect branch from f493a68 to f73c4b6 Compare September 10, 2026 13:04
@vibechoom

Copy link
Copy Markdown
Contributor Author

Fix round — PR #1049 @ f73c4b6

Every finding from the FIX-FIRST review at f493a68 is addressed, including the reviewer's M7 breadth test and the follow-up notes. Rebased onto 3be98dd (clean; #1032/#1035/#1038/#1042/#1043 in the base now — region.rs untouched).

Two things the review got right that changed the shape of the code, not just the prose: F1 is a signature problem, and F2's chain has a link that is a plugin-authoring property rather than a wire property. Both are now written that way.


Findings → changes

# verdict site what changed
F1 MED-HIGH trollshell/src/plugins/effects.rs:721 (new LaunchDone), :764 (open_uri_with), :832 (launch_default_for_uri_async), :850 (broker_open_uri_with), :262 (the arm's comment) open_uri_outcome(…) -> EffectOutcome is gone — it encoded "the verdict is known synchronously". The launcher now answers on a LaunchDone callback and the outcome goes to a report sink; the production launcher is gio::AppInfo::launch_default_for_uri_async, which returns as soon as the request is in flight. The launch still starts on the GTK thread (the binding asserts main-context ownership, which broker_effect satisfies); nothing waits there. The refusal arm still reports inline — check_uri is pure and does no I/O. broker_open_uri_with is the injected seam, mirroring start_detached_with.
F1 second-order same The async entry point is also the one that carries real error information on the portal/app-chooser path, so EffectOutcome { ok } is the desktop's verdict rather than "the request was accepted". Noted on open_uri_with and in Input::EffectResult's docs (hytte-plugin/src/lib.rs:573).
F1 test effects.rs:2706 a_slow_open_uri_launch_does_not_block_the_broker A stub that answers after 2 s from another thread, so the only way the call can cost 2 s is if the broker waits. Three assertions: the arm returns in < 100 ms; a second effect brokered while that launch is outstanding gets its verdict first; the parked verdict still lands afterwards (non-blocking ≠ fire-and-forget).
F2 MED hytte-plugin-proto/src/effect.rs:394 (OPEN_URI_VOCAB), effect.rs's OpenUri variant doc, src/lib.rs:190 + :211, tests/proto.rs's the_open_uri_generation_bumps_the_census_only doc, and the PR body All five now state the conditional truth: the #437 hazard cannot arise for a plugin that declares the capability it emits (it dies at Register), and an undeclared emit is an author bug whose consequences are named — dropped effect + no EffectResult on a #1045 host, Register succeeds then the render frame fails to decode (= the #437 crash-loop) on an older one. VOCAB_UNCONDITIONAL does not move (the reviewer's own recommendation; the cost argument is unchanged), and the docs now say why the residual is named rather than bought off. Also stated: this is the first appended plugin→host Effect variant since VOCAB existed, so the precedent is being set here.
F2 test tests/proto.rs:3163 an_undeclared_open_uri_still_registers_on_an_old_host Exactly the reviewer's spec, both halves: the manifest declaring only Capability::Notify decodes on the pre-#1045 mirror (so Register would succeed, and it stamps a generation that host accepts), and the effect body does not. Plus a control on each half.
F2 follow-up not done, out of lane The debug_assert/warn in hytte-plugin's run() for an effect whose capability the manifest omits — flagged in the PR body as the thing that would make the argument true by construction. The SDK crate docs (hytte-plugin/src/lib.rs:68) now at least warn the author that the pairing costs more than the dropped effect.
F3 LOW effects.rs:623 (MAX_URI_BYTES docs), :2598 (the two new rows) The cap's docs say inclusive, and both sides are pinned: exactly 4096 B is openable, 4097 is TooLong(4097). UriRefusal::TooLong's doc says so too. >>= is now red (it was green).
F4 INFO hytte-plugin-proto/src/manifest.rs:126 (a # Appending the 16th one changes more bytes than you expect section on the Capability enum), tests/golden.rs:44 (the regeneration note), tests/proto.rs:3250 (the test) The test asserts the current 15 through an exhaustive match with no wildcard arm, so appending a 16th variant is a compile error there rather than a silently-green count — and it demonstrates the crossing rather than asserting it in prose (encodes 15 → 0x9f, 16 → dc 00 10).
F5 NIT docs/live-verify.md:329 New row: HTTPS:// uppercase — the case-folding is pinned as policy in the unit table (("HTTPS://pr1ma.darkest.space/", "HTTPS"), already present), but whether GLib resolves it to a handler is a runtime question, and the row says what it means if it doesn't. Two more rows added while in there: :336 the slow-launch responsiveness check F1 exists for (with a hung-NFS recipe), and :362 the F2 residual seen once end to end.
F6 NIT effects.rs:609, hytte-plugin-proto/src/effect.rs's OpenUri doc, PR body Attribution fixed in all three: the mailto: line is #1045's own triage note ("flag it on #947 if @annikahannig wants it wider"), i.e. the filer's suggestion, not a question @annikahannig asked.
M7 reviewer's ask effects.rs:2650 no_refused_uri_shape_reaches_the_launcher Added, as written: all thirteen refusal shapes, asserting the launcher's call count so only the launcher can falsify it.

Mutations

All eleven applied at the pushed tree, md5 recorded before/after each, every one reverted, md5sum -c clean afterwards and git status clean.

# mutation site exit reds
M1 effect_capability: OpenUriCapability::RunCommand session.rs:349 101 effect_capability_maps_each_effect, enforce_capabilities_drops_an_uncapped_open_uri
M2 ALLOWED_URI_SCHEMES += "mailto" effects.rs:613 101 check_uri_allows_only_http_https_and_file, a_refused_scheme_never_launches_and_reports_not_ok, no_refused_uri_shape_reaches_the_launcher
M3 drop reply_effect_result from the reply sink effects.rs:860 101 broker_reports_a_refused_open_uri_back_to_the_plugin, a_slow_open_uri_launch_does_not_block_the_broker
M4 refusal path reports ok: true effects.rs:786 101 the two refusal tests + the two broker tests (4)
M5 VOCAB_UNCONDITIONAL 1 → 5 lib.rs:233 101 golden_bytes_are_pinned, a_new_plugin_clears_an_old_hosts_vocab_check, preem_is_a_negotiated_generation_not_an_unconditional_one, an_undeclared_open_uri_still_registers_on_an_old_host, the_{open_uri,shader,viewport}_generation_bumps_the_census_only (7, run with --no-fail-fast)
M6 VOCAB 5 → 4 lib.rs:195 101 golden_bytes_are_pinned, the_open_uri_generation_bumps_the_census_only
M7 refusal path launches anyway, still ok: false effects.rs:785 101 a_refused_scheme_never_launches_and_reports_not_ok, no_refused_uri_shape_reaches_the_launcher, broker_reports_a_refused_open_uri_back_to_the_plugin — the launcher assertions only
M8 uri.len() > MAX_URI_BYTES>= effects.rs:696 101 ← was 0/445 passed check_uri_allows_only_http_https_and_file: left: Err(TooLong(4096)) right: Ok("https")
M9 re-synchronise the launch seam (open_uri_with's report rendezvoused before returning) effects.rs:860 101 a_slow_open_uri_launch_does_not_block_the_broker: "the broker must not wait for the launch; it took 2.000754946s"
M10 the residual test declares the cap after all proto.rs:3184 101 an_undeclared_open_uri_still_registers_on_an_old_host (half one stops decoding — the test is not vacuous)
M11 append a 16th Capability manifest.rs 101 error[E0004]: non-exhaustive patterns: Capability::Xyzzy not covered — the intended compile-time alarm

Two notes on method. M5/M6 were re-run with --no-fail-fast because cargo test stops after the first failing binary, so the first pass under-reported their reds (the golden binary runs before proto). And #[serde(other)] — the reviewer's suggested falsifier for the residual test — is not expressible on an externally-tagged enum, so M10 falsifies half one instead (which is the half that could have been vacuous); half two shares its falsifier with an_older_host_cannot_decode_an_open_uri_at_all.


Gates

Fresh at f73c4b6, in the devShell, RUSTC_WRAPPER unset, each redirected to a file and the exit read from $? (never through a pipe).

gate exit
nix fmt -- --no-cache 0 — 459 files, 3 changed then 0 changed on the confirming re-run (converged, not a cache hit)
cargo clippy --workspace --all-targets --features system-tests -- -D warnings 0 (first pass 101 on two clippy::doc_markdown MessagePack hits and one dead_code on the new mirror enum's fields; fixed in f73c4b6)
cargo test -p hytte-plugin-proto -p hytte-plugin -p trollshell 0 (505 / 447 / the proto + SDK suites)
xvfb-run -a cargo test --features system-tests -p trollshell plugins 0 (263 passed)

The PR body is updated for the async launch, the corrected VOCAB argument, the fixarray note and the attribution fix.

🤖 Generated with Claude Code

https://claude.ai/code/session_0157rpXf1ks2hZEfdB47UJ6v

@vibechoom

Copy link
Copy Markdown
Contributor Author

Second pass — PR #1049 @ f73c4b6

Verdict: SOUND — MERGE. Every finding from the first pass is fixed, and fixed at the layer it was broken at rather than in prose: F1 changed the signature and the GLib entry point, F2 changed the claim in all five places and pinned the residual as a fact. All four gates are green at the pushed head, the fixture diff re-verifies byte for byte, all eleven claimed mutations reproduce red, and one extra mutation of mine reds too. Three non-blocking observations follow — one LOW that is a real residual with a named fix, one LOW observability inversion, one doc NIT. None of them gates the merge.


Per-item verification

F1 — the async launch, for real

(a) The report sink from the GLib callback is correct. trollshell/src/plugins/effects.rs:859-861 hands open_uri_with a report closure that calls reply_effect_result (trollshell/src/plugins/effects.rs:876-892). That function does not send from the GTK thread at all — it does hytte::reactive::runtime::handle().spawn(async move { outbound.send(...).await }). Handle::spawn is callable from any thread and never blocks; the .await happens inside the spawned tokio task. So the try_send / blocking_send question doesn't arise: neither is used, and send().await is the right choice here precisely because it is already off the main thread — a try_send would drop the one-shot verdict on a momentarily-full channel, which is the thing this variant exists to deliver.

One hazard I went looking for and did not find: spawn on a shut-down runtime panics. crates/hytte-reactive/src/runtime.rs:7 is a static RUNTIME: OnceLock<Runtime> that is never dropped, so a GLib callback arriving arbitrarily late cannot hit it.

(b) Plugin disconnects mid-launch → Err, not panic. trollshell/src/plugins/effects.rs:884-889: outbound.send(...).await.is_err()tracing::debug!("plugin gone before OpenUri result; dropped"). tokio's Sender::send returns Err(SendError) on a dropped receiver; it never panics. The boxed LaunchDone and its captured Sender clone are dropped on the GTK main thread — the same thread glib::thread_guard::ThreadGuard::new recorded in the gio binding (gio-0.22.5/src/auto/app_info.rs:284, :301-304) — so into_inner() cannot trip its cross-thread guard either.

(c) Two in flight → no shared mutable state. broker_open_uri_with (trollshell/src/plugins/effects.rs:850-862) takes an owned plugin_id.to_owned() and its own outbound.clone() per call; open_uri_with holds no statics; each launch carries its own boxed LaunchDone. The only process-wide thing either touches is the OnceLock runtime handle. Ordering across two outstanding launches is not preserved — each reply is its own spawned task — which the PR's own test depends on and documents.

(d) The M9 test measures the ARM, not the test. trollshell/src/plugins/effects.rs:2711-2726:

let started = Instant::now();
broker_open_uri_with(...);
let returned_in = started.elapsed();
assert!(returned_in < Duration::from_millis(100), "the broker must not wait for the launch; it took {returned_in:?}");

The Instant is closed around the arm's own return, not the test's wall clock. Reproduced: re-synchronising the seam reds it with the broker must not wait for the launch; it took 2.000234901s.

And the layer the PR cannot test. I pulled GLib 2.88.3's gio/gappinfo.c and read g_app_info_launch_default_for_uri_async (line 1439). It is g_task_new + g_uri_parse_scheme, then g_app_info_get_default_for_uri_scheme_async(...), falling back to launch_default_app_for_default_handlerg_file_query_default_handler_async (line 1388). The synchronous g_file_query_default_handler that the GIR's "Synchronous I/O is done on the uri" sentence refers to appears only on the sync sibling's path (line 1168). So the fix is real in C, not only in the Rust signature. The residual is that the content-type sniff moves onto GIO's shared task thread pool rather than the main loop — a resource concern under a sustained 1/s of hung launches, strictly below RunCommand's existing blast radius, and not worth acting on.

Also verified, since the arm relies on it: the gio binding's assert!(is_main_context_owner || has_acquired_main_context.is_some()) (gio-0.22.5/src/auto/app_info.rs:279-282) is a live assertion, and broker_effect is drained from glib::MainContext::default().spawn_local at trollshell/src/plugins/mod.rs:745-747, so it holds. The claim is not hand-waving.

F2 — the residual test asserts both halves

crates/hytte-plugin-proto/tests/proto.rs:3163-3229 is exactly the shape asked for, and then some:

One immaterial deviation from the spec I wrote: half two decodes a bare Effect body rather than a whole PluginMsg::Render carrying it. Same mechanism — an unknown variant tag fails the enclosing body regardless of nesting depth — and the render-frame framing is already carried by the sibling test. Not worth a change.

The prose sites now state the conditional truth (crates/hytte-plugin-proto/src/effect.rs's OpenUri doc and OPEN_URI_VOCAB, crates/hytte-plugin-proto/src/lib.rs:190 and :211, the test doc), and VOCAB_UNCONDITIONAL's rule block explicitly tells the next author to read the rule against #1045. That was the ask.

F3 — the cap boundary is pinned

trollshell/src/plugins/effects.rs:2593-2609 pins both sides (exactly MAX_URI_BYTES is Ok("https"), +1 is Err(TooLong(4097))), and MAX_URI_BYTES' own doc (:620-623) plus UriRefusal::TooLong's say inclusive. M8 (>>=) is now 101 where it was 0 / 445 passed at f493a68, failing with left: Err(TooLong(4096)) right: Ok("https").

F4 — the fixarray crossing

the_capability_list_is_one_variant_from_an_array16_header (crates/hytte-plugin-proto/tests/proto.rs:3250-3300) does better than the one-line note I asked for: an exhaustive match with no wildcard arm (so a 16th variant is E0004 there), plus both header widths demonstrated (0x9f for 15, dc 00 10 for 16) rather than asserted in prose. M11 reds with error[E0004]: non-exhaustive patterns: Capability::Xyzzy not covered. Also documented at the Capability enum and in golden.rs's regeneration note.

F5 / F6

docs/live-verify.md:329 adds the uppercase-HTTPS:// row and says what it means if GLib doesn't resolve it (the case-folding then belongs in the host, not just in the comparison) — which is the useful half. :336 adds the hung-NFS responsiveness check with a recipe, :362 the F2 residual end to end. Eight rows total. Attribution is fixed in all three places: the mailto: line now reads as #1045's own triage note, not a question anyone asked.

Fixture diff — re-verified independently at f73c4b6

Decoded both sides to raw bytes, difflib.SequenceMatcher, every non-equal opcode listed. Four changed, six untouched, each by exactly the claimed delta:

fixture old → new regions what moved
host_msgs_v1 1401 → 1401 B 1 byte 1400 0405
manifest_full_v1 375 → 383 B 4 len byte 737b; byte 40 0405; byte 147 9e9f; +8 B a74f70656e557269 at 284
plugin_register_v1 395 → 403 B 4 len byte 878f; byte 60 0405; byte 167 9e9f; +8 B at 304
plugin_render_v1 1855 → 1915 B 3 len byte 3b77; byte 1101 1011; +60 B at 1855

No pre-existing byte moved position or value.


New findings

N1 — LOW: an outstanding launch has no timeout; RunCommand has one

trollshell/src/plugins/effects.rs:764-815 (open_uri_with) and :832-840 (launch_default_for_uri_async). The launch is started with gio::Cancellable::NONE and nothing bounds how long the desktop may take. If GLib never completes the GTask — a D-Bus-activated handler that never returns, a portal that never answers, g_file_query_info_async against a hard-mounted hang — then report is never called, no HostMsg::EffectResult { id } is ever sent, and the boxed LaunchDone plus its Sender clone are retained for the life of the process. RUN_COMMAND_TIMEOUT exists to bound exactly this shape for the sibling effect; OpenUri has no equivalent.

Failure scenario. A plugin renders its button as "opening…" until the verdict arrives — which is exactly what the new Input::EffectResult docs invite (crates/hytte-plugin/src/lib.rs:576-580: "a launch answers when the desktop has resolved the handler, which may be after an application-chooser dialog, so do not assume it lands in the same frame as the click"). Point it at file:///mnt/hang/x.png on a hard NFS mount and the button stays "opening…" forever, with no way for the plugin to learn it failed. The PR already knows this case is unbounded: docs/live-verify.md:336's own row says "The EffectResult arrives late, or not until the mount gives up".

Why it is LOW and not a merge gate. A plugin already has to tolerate a missing EffectResult — both the capability gate and the rate cap swallow effects with no reply (named as pre-existing in the first pass) — and bounding somebody else's D-Bus reply is a weaker duty than bounding a child process the shell owns and can kill. I confirmed the behaviour rather than inferring it: my mutation N1 below deletes report(outcome) from the success arm, and a_slow_open_uri_launch_does_not_block_the_broker then fails only after 10 s, because the sole thing that ends the wait is the test's own tokio::time::timeout. Nothing in the shell would have ended it.

Fix, if taken. Give the launcher a real gio::Cancellable, arm glib::timeout_add_local_once(OPEN_URI_TIMEOUT, ...) to cancel it, and report ok: false, output: Some("launch timed out"). Cancelling makes GLib complete the task, so report still fires exactly once and the existing arity holds.

Fixing test — at the seam that already exists, no GLib needed: drive broker_open_uri_with with a launcher that simply drops its LaunchDone, and assert an EffectResult { id, ok: false } arrives inside the timeout. Red today, green after.

N2 — LOW: a successful open records its destination nowhere; a refused one does

trollshell/src/plugins/effects.rs:792 logs plugin, id, %scheme — the scheme only, never the URI. Ten lines above, :778-784 logs the refused URI truncated to 256 bytes. And record_audit (:115-120) stamps decision=allowed for both cases with no URI at all: broker_reports_a_refused_open_uri_back_to_the_plugin (:2855-2862) pins the literal string effect=OpenUri decision=allowed id=8 for a URI the host refused.

Failure scenario. An operator asking "which links did this plugin open?" cannot answer it from journalctl --user -u trollshell (scheme only) or from effects-audit.log (kind + id + allowed). Meanwhile "which links did it fail to open?" is fully answerable from the journal. That is inverted from what forensics wants, and it is inconsistent inside this one function — the "don't interpolate plugin strings" rule is applied to the success line and not to the refusal line directly above it. It isn't a house invariant either: Notify (:179), RaiseOsd (:169) and RequestConsent (:196) all log plugin-supplied strings at info.

Why it is LOW. AuditDecision::Allowed is defined as "cleared capability enforcement + the rate cap" (:1585-1586), so decision=allowed for a scheme-refused effect is internally consistent rather than wrong, and RunCommand logs argc rather than argv, so the audit-log side has precedent. It is the info line that is the odd one out.

Fix. Add uri = %truncate_on_char_boundary(uri, 256) to the info line at :792, matching the refusal line's treatment. Fixing test: the harness already exists — plugins::tests::rejected_plugin_id_records_no_phantom_unit installs a real tracing_subscriber and reads the formatted line back; assert the OpenUri info line carries the host.

N3 — NIT: the EffectResult id space is now shared across three effect kinds and nothing says so

crates/hytte-plugin/src/lib.rs:557-582. Input::EffectResult now carries verdicts from an attached RunCommand, a detached launch, and an OpenUri, all keyed by a plugin-chosen id. A plugin that allocated ids per effect kind — a natural thing to have done while RunCommand was the only replier — can now have two in-flight effects share an id and mis-route a verdict. One sentence in that doc ("the id space is shared across every reply-bearing effect; allocate from a single counter") closes it. No code change.


Credit where the design is load-bearing

  • The NUL guard is a real FFI safety net, not hygiene. glib's ToGlibPtr<*const c_char> for str goes through CString::new(...).expect(...), so an interior NUL reaching uri.to_glib_none() inside launch_default_for_uri_async panics on the GTK main thread and takes the whole shell down. check_uri refuses Control (which covers NUL) before the scheme check, and no_refused_uri_shape_reaches_the_launcher carries "https://x/\0y" explicitly. UriRefusal::Control's doc even names the C-string conversion as the reason. Correct layering, correctly justified.
  • no_refused_uri_shape_reaches_the_launcher is the right shape for the M7 gap: it asserts a call count and nothing about the outcome, so only the launcher can falsify it. Confirmed — M7 reds it and the two sibling refusal tests on the launcher assertions alone.
  • The exhaustive-match capability test turns a silently-green count into a compile error. That is the version of F4 worth having.

Mutations

Eleven claimed plus one of mine, all re-run at f73c4b6, --no-fail-fast throughout, md5 recorded before and after each edit, every one reverted and md5sum -c clean afterwards. Final git status shows only my own untracked review subdir; git rev-parse HEAD = f73c4b69f3e566485cb082d1e98a889cbd143af3.

# mutation site exit reds
M1 effect_capability: OpenUriCapability::RunCommand session.rs:349 101 effect_capability_maps_each_effect, enforce_capabilities_drops_an_uncapped_open_uri
M2 ALLOWED_URI_SCHEMES += "mailto" effects.rs:613 101 check_uri_allows_only_http_https_and_file, a_refused_scheme_never_launches_and_reports_not_ok, no_refused_uri_shape_reaches_the_launcher
M3 drop reply_effect_result from the reply sink effects.rs:860 101 broker_reports_a_refused_open_uri_back_to_the_plugin, a_slow_open_uri_launch_does_not_block_the_broker
M4 refusal path reports ok: true effects.rs:786 101 the two refusal tests + the two broker tests (4)
M5 VOCAB_UNCONDITIONAL 1 → 5 lib.rs:233 101 7: golden_bytes_are_pinned, a_new_plugin_clears_an_old_hosts_vocab_check, preem_is_a_negotiated_generation_not_an_unconditional_one, an_undeclared_open_uri_still_registers_on_an_old_host, the_open_uri/shader/viewport_generation_bumps_the_census_only
M6 VOCAB 5 → 4 lib.rs:195 101 golden_bytes_are_pinned, the_open_uri_generation_bumps_the_census_only
M7 refusal path launches anyway, still ok: false effects.rs:785 101 a_refused_scheme_never_launches_and_reports_not_ok, no_refused_uri_shape_reaches_the_launcher, broker_reports_a_refused_open_uri_back_to_the_plugin — launcher assertions only
M8 uri.len() > MAX_URI_BYTES>= effects.rs:696 101 ← was 0 at f493a68 check_uri_allows_only_http_https_and_file (left: Err(TooLong(4096)) right: Ok("https"))
M9 re-synchronise the launch seam (rendezvous the report before returning) effects.rs:859-861 101 a_slow_open_uri_launch_does_not_block_the_broker: "the broker must not wait for the launch; it took 2.000234901s"
M10 the residual test declares the cap after all proto.rs:3183 101 an_undeclared_open_uri_still_registers_on_an_old_host (half one — not vacuous)
M11 append a 16th Capability manifest.rs:255 101 error[E0004]: non-exhaustive patterns: Capability::Xyzzy not covered
N1 (mine) success arm: report(outcome)drop(outcome) effects.rs:813 101 an_allowed_uri_reaches_the_launcher_and_its_verdict_is_the_outcome, a_slow_open_uri_launch_does_not_block_the_broker — the latter only after 10 s, on the test's own timeout, which is the evidence for finding N1

All eleven claimed mutations reproduce with the colours the fix-round comment states. M5's seven reds match once --no-fail-fast is used, as the comment's method note says.


Gates

Fresh at f73c4b6, in the devShell, RUSTC_WRAPPER unset, each redirected to a file with the exit read from $? — never through a pipe.

gate exit
nix fmt -- --no-cache 0 — 521 traversed, 458 emitted, 0 changed (a genuine no-op run, not a cache hit)
cargo clippy --workspace --all-targets --features system-tests -- -D warnings 0
cargo test -p hytte-plugin-proto -p hytte-plugin -p trollshell 0 (505 + 447 + the proto/SDK suites; 0 failed anywhere)
xvfb-run -a cargo test --features system-tests -p trollshell plugins 0 (263 + 257 passed)

Read-only review: nothing pushed, no approve/request-changes, no file touched outside my own worktree, every mutation reverted and md5-verified, tree clean at the end.

🤖 Generated with Claude Code

https://claude.ai/code/session_0157rpXf1ks2hZEfdB47UJ6v

@vibechoom
vibechoom merged commit 9e32535 into main Sep 10, 2026
1 check passed
@vibechoom
vibechoom deleted the feat/1045-open-uri-effect branch September 10, 2026 13:56
vibechoom added a commit that referenced this pull request Sep 10, 2026
…recise live-verify OpenUri rows (#1060 review)

Folds in the adversarial review on PR #1066 (f6ed4d3):

- F1: adds the reviewer's supplied test exercising the ~100%-production
  order (start's own callback answers first, the timer fires late into
  an already-empty cell) — the two existing tests both had the timer
  win, so the timer-side once-guard at effects.rs:877 was unfalsified.
- F3: launch_with_timeout's arm_timer now returns a cancel handle
  (TimerCancel), invoked when start's own callback wins the race so the
  still-armed timer source is torn down instead of firing into a no-op
  OPEN_URI_TIMEOUT later while holding the Arc<Mutex<..>> and the
  Cancellable open. Production wires it to glib::SourceId::remove; a
  new test asserts the cancel handle is actually called.
- F2: docs/live-verify.md's OpenUri rows no longer certify the pre-fix
  behaviour. The happy-path rows now carry uri=; the hung-launch row
  states the ~10s (OPEN_URI_TIMEOUT) bound and names this as the one
  place anywhere that can observe the real launch_default_for_uri_async
  wiring, since no test reaches it.
- N1: truncate_uri_for_log appends a trailing … when it actually cuts a
  URI, so the audit trail and journal never record a destination
  shorter than what the plugin supplied without saying so. Scoped to
  the URI log/audit call sites only — the shared
  truncate_on_char_boundary keeps its exact-length contract for
  RunCommand output truncation.

Refs #1060 #1049

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0157rpXf1ks2hZEfdB47UJ6v
vibechoom added a commit that referenced this pull request Sep 10, 2026
…, document the shared EffectResult id space

Residue from the second-pass review on #1049 (N1-N3):

- open_uri_with's production launcher now races the desktop's answer
  against a real gio::Cancellable armed by a glib::timeout_add_local_once
  (OPEN_URI_TIMEOUT, 10s, same class as RUN_COMMAND_TIMEOUT), so a launch
  GLib never completes still reports ok: false with a "timed out" outcome
  exactly once. The race and its once-guard are pulled into
  launch_with_timeout, unit-tested synchronously (no real GLib main loop,
  which is a process-wide singleton the default parallel test harness
  cannot share across blocking waits).
- the accepted-for-launch info line and the audit record now both carry
  the (truncated, sanitized) URI on every path, not just a refused one -
  previously an opened link left no trace in either.
- one doc sentence on Input::EffectResult in hytte-plugin: RunCommand,
  OpenUri and the consent round-trip share one correlation id space, so a
  plugin must allocate every id from a single counter.

Refs #1060 #1049 #1045

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0157rpXf1ks2hZEfdB47UJ6v
vibechoom added a commit that referenced this pull request Sep 10, 2026
…recise live-verify OpenUri rows (#1060 review)

Folds in the adversarial review on PR #1066 (f6ed4d3):

- F1: adds the reviewer's supplied test exercising the ~100%-production
  order (start's own callback answers first, the timer fires late into
  an already-empty cell) — the two existing tests both had the timer
  win, so the timer-side once-guard at effects.rs:877 was unfalsified.
- F3: launch_with_timeout's arm_timer now returns a cancel handle
  (TimerCancel), invoked when start's own callback wins the race so the
  still-armed timer source is torn down instead of firing into a no-op
  OPEN_URI_TIMEOUT later while holding the Arc<Mutex<..>> and the
  Cancellable open. Production wires it to glib::SourceId::remove; a
  new test asserts the cancel handle is actually called.
- F2: docs/live-verify.md's OpenUri rows no longer certify the pre-fix
  behaviour. The happy-path rows now carry uri=; the hung-launch row
  states the ~10s (OPEN_URI_TIMEOUT) bound and names this as the one
  place anywhere that can observe the real launch_default_for_uri_async
  wiring, since no test reaches it.
- N1: truncate_uri_for_log appends a trailing … when it actually cuts a
  URI, so the audit trail and journal never record a destination
  shorter than what the plugin supplied without saying so. Scoped to
  the URI log/audit call sites only — the shared
  truncate_on_char_boundary keeps its exact-length contract for
  RunCommand output truncation.

Refs #1060 #1049

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0157rpXf1ks2hZEfdB47UJ6v
vibechoom added a commit that referenced this pull request Sep 10, 2026
…, document the shared EffectResult id space (#1060) (#1066)

Residue from the OpenUri host arm (#1049): an outstanding launch is now bounded by a 10 s timer racing the GLib callback through a once-guard (the winner cancels the loser), the opened URI is logged on the success path and in the audit record with a truncation marker, and the shared EffectResult id space is documented. Review MERGE after one fold-in.

Closes #1060
Refs #1049 #1045

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0157rpXf1ks2hZEfdB47UJ6v
vibechoom added a commit that referenced this pull request Sep 10, 2026
…apability mapping, an SDK-side drop-and-warn guard, and a Render.hidden_on shape cap (#1058)

Three seams, all from #1049's and #1068's review rounds:

- proto: Effect::required_capability() is the one effect→capability mapping,
  exported and exhaustive (a new Effect variant fails to compile until it's
  mapped). The host's session::enforce_capabilities now calls it instead of
  keeping a private copy; effect_capability is gone, and its host-side
  duplicate test moved to the proto crate's own exhaustive test.
- hytte-plugin: the session loop drops an effect the manifest didn't declare
  the capability for, before it reaches the wire, warning once per effect
  kind per session (not once per frame) rather than a debug_assert! that
  would kill a release plugin on a typo. Closes the #437 crash-loop shape for
  any Rust-SDK plugin: Register decodes fine on an old host, but an
  undeclared capability's variant used not to survive the first render frame.
- trollshell: Render.hidden_on gets the shape cap PR #1068's review flagged
  (INFO finding) — at most 64 connector names, each at most 64 bytes — since
  it shipped bounded only by the 16 MiB frame limit. A violation degrades to
  the empty set (card shows everywhere, #1050's own safe default) with one
  warn naming the plugin and the offending count/length; it never kills the
  session.

Refs #1058, #1049, #1068.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0157rpXf1ks2hZEfdB47UJ6v
vibechoom added a commit that referenced this pull request Sep 10, 2026
…Log frames, latch the hidden_on cap, and fix merge-order/LOW findings (#1058 fix round)

Adversarial review on PR #1084 (FIX-FIRST) — #1084 (comment):

- HIGH-1: `drop_ungranted_effects`'s `tracing::warn!` reached nobody — no
  plugin binary installs a subscriber, and the SDK-side drop silenced the
  host's own warn/audit/violation-count for the same mistake. It's now a
  pure fn returning diagnostic messages; the session loop sends each as a
  `PluginMsg::Log{Warn}` frame (the host already routes these through its
  own `tracing::warn!`) and `eprintln!`s it, matching every other
  author-facing line this runtime emits. `tracing` is no longer a dependency
  of `hytte-plugin` at all (Cargo.lock's edge is gone). SDK tests now assert
  the Log frame on the socketpair instead of counting tracing events, which
  also retires the two hand-rolled `Counting` subscribers this PR had added
  (MEDIUM-4).
- MEDIUM-1: `capped_hidden_on`'s only production call site was untested —
  deleting it left `cargo test -p trollshell` green. Added
  `an_over_cap_hidden_on_reaches_the_mailbox_empty`, driving a 65-entry
  Render through `handle_conn` on the real socketpair harness and asserting
  the mounted card's `hidden_on` is empty.
- MEDIUM-2: the cap warned on every violating frame (~30/s at the SDK's
  view-rate cap). `capped_hidden_on` is now pure too, returning at most one
  `(HiddenOnViolation, message)` per call; a new per-connection
  `hidden_on_warned` latch (mirroring the SDK's own `capability_warned`)
  means the caller only sees a violation the first time each kind trips.
  The two hidden_on tests now assert this directly off the return value
  (two frames, one report) — no tracing capture needed.
- MEDIUM-3: fixed the merge-order hazard with #1083 (adds `output` to
  `Input::Event`, `#[non_exhaustive]`) — `Linker::update`'s
  `Input::Event { node, kind }` arm didn't exist when #1083 edited every
  other one in the tree; added `, ..` so either merge order compiles.
- LOW-1: an all-dropped effects step no longer sends a frame (unlike a step
  where `update` itself returned no effects), so the host's
  `runtime_render` freshness signal went dark for that step. `send` is now
  decided on the *pre-guard* effects, before the capability filter strips
  any — a step whose only output was an ungranted effect still puts an
  (effect-less) frame on the wire.
- LOW-2: doc fix — the cap bounds retention (`SlotRender` storage, the
  per-monitor reconcile compare), not the decode-time allocation, which the
  16 MiB frame limit already bounds.

Refs #1058, #1049, #1068.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0157rpXf1ks2hZEfdB47UJ6v
vibechoom added a commit that referenced this pull request Sep 10, 2026
…ard that drops an ungranted effect and says so, and a shape cap on Render.hidden_on (#1084)

One exported effect → capability mapping, and the SDK stops an author's mistake before it reaches the wire. Effect::required_capability in hytte-plugin-proto is exhaustive over the effect vocabulary (a new variant fails to compile until mapped); the host's enforce_capabilities calls it instead of its private copy, arm for arm identical. In hytte-plugin, drop_ungranted_effects compares every emitted effect against the manifest the plugin registered with and drops any whose capability the manifest omits — so an older host that predates the effect's variant never sees an undecodable frame (the #437 crash-loop shape) — and reports the mismatch once per effect kind per session as a PluginMsg::Log warn frame the host routes through log_plugin, plus the plugin's own stderr; the SDK takes no tracing dependency. The host caps Render.hidden_on at 64 entries of at most 64 bytes, degrading to empty with one report per cap kind per connection rather than killing the session; the cap's only call site is exercised through handle_conn. An all-dropped step still sends its frame, so rendering and last_seen keep refreshing.

Review found the first cut's warning reached nobody (no plugin binary installs a subscriber) and the cap wiring untested; both fixed and verified. Because the drop is client-side, the host's DroppedUngranted audit record and violations counter do not fire for a mismatch a current SDK catches — the Log frame is the signal now.

Closes #1058. Refs #1049 #1045 #1068 #437

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0157rpXf1ks2hZEfdB47UJ6v
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant