feat(plugins): an OpenUri effect + capability so a plugin can open a link without RunCommand (#1045, host arm) - #1049
Conversation
Adversarial review — PR #1049 @ f493a68Verdict: 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 Neither is a wire-format problem, and F1's fix is a one-function swap already in FindingsF1 — MEDIUM-HIGH: a plugin-supplied URI does synchronous I/O on the GTK main loop
GLib 2.88.3's own documentation for the call, read out of the GIR shipped by the devShell's glib (
and on the async variant (
Failure scenario. A plugin holding Why "the same call Structural tell. Second-order. Per the async doc above, the sync variant does not carry real error information on the portal path. Fix. Test. Nothing hermetic reds today, and that is the point: F2 — MEDIUM: "the #437 hazard cannot arise here" is an absolute; the truth is conditionalAsserted in four places: The chain is (a) an effect only reaches a host from a plugin that declared its gating capability, therefore (b) since (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
Failure scenario. A plugin built on this SDK emits Why this deserves naming rather than waving off. Per the history of the counter: I am not asking for Test to add, beside the two mirror-decoder tests in Follow-up worth filing (out of this PR's lane): a F3 — LOW: the 4 KiB cap's boundary is unpinned
Mutation M8 (mine): Fix — two rows in F4 — INFO: the capability array is one element from a MessagePack width crossing
F5 — NIT:
|
| fixture | old → new | differing regions | what moved |
|---|---|---|---|
host_msgs_v1 |
1401 → 1401 B | 1 | byte 1400: 04 → 05 (Hello { vocab }) |
manifest_full_v1 |
375 → 383 B | 4 | frame-length low byte 73→7b; byte 40 04→05 (vocab_max); byte 147 9e→9f (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 3b→77; byte 1101 10→11 (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).
VOCAB4 → 5 with a generation marker is correct and is whatHelloadvertises —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 stampedvocab(1, or 0 for a pre-counter manifest) against the host'sVOCAB= 5, so it passes.Hellocarries 5 to any plugin declaringvocab_max, and the plugin clamps withnegotiated_vocab = min(vocab_max, host_vocab)(manifest.rs:505-507) — avocab_max: 4plugin negotiates 4 and cannot be talked into emitting generation 5. That direction is clean. - "An unknown
CapabilitykillsRegister" is true.Capabilitycarries no#[serde(other)]andManifest::capabilitiesis a plainVec<Capability>, so an unknown variant name fails the whole body — confirmed independently, and consistent withCapability::Shader's stated price (manifest.rs:216-219). The mirror-decoder tests both carry proper controls. - What does not follow is that an
OpenUritherefore 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: OpenUri → Capability::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 atsession.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 everyEffectupstream of the broker, soOpenUrishares the 8-token burst / 1-per-second refill (session.rs:189,:194). One asymmetry worth knowing: an effect dropped by either gate never yields anEffectResult, 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 (RunCommandhas 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 — theOpenUrivariant doc states plainly that it is not a sandbox, thatfile:///…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_ed25519opening in an editor is no privilege gain over the socket the plugin already holds. Correctly framed as a legibility guard.https://user:pass@hostis 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.\nand NUL in the path are refused asControl, pinned ateffects.rs:2477-2478. The hand-writtenuri_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_urireads 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.
…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
f493a68 to
f73c4b6
Compare
Fix round — PR #1049 @
|
| # | 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: OpenUri → Capability::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
Second pass — PR #1049 @ f73c4b6Verdict: 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 verificationF1 — the async launch, for real(a) The report sink from the GLib callback is correct. One hazard I went looking for and did not find: (b) Plugin disconnects mid-launch → (c) Two in flight → no shared mutable state. (d) The M9 test measures the ARM, not the test. The And the layer the PR cannot test. I pulled GLib 2.88.3's Also verified, since the arm relies on it: the gio binding's F2 — the residual test asserts both halves
One immaterial deviation from the spec I wrote: half two decodes a bare The prose sites now state the conditional truth ( F3 — the cap boundary is pinned
F4 — the fixarray crossing
F5 / F6
Fixture diff — re-verified independently at f73c4b6Decoded both sides to raw bytes,
No pre-existing byte moved position or value. New findingsN1 — LOW: an outstanding launch has no timeout;
|
| # | mutation | site | exit | reds |
|---|---|---|---|---|
| M1 | effect_capability: OpenUri → Capability::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
…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
…, 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
…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
…, 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
…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
…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
…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
What
The host arm of #1045: an
Effect::OpenUri { id, uri }+Capability::OpenUriso a plugin can open a link through the desktop's default handler without holdingRunCommand.Effect::open_uri(id, uri)constructor,Capability::OpenUri,OPEN_URI_VOCAB = 5,VOCAB4 → 5.session::effect_capabilitymapsOpenUri→Capability::OpenUri;broker_effectgains an arm that validates the scheme, starts the launch viagio::AppInfo::launch_default_for_uri_asyncwithout waiting for it, audits like every effect, and reportsHostMsg::EffectResult { id, ok, output }back the wayRunCommanddoes.docs/live-verify.mdgets the eight checks CI cannot run.The plugin side (turning #963's
agent pagerow into a button) is deliberately not here — it rides #963's rebase round.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 declareCapability::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.OpenUriis 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 resolutionmain.rs's screenshot / recording Open actions already use. In the trust order documented on theCapabilityenum it sits aboveNotify(it starts something) and well belowRunCommand(it cannot choose what).What the allow-list is and isn't:
http/https/fileonly, everything else refused with a warn andok: falsenaming 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 ineffects.rsalone — no proto bump, no plugin rebuild. (#1045's own triage note parksmailto: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_effectruns insidemod.rs'sspawn_localon the GTK main thread, so on that path a plugin emittingEffect::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.fileis 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 isgio::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(…) -> EffectOutcomeencoded "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 aLaunchDonecallback and the outcome goes toreport; nothing waits. The refusal arm still reports inline, becausecheck_uriis pure and does no I/O.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, whichbroker_effectsatisfies) and GLib calls back on the main context.EffectOutcome { ok }is the desktop's verdict rather than "the request was accepted" (the review's second-order F1 point).broker_open_uri_withis the injected seam a test drives, mirroringstart_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
VOCABdecisionAn older host cannot decode an appended variant at all — it is a decode failure that kills the whole frame, not a skip.
codec::decode_bodyisrmp_serde::from_slice, the enum representation is pinned to external tagging with no#[serde(other)], andEffecthas 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 byan_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 bumpVOCAB(the census). Bump this too only if a plugin may emit the variant with no advertisement":VOCAB4 → 5. An appended wire variant always moves the census.OPEN_URI_VOCAB = 5is its generation marker, likeSHADER_VOCAB/SCROLLED_VOCAB/PREEM_VOCAB.VOCAB_UNCONDITIONALstays 1 — but the argument is conditional, not absolute, and the first revision of this PR stated it as an absolute in four places (review F2). This is the first appended plugin→hostEffectvariant since the counter existed (fix(proto): plugin-to-host vocab skew is an undetectable crash-loop — add a vocab counter to the handshake #437 landed afterRequestConsentand the datasource legs) and the first gated by a capability rather than aHello, so the precedent is being set here and it is written out rather than borrowed by analogy:For a plugin that declares the capability it emits, the fix(proto): plugin-to-host vocab skew is an undetectable crash-loop — add a vocab counter to the handshake #437 hazard cannot arise: the host only brokers an effect whose gating cap the manifest named,
Capability::OpenUriis itself undecodable pre-feat(plugins): an OpenUri effect + capability so a plugin can open a link — the agents card shows a URL nobody can follow (#963 retest) #1045, so that plugin'sRegisterframe fails to decode and the connection is dropped loudly at the handshake before any render frame could carry anOpenUri. Pinned byan_older_host_cannot_decode_an_open_uri_capability.That condition is a plugin-authoring property, not a wire property, and nothing enforces it — the SDK says so itself (
hytte-plugin/src/lib.rs: "nothing SDK-side gates which effects an author can emit"). A plugin that emitsOpenUriand forgetsCapability::OpenUriis already broken on a feat(plugins): an OpenUri effect + capability so a plugin can open a link — the agents card shows a URL nobody can follow (#963 retest) #1045 host (the effect is dropped with a warn and noEffectResultever comes back, so a click silently does nothing) and broken worse on an older one:Registersucceeds, and the first render frame carrying the effect fails to decode → the fix(proto): plugin-to-host vocab skew is an undetectable crash-loop — add a vocab counter to the handshake #437 crash-loop. Now pinned as a fact byan_undeclared_open_uri_still_registers_on_an_old_host, which asserts both halves.Moving the ceiling to 5 would convert that into a loud handshake refusal — and would also refuse every other plugin rebuilt on this SDK against every older shell, including all the ones that never open a link. That is the trade feat(plugins): RunCommand children die with the shell — a detached spawn mode via systemd-run --user for launched terminals (#947) #953 called "strictly worse" and feat(proto): typed preem widget state vocabulary + versioning #882/feat(preem): shader widget — plugin-supplied shader code + data buffer (GL-era Pixels successor) #893/feat(plugin): three node-vocabulary gaps a real list card hit — Row spacing, dense ListBox rows, a bounded scroll viewport #966 each declined: a guaranteed compat break for everyone, to catch one authoring bug that already misbehaves visibly on a current host. So the ceiling stays at 1 and the residual is named in all four places instead of waved off, so the next appended
Effectvariant does not inherit an absolute that was never true.A follow-up the reviewer suggested and this PR does not do (out of lane): a
debug_assert/warn inhytte-plugin'srun()whenupdate()returns an effect whose capability the manifest omits. That would close the hole for every effect and make the census-only argument true by construction.The price for a correct plugin is the one every appended capability has paid since the first, stated on
Capability::Shader: declaringOpenUricosts compatibility with a pre-#1045 host, which drops the connection with aplugin handshake read failedwarn naming the undecodable variant.the_open_uri_generation_bumps_the_census_onlycarries the "newest variant" equality (VOCAB == OPEN_URI_VOCAB) that used to live onSCROLLED_VOCAB, per that test's own instruction;the_viewport_generation_bumps_the_census_onlyrelaxes 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_fixturesrun, then diffed byte-structurally (common prefix/suffix + the differing middle):host_msgs_v1Hello { vocab }04→05manifest_full_v1vocab_max04→05, capability array header9e→9f, appendeda7 "OpenUri"plugin_register_v1plugin_render_v1dc 00 10→dc 00 11, appended81 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).
9e→9fis a 15-elementfixarray; the 16th capability crosses toarray16(dc 00 10, three bytes) and shifts every byte after the capability array inmanifest_full_v1andplugin_register_v1. Not a compat break —rmp-serdereads both — but the next author gets a diff that looks alarming and is not. Documented at theCapabilityenum and ingolden.rs's regeneration note, and pinned bythe_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:
uri.len() > MAX_URI_BYTES→>=f493a68— review F3)ok: falseseen/callsassertions onlyan_undeclared_open_uri_still_registers_on_an_old_hostCapabilityE0004non-exhaustive — the intended alarmThe 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 theseenassertion 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::OpenUriparsing 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_effectend to end for a refused scheme (EffectResult on the connection's channel + the audit line withid=and nounit=); the audit correlation id; and, at the enforcement seam, that an un-cappedOpenUriis dropped and thatRunCommand/OpenUrido not substitute for each other in either direction.The launcher is injected into
open_uri_with(and one level up intobroker_open_uri_with) the waystart_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
nix fmt -- --no-cachecargo clippy --workspace --all-targets --features system-tests -- -D warningscargo test -p hytte-plugin-proto -p hytte-plugin -p trollshellxvfb-run -a cargo test --features system-tests -p trollshell pluginsLive-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 testnever starts a browser.docs/live-verify.mdgets eight checks for it — the happy path (browser opens, info log, audit line with nounit=), a refused scheme reaching the plugin as a toastableEffectResult,file:opening a viewer, an uppercaseHTTPS://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
docs/plugin-env.mdis deliberately untouched: it is an environment-variable reference (swept fromstd::env::varcall sites, docs: structured per-plugin env-knob reference — full sweep + own page (#558 follow-up) #573) and lists no effect vocabulary. The effect roster lives in the proto rustdoc andtrollshell/src/plugins/mod.rs, both updated here. The frontend-B spec'senum Capability { OpenPage, Niri, Media, Audio, RunCommand }sketch is already stale by ten capabilities and outside this PR's lane; left alone.region.rsuntouched (PR fix(plugins): a plugin that renders an empty tree hides its chip pill (#1039) #1042 landed on it and is in this branch's base after the rebase); no plugin crate touched.Refs #1045 #963 #947
🤖 Generated with Claude Code
https://claude.ai/code/session_0157rpXf1ks2hZEfdB47UJ6v