Skip to content

docs(fork): plugin GUIs as a button, not a plugin API - #3

Open
s243a wants to merge 11 commits into
mainfrom
claude/plugin-gui-design
Open

docs(fork): plugin GUIs as a button, not a plugin API#3
s243a wants to merge 11 commits into
mainfrom
claude/plugin-gui-design

Conversation

@s243a

@s243a s243a commented Aug 20, 2026

Copy link
Copy Markdown
Owner

T3 has no plugin system. Providers are a static array resolved at build time and nothing loads code at runtime, so "add a plugin" means editing T3's source — a fork patch, and one more thing to carry across every upstream rebase.

But most of what people want to add are interfaces to something already running elsewhere: a peer directory, a tunnel manager, a file-transfer view. Those do not need to be inside T3. They need a way in, and a way to be opened.

So this designs a button that opens a page, and nothing runs inside T3.

Why it is mostly assembly

  • DesktopWindow.ts already intercepts webview attachment and forces sandbox, no Node integration, and a partition T3 assigns — refusing any attachment it does not recognise.
  • httpCors.ts already sets access-control-allow-origin: *, safe because bearer scopes authorise rather than origin.
  • RpcAuthorization.ts already throws at startup for any RPC without a declared scope, and the scopes are separable enough to narrow usefully.
  • PreviewAutomationHosts.tsx already calls webview.executeJavaScript(...).

The idea

The trust boundary is asymmetric — the host can reach into the page, the page cannot reach into the host — so the design uses that rather than working around it. T3 injects a short-lived, narrowly-scoped token as it opens the window. The page never asks for a credential, so there is no "fetch token" endpoint to attack.

It is the same mint-on-demand shape as the peer-fabric work, across a different boundary: hold the grant, make the credential when it is needed, let it expire.

Note the arrows that do not exist: the local service never talks to T3 and T3 never talks to the local service. Each stays independently useful, and the page — the most disposable part — is the only thing that knows both.

Rules it records

Scope down (a peer directory needs access:read, never terminal:operate); bind the token to the window and expire it; a partition per plugin; and care about the direction of store — T3 accepting a credential from a page would make the page's compromise T3's, so the narrow version records where a machine is and lets T3 mint its own.

And what it is not

Not a plugin API — a plugin gets a rectangle and a token, and cannot add a provider, command or view. Not a way to run untrusted code safely: the scoping bounds damage rather than preventing it. Not mobile, since webviews are a desktop mechanism and the token would have to arrive another way.

First consumer is peerhailer, which already serves such a page on loopback and needs no changes to be used this way.

Model: Claude Opus 5 (1M context), harness: Claude Code.

s243a and others added 11 commits August 20, 2026 16:45
T3 has no plugin system and adding one is a large change to a fast-moving
codebase. But most of what people want to add are interfaces to something
already running elsewhere, which need only a way in and a way to be opened — so
the design is a button that opens a page, and nothing runs inside T3.

The mechanism already exists. Webview attachment is intercepted and forced to
sandbox with no Node integration, in a partition T3 assigns, refusing any
attachment it does not recognise. The API sets CORS to `*` deliberately, since
bearer scopes authorise rather than origin. Every RPC declares a scope, enforced
at startup.

The idea is to use the asymmetry rather than work around it: the host can reach
into the page and the page cannot reach into the host, so T3 injects a
short-lived narrowly-scoped token as it opens the window. There is then no
"fetch token" endpoint to attack, because nothing needs to ask.

Records the rules that keep it honest — scope down, bind to the window, a
partition per plugin, and care about the direction of "store", since T3
accepting a credential from a page would make the page's compromise T3's. And
what it is not: not a plugin API, not a way to run untrusted code safely, not
mobile.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NJkDp6cPBBu2aGCsMHfv6N
The first design put a scoped token in the page. Inverting it removes the token
entirely: the plugin publishes events, T3 consumes them and acts on its own
behalf, and the page never calls T3's API — nothing to leak, no scope to get
wrong, no expiry to manage.

The polling cost that suggests is avoidable. Server-Sent Events give one
long-lived GET over plain HTTP, surviving the webview sandbox and reconnecting
by itself, so an idle plugin costs a socket rather than a request every few
seconds all day.

What the inversion asks for instead is written down, because removing the
credential moves the burden rather than deleting it. T3 must authenticate the
source, since anything able to occupy a loopback port could otherwise feed it
events — the plugin signs what it emits with a key pinned when it is configured,
which peerhailer already has. Events need ids and acknowledgement, since
delivered-twice is normal for a stream that can drop while acted-on-twice must
not be. And a stream is a queue somebody else fills, so bounded buffers, a rate
limit, and the ability to mute a plugin without removing it.

An event is a request rather than an instruction: "this machine is at this
address" is a fact T3 may record, "store this token" is a demand it should
refuse, since honouring it makes the page's compromise T3's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NJkDp6cPBBu2aGCsMHfv6N
An earlier draft said T3 should refuse "store this token" on principle. That is
too strong and it breaks the feature: discovering another T3 instance is useless
if T3 cannot then connect to it, and connecting needs a credential that has to
arrive somehow. Refusing all of them leaves the user copying pairing URLs by
hand, which is the friction this exists to remove.

The distinction is which way the authority points. A credential over *this*
machine — a session here, an entry in its authorised clients — must never come
from a plugin, since accepting one makes the page's compromise this machine's. A
credential over *another* machine is outbound authority: it lets this T3 act
there and grants nobody access here, so it is closer to a bookmark with a key.

That has its own risk, and a different answer. The danger is being lured
somewhere hostile rather than escalation, so provenance travels with the
environment, connecting stays a deliberate act rather than automatic, and the
credential is minted at the far end on demand and briefly — the same rule the
directory already follows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NJkDp6cPBBu2aGCsMHfv6N
Records the motivation the design was missing: this is not only convenience.
Today a pairing token moves by clipboard, which any process can read without
permission, which clipboard managers persist to disk unencrypted, and which in
practice means a chat message or an email whenever the two machines are not in
the same room — putting a credential on somebody else's server, logs and
backups.

The peer path improves on all of it. The credential travels over a channel
already authenticated by key between machines that have each admitted the other,
minted on demand, short-lived, stored nowhere in between, and impossible to
deliver to the wrong machine because the channel knows which machine it is
talking to.

Two caveats so it is not oversold. An intermediary must not be able to read it,
which holds for a direct exchange and needs end-to-end encryption once a relay
carries it — as the peer-fabric design already requires of relaying. And
automation removes the one virtue copy-and-paste has, that a person sees what is
being moved and where it goes, which is why provenance and a deliberate connect
step are what replaces that check rather than polish on top of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NJkDp6cPBBu2aGCsMHfv6N
Once a plugin may do more than display a page, "is it installed" stops being one
question: showing a directory and handing T3 a credential are not the same
grant, and one switch makes the smaller carry the weight of the larger. So a
plugin holds a level and each capability names the level it needs — the peer
fabric's capability profiles arrived at from the other end.

Records why read outranks store, which is counterintuitive. Storing gives this
T3 outbound authority over a machine the plugin chose: nobody gains access here,
it is visible in the environment list, and deleting an entry undoes it. Reading
takes authority the user already established and hands it to software — covering
machines the plugin never discovered, surviving its removal, appearing nowhere.
That is exfiltration, and it does not undo.

Proposes a refinement. The real need behind "read token" is inviting another
machine to connect to this one, which is met by minting a fresh scoped
credential at the moment of the invitation. So the top level is `invite` rather
than `extract`: a plugin may ask T3 to make a credential, never to read the ones
already stored, which removes the exfiltration primitive while keeping the
feature.

And what the manager must do: list and pin keys, change a level with the
consequence stated in the interface, show what each plugin actually did, and
mute without removing — the equivalent of blocking a peer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NJkDp6cPBBu2aGCsMHfv6N
…ough

The manager should warn when a plugin is raised to `offer` or `invite`, and
getting that right is mostly restraint: a warning shown too often is one nobody
reads, and the habit transfers to the warning that mattered.

Warn on the change rather than the use, since every action afterwards is a
consequence of a decision already made and prompting each time makes dismissal
reflex. Say the consequence rather than the category — "this plugin will be able
to let other machines connect to this one" is a sentence someone can weigh,
which is the whole point of asking. Do not warn about the harmless levels, which
is what makes warnings worthless. Open with the change not yet made and nothing
pre-selected toward elevation.

Warn again when a plugin's URL or signing key changes, since that is a different
plugin wearing a familiar name and the level it inherited was granted to
something else. And mark elevated plugins wherever they appear: the warning is
the moment, the marking is the memory, and a one-time dialog leaving no trace is
a grant nobody can review later.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NJkDp6cPBBu2aGCsMHfv6N
"Signed" was doing two jobs. A service key answers whether this is still the
thing that was configured: it signs emitted events, is pinned on add, checked
every exchange, and proves continuity rather than provenance. A publisher key
answers whether it came from who it claims, and for a plugin that is a URL what
gets signed is a manifest — name, URL, expected service key, level requested —
checked against a key published where an attacker would have to compromise
something separately. They compose; neither substitutes for the other, and a
manifest checked at install says nothing about the service answering next week.

