Skip to content

App frames: openExternal(url) and openResource(subject) - #1744

Open
michielbdejong wants to merge 3 commits into
claude/installation-connectfrom
claude/frame-open-external
Open

michielbdejong wants to merge 3 commits into
claude/installation-connectfrom
claude/frame-open-external

Conversation

@michielbdejong

@michielbdejong michielbdejong commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Closes #1734
Closes #1735

Stacked on #1733 (claude/installation-connect).

Two FrameBridge ops for sandboxed app frames, so apps can offer "Open in Notion / Google Calendar / GitHub / Clockify" and "Open data table/row" without a fallback.

store.openExternal(url) → { status: 'opened' | 'cancelled' }

  • http(s) only; a user name or password in the URL is refused too (https://bank.example@evil.example).
  • The host draws a bar above the frame: "This app wants to open www.notion.so in a new tab.", with the full host (punycode for IDNs, so a look-alike can't pass) and the whole link under it. Open link / Cancel.
  • Only the person's click on Open link opens it, with window.open(url, '_blank', 'noopener,noreferrer'). The sandbox stays allow-scripts allow-modals, with no allow-popups.
  • A second ask before the person answers resolves the first as cancelled.

store.openResource(subject) → { status: 'opened' | 'cancelled', subject }

  • Accepts an Atomic resource identifier (atomic: / did:ad:, not an agent, commit, blob or node) or an http(s) resource URL. Anything else is refused before anything loads.
  • The host loads it with the person's own store first and refuses it if that fails. So the app can only send them to something they can already read.
  • It then asks, like openExternal: a bar above the frame, "This app wants to open <title>, leaving the app.", with the subject under it. Open / Cancel. Only the click on Open navigates, to /app/show?subject=….
  • One ask at a time across both ops: a second openExternal or openResource before the person answers resolves the first as cancelled.
  • Navigation always uses ?subject=, never a same-origin path, so an app can't reach a host route like /app/dev-drive.

Protocol, SDK, docs

  • openExternal and openResource are in ViewOperation / isViewRequest. OpenExternalResult and OpenResourceResult are exported from @tomic/plugin.
  • view-client.js: ops that wait on the person (openExternal, openResource, and now proxyConnect too) have no 60 s deadline, so a person who takes a while doesn't get a "host did not answer" error.
  • docs/src/plugins/custom-views.md: a new "App frames" section.

Tests

  • helpers/extensions/externalLink.test.ts (new): scheme allow-list, credentials, length cap, punycode host, noopener,noreferrer.
  • chunks/AppPage/hostStore.test.ts: openResource accepts readable resources, refuses unreadable ones, and refuses agents, commits, blobs, nodes and non-subjects without loading them.
  • browser/plugin/src/viewProtocol.test.ts: the generated client sends both ops and waits on the person without a deadline.
  • Checked by hand in a browser with a throwaway Playwright script against a real server and a test app:
    • Nothing opens before the click.
    • Cancel resolves cancelled.
    • Open link opens the link in a new tab with window.opener === null.
    • openResource refuses an agent and navigates to the app's table.

Checked by hand after adding the openResource confirm

A throwaway Playwright script ran against a real server built from this branch:

  • Nothing navigates before the click.
  • Cancel resolves {status: 'cancelled', subject} and stays on the app.
  • An openExternal ask followed by openResource cancels the link ask and shows only the resource bar.
  • Open navigates to the table.

Screenshots

The confirm bar, desktop (1280×720) and mobile (390×844). Shown to the developer in chat.

🤖 Generated with Claude Code

Sandboxed app frames could not open links or send the host anywhere.

- `store.openExternal(url)`: http(s) only, no credentials in the URL. The
  host shows a bar naming the destination host in full (punycode), with the
  whole link under it, and opens it only on the person's click, with
  `noopener,noreferrer`. The sandbox keeps no `allow-popups`. A second ask
  answers the first as cancelled.
- `store.openResource(subject)`: navigates the host to `/app/show` for an
  Atomic resource identifier or http(s) resource URL, after loading it with
  the person's store; agents, commits, blobs, nodes and non-subjects are
  refused before anything loads.
- Both ops are in the v1 view protocol and `@tomic/plugin` types; the
  generated client waits on the person without its 60 s deadline (also for
  `proxyConnect`).

Closes #1734
Closes #1735

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
michielbdejong and others added 2 commits September 28, 2026 18:48
Pre-existing on this stack (from #1700); it blocked every commit here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
openResource navigated the host page as soon as the resource loaded. It
now draws a confirm bar like openExternal: the resource's title, its
subject under it, and Open / Cancel. Only the click navigates.

- One ask at a time across both ops: a second openExternal or
  openResource resolves the pending one as `cancelled`.
- Refusals (not a resource, not readable) still happen before asking.
- OpenResourceResult is now `{ status: 'opened' | 'cancelled', subject }`.
- view-client.js: openResource waits on the person, no 60 s deadline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@michielbdejong

Copy link
Copy Markdown
Contributor Author

Merge-risk triage (2026-09-28). This is a read-only review of this PR's own diff. The rule it applies: nothing that works today may break for existing users or clients, and new behaviour is added alongside the old. The stack is being rebased onto develop as one chain before merging, so line references are against the current stacked base.

Verdict: SAFE (with 1545cab)

Both ops only raise a consent bar in the host; a new tab or navigation happens only in the click handler. openExternal allows http(s) only, refuses URLs with credentials, and opens with noopener,noreferrer. With 1545cab1a, openResource also asks before it leaves the app (the earlier review flagged that it navigated without a click). That commit is being carried into the rebased chain. Checking an openResource subject loads it through the person's store, which the existing get op already allows, so no new read path.

🤖 Generated with Claude Code

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

browser enhancement New feature or request plugin Should probably be an Atomic Plugin security

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant