Skip to content

Install agents from oap:// links in OAP Desktop - #41

Open
josephschorr wants to merge 28 commits into
mainfrom
feat/desktop-install-links
Open

josephschorr wants to merge 28 commits into
mainfrom
feat/desktop-install-links

Conversation

@josephschorr

@josephschorr josephschorr commented Oct 26, 2026 •

Copy link
Copy Markdown
Member

Stacked on #40, which publishes the signed example agents this installs. Retarget to main once #40 merges.

Motivation

We want an "Install in OAP Desktop" button on the docs pages for the example agents. Any web page or app can open a custom link, so it can't actually be one click. Desktop must ask the user every time, and it only accepts agents from sources the user trusts.

Summary of changes

  • Install links. OAP Desktop now responds to oap://install/agent?... links. The link format is versioned, so new kinds of links can be added later, and an older app tells the user to update instead of failing silently.
  • Trusted sources only. Desktop installs only from registry locations on an allowlist. By default that is this project's published agents. Agents from the project must also carry a valid project signature. Nothing is downloaded from a source that isn't trusted.
  • Always asks. Every link shows an approval dialog with Cancel as the default. It shows where the agent comes from and whether its signature checked out, and the agent's own name and description can't make it look more trustworthy than it is.
  • Clear feedback. While an install runs, a small window shows its progress. On success the browser opens a new chat with the agent selected; on failure an error dialog explains what went wrong. The existing "Install agent from .oap…" menu item now works the same way.
  • Robust start-up. A link that launches the app waits until start-up finishes. A link that arrives while the app is stopped or not yet set up is declined with a notification. A second copy of the app can no longer interfere with one that is already running.
  • Settings. A new "Install links" section lets users add, remove or reset trusted sources, or turn install links off entirely.
  • Docs. The agent pages have the install button, always alongside the equivalent CLI command, and the Desktop docs explain how install links work.

Alternatives considered

  • Trusting the web page that hosts the link: not possible. The app can't tell which page or app opened a link, so trust is based on where the agent comes from.
  • Trusting whole registries: too broad, since anyone can publish to a public registry. Trust is scoped to specific locations within a registry.

Known gaps (follow-ups)

  • Repeatedly declined links each produce their own notification.
  • "Install agent from .oap…" can run at the same time as a link install.
  • Quitting during an install can leave the progress window open.

Provenance

  • Author: Joseph Schorr

Ship gate

  • mage test:unit
  • mage test:integration
  • mage test:e2e

All three suites pass. A few unrelated tests timed out on a heavily loaded machine; each passed when re-run on its own and they are outside the code this change touches.

We also tested this by hand on a locally built OAP Desktop:

  • A link that launched the app installed once start-up finished.
  • A link while the app was already running prompted immediately.
  • The approval dialog showed a verified signature.
  • The progress window appeared, and the new chat opened with the agent selected.

Regeneration

  • Web UI changed → rebuilt and committed the web assets
  • n/a: CRDs, RBAC, config/, CLI docs

Coverage

  • The new behavior lives in the macOS app, which the scenario-bundle harness can't drive. It's covered by unit tests plus the manual run above.
  • Does not touch authorization or the SpiceDB schema

@vercel

vercel Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
openagentprimitives Ignored Ignored Preview Nov 5, 2026 4:36pm UTC

Request Review

@samkim

samkim commented Oct 29, 2026

Copy link
Copy Markdown
Member

Security review pass on the install-link flow (clickjacking, info leakage, user approval). Fixes are in 6f6d680.

Fixed

  • Hidden characters in the approval dialog. sanitize only stripped control characters, so format runes (U+202E right-to-left override, U+200B zero-width space, U+FEFF) got through. They can reorder or hide text, and Go's %q escapes them as \uXXXX, which AppleScript rejects as a syntax error (confirmed with osascript). A bundle containing one could never be installed from a link. Now every non-printable rune becomes a space.
  • Provenance. Link installs were recorded as SourceKind: "file" with no ref. They're now "registry" with the link's ref and the OCI digest that was verified or resolved, matching admind's registry installs.
  • Notification leaks and spam. The fallback "Couldn't download" message included the raw registry error; that now stays in the log. Link notifications are rate-limited (burst 3, then 1 per 20s), and dropped ones are logged, so a page firing oap:// links can't flood the screen.
  • Framing. LoopbackGuard now sends X-Frame-Options: DENY and frame-ancestors 'none', covering the setup and settings servers. A framed page's own requests carry the loopback Origin, so the Origin check alone didn't stop it.

Still open, not addressed here

  • The Install button works the instant the dialog appears, and there's no cooldown after a cancel. Impact is limited while the allowlist is only the signed project prefix.
  • The approval dialog doesn't list what the agent can do (tools, MCP servers, sidecars, network access, dependencies).
  • A second link during startup silently replaces the held one (only logged).
  • Signature checks don't stop a link from installing an older signed version over a newer one.
  • About 20 other osascript calls pass text through %q and have the same escaping problem, including the file-picker dialog with its unfiltered description.
  • "Reset to default" keeps the user's added sources, so the label overpromises. Left as is.

Tests: race-enabled tests pass for cmd/oap/internal/desktop/... and cmd/oap/internal/desktopcmd, and vet and build are clean. Integration and e2e weren't run, and the desktop app wasn't launched.

🤖 Generated with Claude Code