The registry keeps keys after uninstall unless cleared, which turns a reinstall
into a comparison rather than a fresh first impression. That is known_hosts, and
worth copying with its lessons: the warning must be specific and rare, since
SSH's looks identical whether a key rotated or a machine was replaced; rotation
needs a path other than deleting the entry, cleanly a rotation statement signed
by the old key; and removal is not forgetting.

Proposes corroboration instead of an authority. A machine with the peer fabric
can ask peers it already trusts what key they hold, so a substitution must reach
all of them. Recorded with its limits: corroboration is not authority, and
correlated compromise defeats it, since machines that fetched from the same
poisoned source agree perfectly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NJkDp6cPBBu2aGCsMHfv6N
Recorded as deferred rather than dismissed, with the change it depends on. A
plugin here is a URL, and what a URL serves can differ on every load, so there
is no artifact to sign: pinning a page hash fights every legitimate update, and
a signed manifest attests to what a plugin claims rather than to the code it
runs.

Code signing becomes meaningful once a plugin is distributed as an artifact —
downloaded, verified against a publisher key, served locally from the verified
copy. That is a coherent model with its own weight, and should be adopted
because artifact distribution is wanted rather than to make a signature possible.

Meanwhile the publisher key says where a plugin came from, the service key says
it is still the same thing, and the trust level bounds what a compromised one
could do. Signing code would narrow the remaining gap — a legitimate publisher
serving altered code — which is real and not the likeliest one to be exercised.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NJkDp6cPBBu2aGCsMHfv6N
Upstream has six open plugin PRs, none merged, two of them spikes comparing
runtime models, plus a competing approach from another contributor. Their spikes
predate our document by two days and the community PR by nine — ours was last,
not first, so their contract is the one to conform to.

What survives contact with it. The core inversion does not: our document argues
at length for the plugin emitting and T3 polling, and upstream reaches the same
safety property by having plugins declare contributions the host invokes under a
generation check — no event loop, no polling overhead, none of the machinery our
own document worried about. That section is superseded.

The trust-level argument survives and has nowhere to live yet: the runtime
decides what is live and who may invoke, and says nothing about which plugin may
ask for what. That is worth keeping and eventually proposing as a layer above a
runtime that exists, not a competing one. Signing and registry are untouched.

And their rule that executable values never cross the RPC boundary is stricter
than ours — we worried about T3 reaching into a plugin and they forbid the
reverse too. Worth adopting rather than arguing with.

The conclusion for peerhailer is that it does not need a plugin for the thing it
is for: T3 already reaches a remote instance through a saved environment, and
what is missing is safe delivery of the credential, which is a peer links job.
A plugin removes a paste rather than unlocking a feature.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NJkDp6cPBBu2aGCsMHfv6N
Answering the question concretely, since it is the thing a plugin would be for.
A command takes no arguments beyond its id and returns a message of at most 500
characters plus a tone. Nothing structured comes back and nothing lets a plugin
write host state.

A handler could act — it runs host-side in the plugins own Effect scope, so it
could reach a local peerhailer daemon and fetch a grant. It cannot hand the
result to T3. Putting a credential in the message means a bearer token in a
toast, which is the clipboard problem with worse ergonomics and a longer life.

Doing it properly needs a slot that does not exist: a credential or environment
provider, invoked to obtain a connection rather than to show a message. Added to
what to watch.

This settles the earlier conclusion for a better reason than convenience —
peerhailer does not need a T3 plugin for the credential path because the plugin
system could not do it even if it were merged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NJkDp6cPBBu2aGCsMHfv6N
Short term, build neither a plugin system nor the credential path a plugin would
have carried. Four planned things turned out to exist: short-lived one-time
tokens defaulting to five minutes, QR delivery that touches no clipboard,
--tailscale pairing through an HTTPS tailnet URL, and Tailscale as the
authenticated channel. Each is faster to adopt than to write and none is ours to
maintain. The one worthwhile contribution is t3 pair --json, since the command
renders a terminal QR unconditionally and anything scripting it is grepping ANSI
art.

Medium term, decide between upstream runtime and our own against criteria rather
than taste: whether contributions gain a permission dimension for the trust-level
argument to attach to, whether a slot appears that returns data rather than a
500-character message, and whether the model settles at all given two of six open
PRs are spikes comparing runtimes.

And the lesson, since it cost hours: check what exists before designing what to
build. The fabric real justification survived — driving an agent on another
machine, reviewed by a human, is not something Tailscale or T3 provides — and the
credential story did not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NJkDp6cPBBu2aGCsMHfv6N
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