Base automatically changed from feat/desktop-install-protocol to main October 30, 2026 19:57
An error occurred while trying to automatically change base from feat/desktop-install-protocol to main October 30, 2026 19:57
@josephschorr
josephschorr force-pushed the feat/desktop-install-links branch from fc5907e to eefb1a5 Compare October 30, 2026 23:03
Register the Apple Event handler before the run loop and consume links on
their own goroutines. Each link is parsed, offered to the install gate under
the lifecycle lock (held while bring-up runs, refused when stopped/failed or
busy), fetched through linkinstall.Prepare with the allowlist re-read from
config.json, approved in an always-shown Cancel-default dialog, and installed
through installApproved, now shared with the .oap file picker.

phase() is factored out of the settings state closure and reports starting
from the moment bring-up is committed (so a cold-launch link is held) and
stopped after Stop (it previously kept reporting the last bring-up step).
Claim the link held during bring-up in the same critical section that flips
running, and launch it before the post-bring-up refreshes, so a link arriving
in that window is refused as busy instead of taking the gate. Every release
of the gate also drains any link still held, and a link held behind a busy
gate is logged. Add Gate.Pending for that diagnostic, and factor the stopped
transition into markStoppedLocked so it is testable.
oras dials ghcr.io:443, ghcr.io. and GHCR.IO as ghcr.io, but RefHasPrefix
compared hosts byte-exactly, so an allowlist entry spelled ghcr.io:443/...
admitted OAP agent refs that the signing-key pin treated as unpinned.
Compare canonical hosts (lowercase, one trailing dot stripped, explicit
:443 dropped), error on hosts with no unambiguous canonical form, and
refuse non-canonical hosts in ValidatePrefix so such entries can't be
saved.
With oap desktop running from a terminal, clicking an install link made
LaunchServices start the .app, whose startup orphan cleanup then killed
the terminal instance's live VM. Take an exclusive non-blocking flock on
<support>/desktop.lock first, hold it for the process lifetime, and have
a second instance log, notify and exit without touching the VM.
- A dialog that can't be shown is installDialogFailed, logged and
  notified on both the link and file paths, never a silent cancel; only
  osascript's -128 is a cancel.
- Link failures from a malformed install source, an unusable ref or the
  embedded signing key get their own messages instead of "Couldn't
  download".
- A ref that names a digest must resolve to that digest before any pull.
- Confirming Uninstall marks the cluster stopped and drops a held link
  with a notification, so links arriving mid-uninstall are refused.
- Log a GetURL Apple Event that carries no URL instead of dropping it.
A link clicked while OAP Desktop isn't open launches it and is held until
start-up finishes; links are refused only while it is stopped, failed to
start, or setup isn't finished.
ref is React's reserved element-ref attribute; carrying the OCI image
reference in it was fragile. Update the component, its test, the four
agent pages and the Markdown rendition rule, which gains a test row.
Extract the relay-animated OAP mark from the setup page into shared
relay.js/relay.css assets and serve a small /installing page that uses
them. The page reads the agent name from its query string and writes it
with textContent only, so bundle-supplied names never reach the markup.
josephschorr and others added 6 commits November 5, 2026 11:21
Add --width/--height to the hidden desktop-window command on every
platform, defaulting to the setup window's 760x640 so existing callers
are unchanged.
A launched .app's stderr goes nowhere, so a notification osascript
could not post was effectively invisible. runDesktop now routes those
failures to the desktop log as well.
After the user approves an install (oap:// link or the .oap picker),
open a small Installing window with the relay-animated mark, close it
when the install finishes, then open the New-chat picker with the
installed agent preselected. Failures, including a cluster that isn't
running, get a native dialog instead of an easily-dropped notification;
needs-config shows a dialog and then opens the dashboard. The adoption
notice is kept since the chat page doesn't carry it.
- Flatten every non-printable rune in the link approval dialog, not just
  control characters. Format runes (U+202E, U+200B, U+FEFF) could reorder
  or hide text, and %q escapes them as \uXXXX, which AppleScript rejects,
  so the dialog failed to show at all.
- Record link installs as registry installs with the link's ref and the
  OCI digest Prepare verified or resolved, instead of as a local file.
- Keep raw registry errors out of link notifications (they stay in the
  log) and rate-limit link banners, so a web page firing oap:// links
  cannot flood the screen. Dropped banners are logged.
- Send X-Frame-Options: DENY and frame-ancestors 'none' from LoopbackGuard,
  covering the setup and settings servers: a framed page's own requests
  carry the loopback Origin and would pass the Origin check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An Install chosen before the approval dialog has been up for a second now
shows the dialog again with a note, so a click or keypress already in
progress when a page fires a link cannot approve it. Cancelling the dialog
refuses links for ten seconds, so a page cannot immediately re-raise it.

A second link arriving during start-up no longer silently replaces the held
one: the first keeps its place and the second is refused with a notification.
Every osascript call spliced its text into the script source with Go's %q,
which is not AppleScript string syntax: a format rune such as U+202E became
‮ and the dialog failed to show with a syntax error, and the file-picker
install dialog passed a bundle's name and description through unfiltered.
Text now reaches the script as argv, never as source, so no input can break
or rewrite the script. The shared dialog sanitizer also strips format runes,
and the file-picker confirm dialog now flattens the bundle's name, version
and description like the link dialog does.
@josephschorr
josephschorr force-pushed the feat/desktop-install-links branch from eefb1a5 to a658bb5 Compare November 5, 2026 16:36

This branch was successfully deployed

1 active (outdated) deployment
Preview — 986ce673 Deployed Oct 26, 2026 by vercel[bot]
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.

2 participants